diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx
index c7af228299..588dff618a 100644
--- a/dotnet/agent-framework-dotnet.slnx
+++ b/dotnet/agent-framework-dotnet.slnx
@@ -91,6 +91,9 @@
+
+
+
@@ -310,6 +313,7 @@
+
@@ -341,6 +345,7 @@
+
diff --git a/dotnet/samples/Purview/AgentWithPurview/AgentWithPurview.csproj b/dotnet/samples/Purview/AgentWithPurview/AgentWithPurview.csproj
new file mode 100644
index 0000000000..8dc509efed
--- /dev/null
+++ b/dotnet/samples/Purview/AgentWithPurview/AgentWithPurview.csproj
@@ -0,0 +1,21 @@
+
+
+
+ Exe
+ net9.0
+
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet/samples/Purview/AgentWithPurview/Program.cs b/dotnet/samples/Purview/AgentWithPurview/Program.cs
new file mode 100644
index 0000000000..842917b427
--- /dev/null
+++ b/dotnet/samples/Purview/AgentWithPurview/Program.cs
@@ -0,0 +1,43 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+// This sample shows how to create and use a simple AI agent with Purview integration.
+// It uses Azure OpenAI as the backend, but any IChatClient can be used.
+// Authentication to Purview is done using an InteractiveBrowserCredential.
+// Any TokenCredential with Purview API permissions can be used here.
+
+using Azure.AI.OpenAI;
+using Azure.Core;
+using Azure.Identity;
+using Microsoft.Agents.AI;
+using Microsoft.Agents.AI.Purview;
+using Microsoft.Extensions.AI;
+
+var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
+var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
+var purviewClientAppId = Environment.GetEnvironmentVariable("PURVIEW_CLIENT_APP_ID") ?? throw new InvalidOperationException("PURVIEW_CLIENT_APP_ID is not set.");
+
+// This will get a user token for an entra app configured to call the Purview API.
+// Any TokenCredential with permissions to call the Purview API can be used here.
+TokenCredential browserCredential = new InteractiveBrowserCredential(
+ new InteractiveBrowserCredentialOptions
+ {
+ ClientId = purviewClientAppId
+ });
+
+using IChatClient client = new AzureOpenAIClient(
+ new Uri(endpoint),
+ new AzureCliCredential())
+ .GetOpenAIResponseClient(deploymentName)
+ .AsIChatClient()
+ .AsBuilder()
+ .WithPurview(browserCredential, new PurviewSettings("Agent Framework Test App"))
+ .Build();
+
+Console.WriteLine("Enter a prompt to send to the client:");
+string? promptText = Console.ReadLine();
+
+if (!string.IsNullOrEmpty(promptText))
+{
+ // Invoke the agent and output the text result.
+ Console.WriteLine(await client.GetResponseAsync(promptText));
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/BackgroundJobRunner.cs b/dotnet/src/Microsoft.Agents.AI.Purview/BackgroundJobRunner.cs
new file mode 100644
index 0000000000..d55c5a6a66
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/BackgroundJobRunner.cs
@@ -0,0 +1,72 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Threading;
+using System.Threading.Channels;
+using System.Threading.Tasks;
+using Microsoft.Agents.AI.Purview.Models.Jobs;
+using Microsoft.Extensions.Logging;
+
+namespace Microsoft.Agents.AI.Purview;
+
+///
+/// Service that runs jobs in background threads.
+///
+internal sealed class BackgroundJobRunner
+{
+ private readonly IChannelHandler _channelHandler;
+ private readonly IPurviewClient _purviewClient;
+ private readonly ILogger _logger;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The channel handler used to manage job channels.
+ /// The Purview client used to send requests to Purview.
+ /// The logger used to log information about background jobs.
+ /// The settings used to configure Purview client behavior.
+ public BackgroundJobRunner(IChannelHandler channelHandler, IPurviewClient purviewClient, ILogger logger, PurviewSettings purviewSettings)
+ {
+ this._channelHandler = channelHandler;
+ this._purviewClient = purviewClient;
+ this._logger = logger;
+
+ for (int i = 0; i < purviewSettings.MaxConcurrentJobConsumers; i++)
+ {
+ this._channelHandler.AddRunner(async (Channel channel) =>
+ {
+ await foreach (BackgroundJobBase job in channel.Reader.ReadAllAsync().ConfigureAwait(false))
+ {
+ try
+ {
+ await this.RunJobAsync(job).ConfigureAwait(false);
+ }
+ catch (Exception e) when (
+ !(e is OperationCanceledException) &&
+ !(e is SystemException))
+ {
+ this._logger.LogError(e, "Error running background job {BackgroundJobError}.", e.Message);
+ }
+ }
+ });
+ }
+ }
+
+ ///
+ /// Runs a job.
+ ///
+ /// The job to run.
+ /// A task representing the job.
+ private async Task RunJobAsync(BackgroundJobBase job)
+ {
+ switch (job)
+ {
+ case ProcessContentJob processContentJob:
+ _ = await this._purviewClient.ProcessContentAsync(processContentJob.Request, CancellationToken.None).ConfigureAwait(false);
+ break;
+ case ContentActivityJob contentActivityJob:
+ _ = await this._purviewClient.SendContentActivitiesAsync(contentActivityJob.Request, CancellationToken.None).ConfigureAwait(false);
+ break;
+ }
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/CacheProvider.cs b/dotnet/src/Microsoft.Agents.AI.Purview/CacheProvider.cs
new file mode 100644
index 0000000000..472b53c50b
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/CacheProvider.cs
@@ -0,0 +1,89 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Text.Json;
+using System.Text.Json.Serialization.Metadata;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Agents.AI.Purview.Serialization;
+using Microsoft.Extensions.Caching.Distributed;
+
+namespace Microsoft.Agents.AI.Purview;
+
+///
+/// Manages caching of values.
+///
+internal sealed class CacheProvider : ICacheProvider
+{
+ private readonly IDistributedCache _cache;
+ private readonly PurviewSettings _purviewSettings;
+
+ ///
+ /// Create a new instance of the class.
+ ///
+ /// The cache where the data is stored.
+ /// The purview integration settings.
+ public CacheProvider(IDistributedCache cache, PurviewSettings purviewSettings)
+ {
+ this._cache = cache;
+ this._purviewSettings = purviewSettings;
+ }
+
+ ///
+ /// Get a value from the cache.
+ ///
+ /// The type of the key in the cache. Used for serialization.
+ /// The type of the value in the cache. Used for serialization.
+ /// The key to look up in the cache.
+ /// A cancellation token for the async operation.
+ /// The value in the cache. Null or default if no value is present.
+ public async Task GetAsync(TKey key, CancellationToken cancellationToken)
+ {
+ JsonTypeInfo keyTypeInfo = (JsonTypeInfo)PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(TKey));
+ string serializedKey = JsonSerializer.Serialize(key, keyTypeInfo);
+ byte[]? data = await this._cache.GetAsync(serializedKey, cancellationToken).ConfigureAwait(false);
+ if (data == null)
+ {
+ return default;
+ }
+
+ JsonTypeInfo valueTypeInfo = (JsonTypeInfo)PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(TValue));
+
+ return JsonSerializer.Deserialize(data, valueTypeInfo);
+ }
+
+ ///
+ /// Set a value in the cache.
+ ///
+ /// The type of the key in the cache. Used for serialization.
+ /// The type of the value in the cache. Used for serialization.
+ /// The key to identify the cache entry.
+ /// The value to cache.
+ /// A cancellation token for the async operation.
+ /// A task for the async operation.
+ public Task SetAsync(TKey key, TValue value, CancellationToken cancellationToken)
+ {
+ JsonTypeInfo keyTypeInfo = (JsonTypeInfo)PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(TKey));
+ string serializedKey = JsonSerializer.Serialize(key, keyTypeInfo);
+ JsonTypeInfo valueTypeInfo = (JsonTypeInfo)PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(TValue));
+ byte[] serializedValue = JsonSerializer.SerializeToUtf8Bytes(value, valueTypeInfo);
+
+ DistributedCacheEntryOptions cacheOptions = new() { AbsoluteExpirationRelativeToNow = this._purviewSettings.CacheTTL };
+
+ return this._cache.SetAsync(serializedKey, serializedValue, cacheOptions, cancellationToken);
+ }
+
+ ///
+ /// Removes a value from the cache.
+ ///
+ /// The type of the key.
+ /// The key to identify the cache entry.
+ /// The cancellation token for the async operation.
+ /// A task for the async operation.
+ public Task RemoveAsync(TKey key, CancellationToken cancellationToken)
+ {
+ JsonTypeInfo keyTypeInfo = (JsonTypeInfo)PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(TKey));
+ string serializedKey = JsonSerializer.Serialize(key, keyTypeInfo);
+
+ return this._cache.RemoveAsync(serializedKey, cancellationToken);
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/ChannelHandler.cs b/dotnet/src/Microsoft.Agents.AI.Purview/ChannelHandler.cs
new file mode 100644
index 0000000000..ed3111fb3f
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/ChannelHandler.cs
@@ -0,0 +1,100 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Threading.Channels;
+using System.Threading.Tasks;
+using Microsoft.Agents.AI.Purview.Models.Jobs;
+using Microsoft.Extensions.Logging;
+
+namespace Microsoft.Agents.AI.Purview;
+
+///
+/// Handler class for background job management.
+///
+internal class ChannelHandler : IChannelHandler
+{
+ private readonly Channel _jobChannel;
+ private readonly List _channelListeners;
+ private readonly ILogger _logger;
+ private readonly PurviewSettings _purviewSettings;
+
+ ///
+ /// Creates a new instance of JobHandler.
+ ///
+ /// The purview integration settings.
+ /// The logger used for logging job information.
+ /// The job channel used for queuing and reading background jobs.
+ public ChannelHandler(PurviewSettings purviewSettings, ILogger logger, Channel jobChannel)
+ {
+ this._purviewSettings = purviewSettings;
+ this._logger = logger;
+ this._jobChannel = jobChannel;
+
+ this._channelListeners = new List(this._purviewSettings.MaxConcurrentJobConsumers);
+ }
+
+ ///
+ public void QueueJob(BackgroundJobBase job)
+ {
+ try
+ {
+ if (job == null)
+ {
+ throw new PurviewJobException("Cannot queue null job.");
+ }
+
+ if (this._channelListeners.Count == 0)
+ {
+ this._logger.LogWarning("No listeners are available to process the job.");
+ throw new PurviewJobException("No listeners are available to process the job.");
+ }
+
+ bool canQueue = this._jobChannel.Writer.TryWrite(job);
+
+ if (!canQueue)
+ {
+ int jobCount = this._jobChannel.Reader.Count;
+ this._logger.LogError("Could not queue a job for background processing.");
+
+ if (this._jobChannel.Reader.Completion.IsCompleted)
+ {
+ throw new PurviewJobException("Job channel is closed or completed. Cannot queue job.");
+ }
+ else if (jobCount >= this._purviewSettings.PendingBackgroundJobLimit)
+ {
+ throw new PurviewJobLimitExceededException($"Job queue is full. Current pending jobs: {jobCount}. Maximum number of queued jobs: {this._purviewSettings.PendingBackgroundJobLimit}");
+ }
+ else
+ {
+ throw new PurviewJobException("Could not queue job for background processing.");
+ }
+ }
+ }
+ catch (Exception e)
+ {
+ if (this._purviewSettings.IgnoreExceptions)
+ {
+ this._logger.LogError(e, "Error queuing job: {ExceptionMessage}", e.Message);
+ }
+ else
+ {
+ throw;
+ }
+ }
+ }
+
+ ///
+ public void AddRunner(Func, Task> runnerTask)
+ {
+ this._channelListeners.Add(Task.Run(async () => await runnerTask(this._jobChannel).ConfigureAwait(false)));
+ }
+
+ ///
+ public async Task StopAndWaitForCompletionAsync()
+ {
+ this._jobChannel.Writer.Complete();
+ await this._jobChannel.Reader.Completion.ConfigureAwait(false);
+ await Task.WhenAll(this._channelListeners).ConfigureAwait(false);
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Constants.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Constants.cs
new file mode 100644
index 0000000000..610f0748bc
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/Constants.cs
@@ -0,0 +1,29 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+namespace Microsoft.Agents.AI.Purview;
+
+///
+/// Shared constants for the Purview service.
+///
+internal static class Constants
+{
+ ///
+ /// The odata type property name used in requests and responses.
+ ///
+ public const string ODataTypePropertyName = "@odata.type";
+
+ ///
+ /// The OData Graph namespace used for odata types.
+ ///
+ public const string ODataGraphNamespace = "microsoft.graph";
+
+ ///
+ /// The name of the property that contains the conversation id.
+ ///
+ public const string ConversationId = "conversationId";
+
+ ///
+ /// The name of the property that contains the user id.
+ ///
+ public const string UserId = "userId";
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewAuthenticationException.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewAuthenticationException.cs
new file mode 100644
index 0000000000..83f80f3eb8
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewAuthenticationException.cs
@@ -0,0 +1,27 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+
+namespace Microsoft.Agents.AI.Purview;
+
+///
+/// Exception for authentication errors related to Purview.
+///
+public class PurviewAuthenticationException : PurviewException
+{
+ ///
+ public PurviewAuthenticationException(string message)
+ : base(message)
+ {
+ }
+
+ ///
+ public PurviewAuthenticationException() : base()
+ {
+ }
+
+ ///
+ public PurviewAuthenticationException(string? message, Exception? innerException) : base(message, innerException)
+ {
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewException.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewException.cs
new file mode 100644
index 0000000000..36c859d9b1
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewException.cs
@@ -0,0 +1,27 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+
+namespace Microsoft.Agents.AI.Purview;
+
+///
+/// General base exception type for Purview service errors.
+///
+public class PurviewException : Exception
+{
+ ///
+ public PurviewException(string message)
+ : base(message)
+ {
+ }
+
+ ///
+ public PurviewException() : base()
+ {
+ }
+
+ ///
+ public PurviewException(string? message, Exception? innerException) : base(message, innerException)
+ {
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewJobException.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewJobException.cs
new file mode 100644
index 0000000000..1737b70f1f
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewJobException.cs
@@ -0,0 +1,27 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+
+namespace Microsoft.Agents.AI.Purview;
+
+///
+/// Represents errors that occur during the execution of a Purview job.
+///
+/// This exception is thrown when a Purview job encounters an error that prevents it from completing successfully.
+internal class PurviewJobException : PurviewException
+{
+ ///
+ public PurviewJobException(string message) : base(message)
+ {
+ }
+
+ ///
+ public PurviewJobException() : base()
+ {
+ }
+
+ ///
+ public PurviewJobException(string? message, Exception? innerException) : base(message, innerException)
+ {
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewJobLimitExceededException.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewJobLimitExceededException.cs
new file mode 100644
index 0000000000..7560000a55
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewJobLimitExceededException.cs
@@ -0,0 +1,27 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+
+namespace Microsoft.Agents.AI.Purview;
+
+///
+/// Represents an exception that is thrown when the maximum number of concurrent Purview jobs has been exceeded.
+///
+/// This exception indicates that the Purview service has reached its limit for concurrent job executions.
+internal class PurviewJobLimitExceededException : PurviewJobException
+{
+ ///
+ public PurviewJobLimitExceededException(string message) : base(message)
+ {
+ }
+
+ ///
+ public PurviewJobLimitExceededException() : base()
+ {
+ }
+
+ ///
+ public PurviewJobLimitExceededException(string? message, Exception? innerException) : base(message, innerException)
+ {
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewPaymentRequiredException.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewPaymentRequiredException.cs
new file mode 100644
index 0000000000..28a6c70323
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewPaymentRequiredException.cs
@@ -0,0 +1,26 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+
+namespace Microsoft.Agents.AI.Purview;
+
+///
+/// Exception for payment required errors related to Purview.
+///
+public class PurviewPaymentRequiredException : PurviewException
+{
+ ///
+ public PurviewPaymentRequiredException(string message) : base(message)
+ {
+ }
+
+ ///
+ public PurviewPaymentRequiredException() : base()
+ {
+ }
+
+ ///
+ public PurviewPaymentRequiredException(string? message, Exception? innerException) : base(message, innerException)
+ {
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewRateLimitException.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewRateLimitException.cs
new file mode 100644
index 0000000000..71483886d2
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewRateLimitException.cs
@@ -0,0 +1,27 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+
+namespace Microsoft.Agents.AI.Purview;
+
+///
+/// Exception for rate limit exceeded errors from Purview service.
+///
+public class PurviewRateLimitException : PurviewException
+{
+ ///
+ public PurviewRateLimitException(string message)
+ : base(message)
+ {
+ }
+
+ ///
+ public PurviewRateLimitException() : base()
+ {
+ }
+
+ ///
+ public PurviewRateLimitException(string? message, Exception? innerException) : base(message, innerException)
+ {
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewRequestException.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewRequestException.cs
new file mode 100644
index 0000000000..a34fad6ce4
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewRequestException.cs
@@ -0,0 +1,40 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Net;
+
+namespace Microsoft.Agents.AI.Purview;
+
+///
+/// Exception for general http request errors from Purview.
+///
+public class PurviewRequestException : PurviewException
+{
+ ///
+ /// HTTP status code returned by the Purview service.
+ ///
+ public HttpStatusCode StatusCode { get; }
+
+ ///
+ public PurviewRequestException(HttpStatusCode statusCode, string endpointName)
+ : base($"Failed to call {endpointName}. Status code: {statusCode}")
+ {
+ this.StatusCode = statusCode;
+ }
+
+ ///
+ public PurviewRequestException(string message)
+ : base(message)
+ {
+ }
+
+ ///
+ public PurviewRequestException() : base()
+ {
+ }
+
+ ///
+ public PurviewRequestException(string? message, Exception? innerException) : base(message, innerException)
+ {
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/ICacheProvider.cs b/dotnet/src/Microsoft.Agents.AI.Purview/ICacheProvider.cs
new file mode 100644
index 0000000000..6d6dad527c
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/ICacheProvider.cs
@@ -0,0 +1,42 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Microsoft.Agents.AI.Purview;
+
+///
+/// Manages caching of values.
+///
+internal interface ICacheProvider
+{
+ ///
+ /// Get a value from the cache.
+ ///
+ /// The type of the key in the cache. Used for serialization.
+ /// The type of the value in the cache. Used for serialization.
+ /// The key to look up in the cache.
+ /// A cancellation token for the async operation.
+ /// The value in the cache. Null or default if no value is present.
+ Task GetAsync(TKey key, CancellationToken cancellationToken);
+
+ ///
+ /// Set a value in the cache.
+ ///
+ /// The type of the key in the cache. Used for serialization.
+ /// The type of the value in the cache. Used for serialization.
+ /// The key to identify the cache entry.
+ /// The value to cache.
+ /// A cancellation token for the async operation.
+ /// A task for the async operation.
+ Task SetAsync(TKey key, TValue value, CancellationToken cancellationToken);
+
+ ///
+ /// Removes a value from the cache.
+ ///
+ /// The type of the key.
+ /// The key to identify the cache entry.
+ /// The cancellation token for the async operation.
+ /// A task for the async operation.
+ Task RemoveAsync(TKey key, CancellationToken cancellationToken);
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/IChannelHandler.cs b/dotnet/src/Microsoft.Agents.AI.Purview/IChannelHandler.cs
new file mode 100644
index 0000000000..d8593abd48
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/IChannelHandler.cs
@@ -0,0 +1,32 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Threading.Channels;
+using System.Threading.Tasks;
+using Microsoft.Agents.AI.Purview.Models.Jobs;
+
+namespace Microsoft.Agents.AI.Purview;
+
+///
+/// Interface for a class that controls background job processing.
+///
+internal interface IChannelHandler
+{
+ ///
+ /// Queue a job for background processing.
+ ///
+ /// The job queued for background processing.
+ void QueueJob(BackgroundJobBase job);
+
+ ///
+ /// Add a runner to the channel handler.
+ ///
+ /// The runner task used to process jobs.
+ void AddRunner(Func, Task> runnerTask);
+
+ ///
+ /// Stop the channel and wait for all runners to complete
+ ///
+ /// A task representing the job.
+ Task StopAndWaitForCompletionAsync();
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/IPurviewClient.cs b/dotnet/src/Microsoft.Agents.AI.Purview/IPurviewClient.cs
new file mode 100644
index 0000000000..00de9051ef
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/IPurviewClient.cs
@@ -0,0 +1,56 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Agents.AI.Purview.Models.Common;
+using Microsoft.Agents.AI.Purview.Models.Requests;
+using Microsoft.Agents.AI.Purview.Models.Responses;
+
+namespace Microsoft.Agents.AI.Purview;
+
+///
+/// Defines methods for interacting with the Purview service, including content processing,
+/// protection scope management, and activity tracking.
+///
+/// This interface provides methods to interact with various Purview APIs. It includes processing content, managing protection
+/// scopes, and sending content activity data. Implementations of this interface are expected to handle communication
+/// with the Purview service and manage any necessary authentication or error handling.
+internal interface IPurviewClient
+{
+ ///
+ /// Get user info from auth token.
+ ///
+ /// The cancellation token used to cancel async processing.
+ /// The default tenant id used to retrieve the token and its info.
+ /// The token info from the token.
+ /// Throw if the token was invalid or could not be retrieved.
+ Task GetUserInfoFromTokenAsync(CancellationToken cancellationToken, string? tenantId = default);
+
+ ///
+ /// Call ProcessContent API.
+ ///
+ /// The request containing the content to process.
+ /// The cancellation token used to cancel async processing.
+ /// The response from the Purview API.
+ /// Thrown for validation, auth, and network errors.
+ Task ProcessContentAsync(ProcessContentRequest request, CancellationToken cancellationToken);
+
+ ///
+ /// Call user ProtectionScope API.
+ ///
+ /// The request containing the protection scopes metadata.
+ /// The cancellation token used to cancel async processing.
+ /// The protection scopes that apply to the data sent in the request.
+ /// Thrown for validation, auth, and network errors.
+ Task GetProtectionScopesAsync(ProtectionScopesRequest request, CancellationToken cancellationToken);
+
+ ///
+ /// Call contentActivities API.
+ ///
+ /// The request containing the content metadata. Used to generate interaction records.
+ /// The cancellation token used to cancel async processing.
+ /// The response from the Purview API.
+ /// Thrown for validation, auth, and network errors.
+ Task SendContentActivitiesAsync(ContentActivitiesRequest request, CancellationToken cancellationToken);
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/IScopedContentProcessor.cs b/dotnet/src/Microsoft.Agents.AI.Purview/IScopedContentProcessor.cs
new file mode 100644
index 0000000000..059e7c4d2d
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/IScopedContentProcessor.cs
@@ -0,0 +1,28 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Agents.AI.Purview.Models.Common;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI.Purview;
+
+///
+/// Orchestrates the processing of scoped content by combining protection scope, process content, and content activities operations.
+///
+internal interface IScopedContentProcessor
+{
+ ///
+ /// Process a list of messages.
+ /// The list of messages should be a prompt or response.
+ ///
+ /// A list of objects sent to the agent or received from the agent..
+ /// The thread where the messages were sent.
+ /// An activity to indicate prompt or response.
+ /// Purview settings containing tenant id, app name, etc.
+ /// The user who sent the prompt or is receiving the response.
+ /// Cancellation token.
+ /// A bool indicating if the request should be blocked and the user id of the user who made the request.
+ Task<(bool shouldBlock, string? userId)> ProcessMessagesAsync(IEnumerable messages, string? threadId, Activity activity, PurviewSettings purviewSettings, string? userId, CancellationToken cancellationToken);
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Microsoft.Agents.AI.Purview.csproj b/dotnet/src/Microsoft.Agents.AI.Purview/Microsoft.Agents.AI.Purview.csproj
new file mode 100644
index 0000000000..20eca86359
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/Microsoft.Agents.AI.Purview.csproj
@@ -0,0 +1,43 @@
+
+
+
+ $(ProjectsTargetFrameworks)
+ $(ProjectsDebugTargetFrameworks)
+ alpha
+
+
+
+ true
+ true
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Microsoft.Agents.AI.Purview
+ Tools to connect generative AI apps to Microsoft Purview.
+
+
+
+
+
+
+
+
+ $(NoWarn);CA1812
+
+
+
\ No newline at end of file
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/AIAgentInfo.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/AIAgentInfo.cs
new file mode 100644
index 0000000000..15c1fbab00
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/AIAgentInfo.cs
@@ -0,0 +1,29 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Text.Json.Serialization;
+
+namespace Microsoft.Agents.AI.Purview.Models.Common;
+
+///
+/// Info about an AI agent associated with the content.
+///
+internal sealed class AIAgentInfo
+{
+ ///
+ /// Gets or sets agent id.
+ ///
+ [JsonPropertyName("identifier")]
+ public string? Identifier { get; set; }
+
+ ///
+ /// Gets or sets agent name.
+ ///
+ [JsonPropertyName("name")]
+ public string? Name { get; set; }
+
+ ///
+ /// Gets or sets agent version.
+ ///
+ [JsonPropertyName("version")]
+ public string? Version { get; set; }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/AIInteractionPlugin.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/AIInteractionPlugin.cs
new file mode 100644
index 0000000000..d9b56f3911
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/AIInteractionPlugin.cs
@@ -0,0 +1,29 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Text.Json.Serialization;
+
+namespace Microsoft.Agents.AI.Purview.Models.Common;
+
+///
+/// Represents a plugin used in an AI interaction within the Purview SDK.
+///
+internal sealed class AIInteractionPlugin
+{
+ ///
+ /// Gets or sets Plugin id.
+ ///
+ [JsonPropertyName("identifier")]
+ public string? Identifier { get; set; }
+
+ ///
+ /// Gets or sets Plugin Name.
+ ///
+ [JsonPropertyName("name")]
+ public string? Name { get; set; }
+
+ ///
+ /// Gets or sets Plugin Version.
+ ///
+ [JsonPropertyName("version")]
+ public string? Version { get; set; }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/AccessedResourceDetails.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/AccessedResourceDetails.cs
new file mode 100644
index 0000000000..e9a18543c6
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/AccessedResourceDetails.cs
@@ -0,0 +1,53 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Text.Json.Serialization;
+
+namespace Microsoft.Agents.AI.Purview.Models.Common;
+
+///
+/// Information about a resource accessed during a conversation.
+///
+internal sealed class AccessedResourceDetails
+{
+ ///
+ /// Resource ID.
+ ///
+ [JsonPropertyName("identifier")]
+ public string? Identifier { get; set; }
+
+ ///
+ /// Resource name.
+ ///
+ [JsonPropertyName("name")]
+ public string? Name { get; set; }
+
+ ///
+ /// Resource URL.
+ ///
+ [JsonPropertyName("url")]
+ public string? Url { get; set; }
+
+ ///
+ /// Sensitivity label id detected on the resource.
+ ///
+ [JsonPropertyName("labelId")]
+ public string? LabelId { get; set; }
+
+ ///
+ /// Access type performed on the resource.
+ ///
+ [JsonPropertyName("accessType")]
+ public ResourceAccessType AccessType { get; set; }
+
+ ///
+ /// Status of the access operation.
+ ///
+ [JsonPropertyName("status")]
+ public ResourceAccessStatus Status { get; set; }
+
+ ///
+ /// Indicates if cross prompt injection was detected.
+ ///
+ [JsonPropertyName("isCrossPromptInjectionDetected")]
+ public bool? IsCrossPromptInjectionDetected { get; set; }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/Activity.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/Activity.cs
new file mode 100644
index 0000000000..5f9fdeb9d7
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/Activity.cs
@@ -0,0 +1,44 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Runtime.Serialization;
+using System.Text.Json.Serialization;
+
+namespace Microsoft.Agents.AI.Purview.Models.Common;
+
+///
+/// Activity definitions
+///
+[DataContract]
+[JsonConverter(typeof(JsonStringEnumConverter))]
+internal enum Activity : int
+{
+ ///
+ /// Unknown activity
+ ///
+ [EnumMember(Value = "unknown")]
+ Unknown = 0,
+
+ ///
+ /// Upload text
+ ///
+ [EnumMember(Value = "uploadText")]
+ UploadText = 1,
+
+ ///
+ /// Upload file
+ ///
+ [EnumMember(Value = "uploadFile")]
+ UploadFile = 2,
+
+ ///
+ /// Download text
+ ///
+ [EnumMember(Value = "downloadText")]
+ DownloadText = 3,
+
+ ///
+ /// Download file
+ ///
+ [EnumMember(Value = "downloadFile")]
+ DownloadFile = 4,
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ActivityMetadata.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ActivityMetadata.cs
new file mode 100644
index 0000000000..deefc24560
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ActivityMetadata.cs
@@ -0,0 +1,30 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Runtime.Serialization;
+using System.Text.Json.Serialization;
+
+namespace Microsoft.Agents.AI.Purview.Models.Common;
+
+///
+/// Request for metadata information
+///
+[DataContract]
+internal sealed class ActivityMetadata
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The activity performed with the content.
+ public ActivityMetadata(Activity activity)
+ {
+ this.Activity = activity;
+ }
+
+ ///
+ /// The activity performed with the content.
+ ///
+ [DataMember]
+ [JsonConverter(typeof(JsonStringEnumConverter))]
+ [JsonPropertyName("activity")]
+ public Activity Activity { get; }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ClassificationErrorBase.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ClassificationErrorBase.cs
new file mode 100644
index 0000000000..e52bf9ebb4
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ClassificationErrorBase.cs
@@ -0,0 +1,37 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Text.Json.Serialization;
+
+namespace Microsoft.Agents.AI.Purview.Models.Common;
+
+///
+/// Base error contract returned when some exception occurs.
+///
+[JsonDerivedType(typeof(ProcessingError))]
+internal class ClassificationErrorBase
+{
+ ///
+ /// Gets or sets the error code.
+ ///
+ [JsonPropertyName("code")]
+ public string? ErrorCode { get; set; }
+
+ ///
+ /// Gets or sets the message.
+ ///
+ [JsonPropertyName("message")]
+ public string? Message { get; set; }
+
+ ///
+ /// Gets or sets target of error.
+ ///
+ [JsonPropertyName("target")]
+ public string? Target { get; set; }
+
+ ///
+ /// Gets or sets an object containing more specific information than the current object about the error.
+ /// It can't be a Dictionary because OData will make ClassificationErrorBase open type. It's not expected behavior.
+ ///
+ [JsonPropertyName("innerError")]
+ public ClassificationInnerError? InnerError { get; set; }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ClassificationInnerError.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ClassificationInnerError.cs
new file mode 100644
index 0000000000..1133529188
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ClassificationInnerError.cs
@@ -0,0 +1,36 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Text.Json.Serialization;
+
+namespace Microsoft.Agents.AI.Purview.Models.Common;
+
+///
+/// Inner classification error.
+///
+internal sealed class ClassificationInnerError
+{
+ ///
+ /// Gets or sets date of error.
+ ///
+ [JsonPropertyName("date")]
+ public DateTime? Date { get; set; }
+
+ ///
+ /// Gets or sets error code.
+ ///
+ [JsonPropertyName("code")]
+ public string? ErrorCode { get; set; }
+
+ ///
+ /// Gets or sets client request ID.
+ ///
+ [JsonPropertyName("clientRequestId")]
+ public string? ClientRequestId { get; set; }
+
+ ///
+ /// Gets or sets Activity ID.
+ ///
+ [JsonPropertyName("activityId")]
+ public string? ActivityId { get; set; }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ContentBase.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ContentBase.cs
new file mode 100644
index 0000000000..9619d27fc8
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ContentBase.cs
@@ -0,0 +1,21 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Text.Json.Serialization;
+
+namespace Microsoft.Agents.AI.Purview.Models.Common;
+
+///
+/// Base class for content items to be processed by the Purview SDK.
+///
+[JsonDerivedType(typeof(PurviewTextContent))]
+[JsonDerivedType(typeof(PurviewBinaryContent))]
+internal abstract class ContentBase : GraphDataTypeBase
+{
+ ///
+ /// Creates a new instance of the class.
+ ///
+ /// The graph data type of the content.
+ public ContentBase(string dataType) : base(dataType)
+ {
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ContentProcessingErrorType.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ContentProcessingErrorType.cs
new file mode 100644
index 0000000000..3d57a02aee
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ContentProcessingErrorType.cs
@@ -0,0 +1,27 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Text.Json.Serialization;
+
+namespace Microsoft.Agents.AI.Purview.Models.Common;
+
+///
+/// Type of error that occurred during content processing.
+///
+[JsonConverter(typeof(JsonStringEnumConverter))]
+internal enum ContentProcessingErrorType
+{
+ ///
+ /// Error is transient.
+ ///
+ Transient,
+
+ ///
+ /// Error is permanent.
+ ///
+ Permanent,
+
+ ///
+ /// Unknown future value placeholder.
+ ///
+ UnknownFutureValue
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ContentToProcess.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ContentToProcess.cs
new file mode 100644
index 0000000000..9e2e5824f3
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ContentToProcess.cs
@@ -0,0 +1,70 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Runtime.Serialization;
+using System.Text.Json.Serialization;
+
+namespace Microsoft.Agents.AI.Purview.Models.Common;
+
+///
+/// Content to be processed by process content.
+///
+internal sealed class ContentToProcess
+{
+ ///
+ /// Creates a new instance of ContentToProcess.
+ ///
+ /// The content to send and its associated ids.
+ /// Metadata about the activity performed with the content.
+ /// Metadata about the device that produced the content.
+ /// Metadata about the application integrating with Purview.
+ /// Metadata about the application being protected by Purview.
+ public ContentToProcess(
+ List contentEntries,
+ ActivityMetadata activityMetadata,
+ DeviceMetadata deviceMetadata,
+ IntegratedAppMetadata integratedAppMetadata,
+ ProtectedAppMetadata protectedAppMetadata)
+ {
+ this.ContentEntries = contentEntries;
+ this.ActivityMetadata = activityMetadata;
+ this.DeviceMetadata = deviceMetadata;
+ this.IntegratedAppMetadata = integratedAppMetadata;
+ this.ProtectedAppMetadata = protectedAppMetadata;
+ }
+
+ ///
+ /// Gets or sets the content entries.
+ /// List of activities supported by caller. It is used to trim response to activities interesting to the caller.
+ ///
+ [JsonPropertyName("contentEntries")]
+ public List ContentEntries { get; set; }
+
+ ///
+ /// Activity metadata
+ ///
+ [DataMember]
+ [JsonPropertyName("activityMetadata")]
+ public ActivityMetadata ActivityMetadata { get; set; }
+
+ ///
+ /// Device metadata
+ ///
+ [DataMember]
+ [JsonPropertyName("deviceMetadata")]
+ public DeviceMetadata DeviceMetadata { get; set; }
+
+ ///
+ /// Integrated app metadata
+ ///
+ [DataMember]
+ [JsonPropertyName("integratedAppMetadata")]
+ public IntegratedAppMetadata IntegratedAppMetadata { get; set; }
+
+ ///
+ /// Protected app metadata
+ ///
+ [DataMember]
+ [JsonPropertyName("protectedAppMetadata")]
+ public ProtectedAppMetadata ProtectedAppMetadata { get; set; }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/DeviceMetadata.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/DeviceMetadata.cs
new file mode 100644
index 0000000000..3a60686be3
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/DeviceMetadata.cs
@@ -0,0 +1,29 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Text.Json.Serialization;
+
+namespace Microsoft.Agents.AI.Purview.Models.Common;
+
+///
+/// Endpoint device Metdata
+///
+internal sealed class DeviceMetadata
+{
+ ///
+ /// Device type
+ ///
+ [JsonPropertyName("deviceType")]
+ public string? DeviceType { get; set; }
+
+ ///
+ /// The ip address of the device.
+ ///
+ [JsonPropertyName("ipAddress")]
+ public string? IpAddress { get; set; }
+
+ ///
+ /// OS specifications
+ ///
+ [JsonPropertyName("operatingSystemSpecifications")]
+ public OperatingSystemSpecifications? OperatingSystemSpecifications { get; set; }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/DlpAction.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/DlpAction.cs
new file mode 100644
index 0000000000..8eda013588
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/DlpAction.cs
@@ -0,0 +1,69 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Text.Json.Serialization;
+
+namespace Microsoft.Agents.AI.Purview.Models.Common;
+
+///
+/// Defines all the actions for DLP.
+///
+[JsonConverter(typeof(JsonStringEnumConverter))]
+internal enum DlpAction
+{
+ ///
+ /// The DLP action to notify user.
+ ///
+ NotifyUser,
+
+ ///
+ /// The DLP action is block.
+ ///
+ BlockAccess,
+
+ ///
+ /// The DLP action to apply restrictions on device.
+ ///
+ DeviceRestriction,
+
+ ///
+ /// The DLP action to apply restrictions on browsers.
+ ///
+ BrowserRestriction,
+
+ ///
+ /// The DLP action to generate an alert
+ ///
+ GenerateAlert,
+
+ ///
+ /// The DLP action to generate an incident report
+ ///
+ GenerateIncidentReportAction,
+
+ ///
+ /// The DLP action to block anonymous link access in SPO
+ ///
+ SPBlockAnonymousAccess,
+
+ ///
+ /// DLP Action to disallow guest access in SPO
+ ///
+ SPRuntimeAccessControl,
+
+ ///
+ /// DLP No Op action for NotifyUser. Used in Block Access V2 rule
+ ///
+ SPSharingNotifyUser,
+
+ ///
+ /// DLP No Op action for GIR. Used in Block Access V2 rule
+ ///
+ SPSharingGenerateIncidentReport,
+
+ ///
+ /// Restrict access action for data in motion scenarios.
+ /// Advanced version of BlockAccess which can take both enforced restriction mode (Audit, Block, etc.)
+ /// and action triggers (Print, SaveToLocal, etc.) as parameters.
+ ///
+ RestrictAccess,
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/DlpActionInfo.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/DlpActionInfo.cs
new file mode 100644
index 0000000000..a5846acadc
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/DlpActionInfo.cs
@@ -0,0 +1,23 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Text.Json.Serialization;
+
+namespace Microsoft.Agents.AI.Purview.Models.Common;
+
+///
+/// Base class to define DLP Actions.
+///
+internal sealed class DlpActionInfo
+{
+ ///
+ /// Gets or sets the type of the DLP action.
+ ///
+ [JsonPropertyName("action")]
+ public DlpAction Action { get; set; }
+
+ ///
+ /// The type of restriction action to take.
+ ///
+ [JsonPropertyName("restrictionAction")]
+ public RestrictionAction? RestrictionAction { get; set; }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ErrorDetails.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ErrorDetails.cs
new file mode 100644
index 0000000000..dd79ee13ce
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ErrorDetails.cs
@@ -0,0 +1,23 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Text.Json.Serialization;
+
+namespace Microsoft.Agents.AI.Purview.Models.Common;
+
+///
+/// Represents the details of an error.
+///
+internal sealed class ErrorDetails
+{
+ ///
+ /// Gets or sets the error code.
+ ///
+ [JsonPropertyName("code")]
+ public string? Code { get; set; }
+
+ ///
+ /// Gets or sets the error message.
+ ///
+ [JsonPropertyName("message")]
+ public string? Message { get; set; }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ExecutionMode.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ExecutionMode.cs
new file mode 100644
index 0000000000..3fecfbb3f4
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ExecutionMode.cs
@@ -0,0 +1,27 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Text.Json.Serialization;
+
+namespace Microsoft.Agents.AI.Purview.Models.Common;
+
+///
+/// Request execution mode
+///
+[JsonConverter(typeof(JsonStringEnumConverter))]
+internal enum ExecutionMode : int
+{
+ ///
+ /// Evaluate inline.
+ ///
+ EvaluateInline = 1,
+
+ ///
+ /// Evaluate offline.
+ ///
+ EvaluateOffline = 2,
+
+ ///
+ /// Unknown future value.
+ ///
+ UnknownFutureValue = 3
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/GraphDataTypeBase.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/GraphDataTypeBase.cs
new file mode 100644
index 0000000000..b4334fdb43
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/GraphDataTypeBase.cs
@@ -0,0 +1,26 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Text.Json.Serialization;
+
+namespace Microsoft.Agents.AI.Purview.Models.Common;
+
+///
+/// Base class for all graph data types used in the Purview SDK.
+///
+internal abstract class GraphDataTypeBase
+{
+ ///
+ /// Create a new instance of the class.
+ ///
+ /// The data type of the graph object.
+ public GraphDataTypeBase(string dataType)
+ {
+ this.DataType = dataType;
+ }
+
+ ///
+ /// The @odata.type property name used in the JSON representation of the object.
+ ///
+ [JsonPropertyName(Constants.ODataTypePropertyName)]
+ public string DataType { get; set; }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/IntegratedAppMetadata.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/IntegratedAppMetadata.cs
new file mode 100644
index 0000000000..1a5e8b5e13
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/IntegratedAppMetadata.cs
@@ -0,0 +1,27 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Runtime.Serialization;
+using System.Text.Json.Serialization;
+
+namespace Microsoft.Agents.AI.Purview.Models.Common;
+
+///
+/// Request for metadata information
+///
+[JsonDerivedType(typeof(ProtectedAppMetadata))]
+internal class IntegratedAppMetadata
+{
+ ///
+ /// Application name
+ ///
+ [DataMember]
+ [JsonPropertyName("name")]
+ public string? Name { get; set; }
+
+ ///
+ /// Application version
+ ///
+ [DataMember]
+ [JsonPropertyName("version")]
+ public string? Version { get; set; }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/OperatingSystemSpecifications.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/OperatingSystemSpecifications.cs
new file mode 100644
index 0000000000..3ea8837177
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/OperatingSystemSpecifications.cs
@@ -0,0 +1,23 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Text.Json.Serialization;
+
+namespace Microsoft.Agents.AI.Purview.Models.Common;
+
+///
+/// Operating System Specifications
+///
+internal sealed class OperatingSystemSpecifications
+{
+ ///
+ /// OS platform
+ ///
+ [JsonPropertyName("operatingSystemPlatform")]
+ public string? OperatingSystemPlatform { get; set; }
+
+ ///
+ /// OS version
+ ///
+ [JsonPropertyName("operatingSystemVersion")]
+ public string? OperatingSystemVersion { get; set; }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PolicyBinding.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PolicyBinding.cs
new file mode 100644
index 0000000000..9898f62e01
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PolicyBinding.cs
@@ -0,0 +1,25 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+
+namespace Microsoft.Agents.AI.Purview.Models.Common;
+
+///
+/// Represents user scoping information, i.e. which users are affected by the policy.
+///
+internal sealed class PolicyBinding
+{
+ ///
+ /// Gets or sets the users to be included.
+ ///
+ [JsonPropertyName("inclusions")]
+ public ICollection? Inclusions { get; set; }
+
+ ///
+ /// Gets or sets the users to be excluded.
+ /// Exclusions may not be present in the response, thus this property is nullable.
+ ///
+ [JsonPropertyName("exclusions")]
+ public ICollection? Exclusions { get; set; }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PolicyLocation.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PolicyLocation.cs
new file mode 100644
index 0000000000..c0a40974e5
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PolicyLocation.cs
@@ -0,0 +1,27 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Text.Json.Serialization;
+
+namespace Microsoft.Agents.AI.Purview.Models.Common;
+
+///
+/// Represents a location to which policy is applicable.
+///
+internal sealed class PolicyLocation : GraphDataTypeBase
+{
+ ///
+ /// Creates a new instance of the class.
+ ///
+ /// The graph data type of the PolicyLocation object.
+ /// THe value of the policy location: app id, domain, etc.
+ public PolicyLocation(string dataType, string value) : base(dataType)
+ {
+ this.Value = value;
+ }
+
+ ///
+ /// Gets or sets the applicable value for location.
+ ///
+ [JsonPropertyName("value")]
+ public string Value { get; set; }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PolicyPivotProperty.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PolicyPivotProperty.cs
new file mode 100644
index 0000000000..d56a374842
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PolicyPivotProperty.cs
@@ -0,0 +1,42 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Runtime.Serialization;
+using System.Text.Json.Serialization;
+
+namespace Microsoft.Agents.AI.Purview.Models.Common;
+
+///
+/// Property for policy scoping response to aggregate on
+///
+[DataContract]
+[JsonConverter(typeof(JsonStringEnumConverter))]
+internal enum PolicyPivotProperty : int
+{
+ ///
+ /// Unknown activity
+ ///
+ [EnumMember]
+ [JsonPropertyName("none")]
+ None = 0,
+
+ ///
+ /// Pivot on Activity
+ ///
+ [EnumMember]
+ [JsonPropertyName("activity")]
+ Activity = 1,
+
+ ///
+ /// Pivot on location
+ ///
+ [EnumMember]
+ [JsonPropertyName("location")]
+ Location = 2,
+
+ ///
+ /// Pivot on location
+ ///
+ [EnumMember]
+ [JsonPropertyName("unknownFutureValue")]
+ UnknownFutureValue = 3,
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PolicyScope.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PolicyScope.cs
new file mode 100644
index 0000000000..f00e941d35
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PolicyScope.cs
@@ -0,0 +1,43 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+
+namespace Microsoft.Agents.AI.Purview.Models.Common;
+
+///
+/// Represents a scope for policy protection.
+///
+internal sealed class PolicyScopeBase
+{
+ ///
+ /// Gets or sets the locations to be protected, e.g. domains or URLs.
+ ///
+ [JsonPropertyName("locations")]
+ public ICollection? Locations { get; set; }
+
+ ///
+ /// Gets or sets the activities to be protected, e.g. uploadText, downloadText.
+ ///
+ [JsonPropertyName("activities")]
+ public ProtectionScopeActivities Activities { get; set; }
+
+ ///
+ /// Gets or sets how policy should be executed - fire-and-forget or wait for completion.
+ ///
+ [JsonPropertyName("executionMode")]
+ public ExecutionMode ExecutionMode { get; set; }
+
+ ///
+ /// Gets or sets the enforcement actions to be taken on activities and locations from this scope.
+ /// There may be no actions in the response.
+ ///
+ [JsonPropertyName("policyActions")]
+ public ICollection? PolicyActions { get; set; }
+
+ ///
+ /// Gets or sets information about policy applicability to a specific user.
+ ///
+ [JsonPropertyName("policyScope")]
+ public PolicyBinding? PolicyScope { get; set; }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessContentMetadataBase.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessContentMetadataBase.cs
new file mode 100644
index 0000000000..51f4936e82
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessContentMetadataBase.cs
@@ -0,0 +1,94 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Text.Json.Serialization;
+
+namespace Microsoft.Agents.AI.Purview.Models.Common;
+
+///
+/// Base class for process content metadata.
+///
+[JsonDerivedType(typeof(ProcessConversationMetadata))]
+[JsonDerivedType(typeof(ProcessFileMetadata))]
+internal abstract class ProcessContentMetadataBase : GraphDataTypeBase
+{
+ private const string ProcessConversationMetadataDataType = Constants.ODataGraphNamespace + ".processConversationMetadata";
+
+ ///
+ /// Creates a new instance of ProcessContentMetadataBase.
+ ///
+ /// The content that will be processed.
+ /// The unique identifier for the content.
+ /// Indicates if the content is truncated.
+ /// The name of the content.
+ public ProcessContentMetadataBase(ContentBase content, string identifier, bool isTruncated, string name) : base(ProcessConversationMetadataDataType)
+ {
+ this.Identifier = identifier;
+ this.IsTruncated = isTruncated;
+ this.Content = content;
+ this.Name = name;
+ }
+
+ ///
+ /// Gets or sets the identifier.
+ /// Unique id for the content. It is specific to the enforcement plane. Path is used as item unique identifier, e.g., guid of a message in the conversation, file URL, storage file path, message ID, etc.
+ ///
+ [JsonPropertyName("identifier")]
+ public string Identifier { get; set; }
+
+ ///
+ /// Gets or sets the content.
+ /// The content to be processed.
+ ///
+ [JsonPropertyName("content")]
+ public ContentBase Content { get; set; }
+
+ ///
+ /// Gets or sets the name.
+ /// Name of the content, e.g., file name or web page title.
+ ///
+ [JsonPropertyName("name")]
+ public string Name { get; set; }
+
+ ///
+ /// Gets or sets the correlationId.
+ /// Identifier to group multiple contents.
+ ///
+ [JsonPropertyName("correlationId")]
+ public string? CorrelationId { get; set; }
+
+ ///
+ /// Gets or sets the sequenceNumber.
+ /// Sequence in which the content was originally generated.
+ ///
+ [JsonPropertyName("sequenceNumber")]
+ public long? SequenceNumber { get; set; }
+
+ ///
+ /// Gets or sets the length.
+ /// Content length in bytes.
+ ///
+ [JsonPropertyName("length")]
+ public long? Length { get; set; }
+
+ ///
+ /// Gets or sets the isTruncated.
+ /// Indicates if the original content has been truncated, e.g., to meet text or file size limits.
+ ///
+ [JsonPropertyName("isTruncated")]
+ public bool IsTruncated { get; set; }
+
+ ///
+ /// Gets or sets the createdDateTime.
+ /// When the content was created. E.g., file created time or the time when a message was sent.
+ ///
+ [JsonPropertyName("createdDateTime")]
+ public DateTimeOffset CreatedDateTime { get; set; } = DateTime.UtcNow;
+
+ ///
+ /// Gets or sets the modifiedDateTime.
+ /// When the content was last modified. E.g., file last modified time. For content created on the fly, such as messaging, whenModified and whenCreated are expected to be the same.
+ ///
+ [JsonPropertyName("modifiedDateTime")]
+ public DateTimeOffset? ModifiedDateTime { get; set; } = DateTime.UtcNow;
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessConversationMetadata.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessConversationMetadata.cs
new file mode 100644
index 0000000000..86bedb9248
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessConversationMetadata.cs
@@ -0,0 +1,46 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+
+namespace Microsoft.Agents.AI.Purview.Models.Common;
+
+///
+/// Represents metadata for conversation content to be processed by the Purview SDK.
+///
+internal sealed class ProcessConversationMetadata : ProcessContentMetadataBase
+{
+ private const string ProcessConversationMetadataDataType = Constants.ODataGraphNamespace + ".processConversationMetadata";
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public ProcessConversationMetadata(ContentBase contentBase, string identifier, bool isTruncated, string name) : base(contentBase, identifier, isTruncated, name)
+ {
+ this.DataType = ProcessConversationMetadataDataType;
+ }
+
+ ///
+ /// Gets or sets the parent message ID for nested conversations.
+ ///
+ [JsonPropertyName("parentMessageId")]
+ public string? ParentMessageId { get; set; }
+
+ ///
+ /// Gets or sets the accessed resources during message generation for bot messages.
+ ///
+ [JsonPropertyName("accessedResources_v2")]
+ public List? AccessedResources { get; set; }
+
+ ///
+ /// Gets or sets the plugins used during message generation for bot messages.
+ ///
+ [JsonPropertyName("plugins")]
+ public List? Plugins { get; set; }
+
+ ///
+ /// Gets or sets the collection of AI agent information.
+ ///
+ [JsonPropertyName("agents")]
+ public List? Agents { get; set; }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessFileMetadata.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessFileMetadata.cs
new file mode 100644
index 0000000000..a9f1749bed
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessFileMetadata.cs
@@ -0,0 +1,27 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Text.Json.Serialization;
+
+namespace Microsoft.Agents.AI.Purview.Models.Common;
+
+///
+/// Represents metadata for a file content to be processed by the Purview SDK.
+///
+internal sealed class ProcessFileMetadata : ProcessContentMetadataBase
+{
+ private const string ProcessFileMetadataDataType = Constants.ODataGraphNamespace + ".processFileMetadata";
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public ProcessFileMetadata(ContentBase contentBase, string identifier, bool isTruncated, string name) : base(contentBase, identifier, isTruncated, name)
+ {
+ this.DataType = ProcessFileMetadataDataType;
+ }
+
+ ///
+ /// Gets or sets the owner ID.
+ ///
+ [JsonPropertyName("ownerId")]
+ public string? OwnerId { get; set; }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessingError.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessingError.cs
new file mode 100644
index 0000000000..4852d5ca8a
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessingError.cs
@@ -0,0 +1,24 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+
+namespace Microsoft.Agents.AI.Purview.Models.Common;
+
+///
+/// Contains information about a processing error.
+///
+internal sealed class ProcessingError : ClassificationErrorBase
+{
+ ///
+ /// Details about the error.
+ ///
+ [JsonPropertyName("details")]
+ public List? Details { get; set; }
+
+ ///
+ /// Gets or sets the error type.
+ ///
+ [JsonPropertyName("type")]
+ public ContentProcessingErrorType? Type { get; set; }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProtectedAppMetadata.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProtectedAppMetadata.cs
new file mode 100644
index 0000000000..984a4168e7
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProtectedAppMetadata.cs
@@ -0,0 +1,26 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Text.Json.Serialization;
+
+namespace Microsoft.Agents.AI.Purview.Models.Common;
+
+///
+/// Represents metadata for a protected application that is integrated with Purview.
+///
+internal sealed class ProtectedAppMetadata : IntegratedAppMetadata
+{
+ ///
+ /// Creates a new instance of the class.
+ ///
+ /// The location information of the protected app's data.
+ public ProtectedAppMetadata(PolicyLocation applicationLocation)
+ {
+ this.ApplicationLocation = applicationLocation;
+ }
+
+ ///
+ /// The location of the application.
+ ///
+ [JsonPropertyName("applicationLocation")]
+ public PolicyLocation ApplicationLocation { get; set; }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProtectionScopeActivities.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProtectionScopeActivities.cs
new file mode 100644
index 0000000000..6c93a76124
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProtectionScopeActivities.cs
@@ -0,0 +1,52 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Runtime.Serialization;
+using System.Text.Json.Serialization;
+
+namespace Microsoft.Agents.AI.Purview.Models.Common;
+
+///
+/// Activities that can be protected by the Purview Protection Scopes API.
+///
+[Flags]
+[DataContract]
+[JsonConverter(typeof(JsonStringEnumConverter))]
+internal enum ProtectionScopeActivities
+{
+ ///
+ /// None.
+ ///
+ [EnumMember(Value = "none")]
+ None = 0,
+
+ ///
+ /// Upload text activity.
+ ///
+ [EnumMember(Value = "uploadText")]
+ UploadText = 1,
+
+ ///
+ /// Upload file activity.
+ ///
+ [EnumMember(Value = "uploadFile")]
+ UploadFile = 2,
+
+ ///
+ /// Download text activity.
+ ///
+ [EnumMember(Value = "downloadText")]
+ DownloadText = 4,
+
+ ///
+ /// Download file activity.
+ ///
+ [EnumMember(Value = "downloadFile")]
+ DownloadFile = 8,
+
+ ///
+ /// Unknown future value.
+ ///
+ [EnumMember(Value = "unknownFutureValue")]
+ UnknownFutureValue = 16
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProtectionScopeState.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProtectionScopeState.cs
new file mode 100644
index 0000000000..8fc7a534ad
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProtectionScopeState.cs
@@ -0,0 +1,27 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Text.Json.Serialization;
+
+namespace Microsoft.Agents.AI.Purview.Models.Common;
+
+///
+/// Indicates status of protection scope changes.
+///
+[JsonConverter(typeof(JsonStringEnumConverter))]
+internal enum ProtectionScopeState
+{
+ ///
+ /// Scope state hasn't changed.
+ ///
+ NotModified = 0,
+
+ ///
+ /// Scope state has changed.
+ ///
+ Modified = 1,
+
+ ///
+ /// Unknown value placeholder for future use.
+ ///
+ UnknownFutureValue = 2
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProtectionScopesCacheKey.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProtectionScopesCacheKey.cs
new file mode 100644
index 0000000000..2c772cbcb0
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProtectionScopesCacheKey.cs
@@ -0,0 +1,91 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Linq;
+using Microsoft.Agents.AI.Purview.Models.Requests;
+
+namespace Microsoft.Agents.AI.Purview.Models.Common;
+
+///
+/// A cache key for storing protection scope responses.
+///
+internal sealed class ProtectionScopesCacheKey
+{
+ ///
+ /// Creates a new instance of .
+ ///
+ /// The entra id of the user who made the interaction.
+ /// The tenant id of the user who made the interaction.
+ /// The activity performed with the data.
+ /// The location where the data came from.
+ /// The property to pivot on.
+ /// Metadata about the device that made the interaction.
+ /// Metadata about the app that is integrating with Purview.
+ public ProtectionScopesCacheKey(
+ string userId,
+ string tenantId,
+ ProtectionScopeActivities activities,
+ PolicyLocation? location,
+ PolicyPivotProperty? pivotOn,
+ DeviceMetadata? deviceMetadata,
+ IntegratedAppMetadata? integratedAppMetadata)
+ {
+ this.UserId = userId;
+ this.TenantId = tenantId;
+ this.Activities = activities;
+ this.Location = location;
+ this.PivotOn = pivotOn;
+ this.DeviceMetadata = deviceMetadata;
+ this.IntegratedAppMetadata = integratedAppMetadata;
+ }
+
+ ///
+ /// Creates a mew instance of .
+ ///
+ /// A protection scopes request.
+ public ProtectionScopesCacheKey(
+ ProtectionScopesRequest request) : this(
+ request.UserId,
+ request.TenantId,
+ request.Activities,
+ request.Locations.FirstOrDefault(),
+ request.PivotOn,
+ request.DeviceMetadata,
+ request.IntegratedAppMetadata)
+ {
+ }
+
+ ///
+ /// The id of the user making the request.
+ ///
+ public string UserId { get; set; }
+
+ ///
+ /// The id of the tenant containing the user making the request.
+ ///
+ public string TenantId { get; set; }
+
+ ///
+ /// The activity performed with the content.
+ ///
+ public ProtectionScopeActivities Activities { get; set; }
+
+ ///
+ /// The location of the application.
+ ///
+ public PolicyLocation? Location { get; set; }
+
+ ///
+ /// The property used to pivot the policy evaluation.
+ ///
+ public PolicyPivotProperty? PivotOn { get; set; }
+
+ ///
+ /// Metadata about the device used to access the content.
+ ///
+ public DeviceMetadata? DeviceMetadata { get; set; }
+
+ ///
+ /// Metadata about the integrated app used to access the content.
+ ///
+ public IntegratedAppMetadata? IntegratedAppMetadata { get; set; }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PurviewBinaryContent.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PurviewBinaryContent.cs
new file mode 100644
index 0000000000..0d65ac341d
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PurviewBinaryContent.cs
@@ -0,0 +1,28 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Text.Json.Serialization;
+
+namespace Microsoft.Agents.AI.Purview.Models.Common;
+
+///
+/// Represents a binary content item to be processed.
+///
+internal sealed class PurviewBinaryContent : ContentBase
+{
+ private const string BinaryContentDataType = Constants.ODataGraphNamespace + ".binaryContent";
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The binary content in byte array format.
+ public PurviewBinaryContent(byte[] data) : base(BinaryContentDataType)
+ {
+ this.Data = data;
+ }
+
+ ///
+ /// Gets or sets the binary data.
+ ///
+ [JsonPropertyName("data")]
+ public byte[] Data { get; set; }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PurviewTextContent.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PurviewTextContent.cs
new file mode 100644
index 0000000000..cfd03ae6ce
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PurviewTextContent.cs
@@ -0,0 +1,28 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Text.Json.Serialization;
+
+namespace Microsoft.Agents.AI.Purview.Models.Common;
+
+///
+/// Represents a text content item to be processed.
+///
+internal sealed class PurviewTextContent : ContentBase
+{
+ private const string TextContentDataType = Constants.ODataGraphNamespace + ".textContent";
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The text content in string format.
+ public PurviewTextContent(string data) : base(TextContentDataType)
+ {
+ this.Data = data;
+ }
+
+ ///
+ /// Gets or sets the text data.
+ ///
+ [JsonPropertyName("data")]
+ public string Data { get; set; }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ResourceAccessStatus.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ResourceAccessStatus.cs
new file mode 100644
index 0000000000..623f138e8b
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ResourceAccessStatus.cs
@@ -0,0 +1,32 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Runtime.Serialization;
+using System.Text.Json.Serialization;
+
+namespace Microsoft.Agents.AI.Purview.Models.Common;
+
+///
+/// Status of the access operation.
+///
+[DataContract]
+[JsonConverter(typeof(JsonStringEnumConverter))]
+internal enum ResourceAccessStatus
+{
+ ///
+ /// Represents failed access to the resource.
+ ///
+ [EnumMember(Value = "failure")]
+ Failure = 0,
+
+ ///
+ /// Represents successful access to the resource.
+ ///
+ [EnumMember(Value = "success")]
+ Success = 1,
+
+ ///
+ /// Unknown future value.
+ ///
+ [EnumMember(Value = "unknownFutureValue")]
+ UnknownFutureValue = 2
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ResourceAccessType.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ResourceAccessType.cs
new file mode 100644
index 0000000000..cb4e3b0cab
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ResourceAccessType.cs
@@ -0,0 +1,46 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Runtime.Serialization;
+using System.Text.Json.Serialization;
+
+namespace Microsoft.Agents.AI.Purview.Models.Common;
+
+///
+/// Access type performed on the resource.
+///
+[Flags]
+[DataContract]
+[JsonConverter(typeof(JsonStringEnumConverter))]
+internal enum ResourceAccessType : long
+{
+ ///
+ /// No access type.
+ ///
+ [EnumMember(Value = "none")]
+ None = 0,
+
+ ///
+ /// Read access.
+ ///
+ [EnumMember(Value = "read")]
+ Read = 1 << 0,
+
+ ///
+ /// Write access.
+ ///
+ [EnumMember(Value = "write")]
+ Write = 1 << 1,
+
+ ///
+ /// Create access.
+ ///
+ [EnumMember(Value = "create")]
+ Create = 1 << 2,
+
+ ///
+ /// Unknown future value.
+ ///
+ [EnumMember(Value = "unknownFutureValue")]
+ UnknownFutureValue = 1 << 3
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/RestrictionAction.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/RestrictionAction.cs
new file mode 100644
index 0000000000..ea13ec36a6
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/RestrictionAction.cs
@@ -0,0 +1,32 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Text.Json.Serialization;
+
+namespace Microsoft.Agents.AI.Purview.Models.Common;
+
+///
+/// Restriction actions for devices.
+///
+[JsonConverter(typeof(JsonStringEnumConverter))]
+internal enum RestrictionAction
+{
+ ///
+ /// Warn Action.
+ ///
+ Warn,
+
+ ///
+ /// Audit action.
+ ///
+ Audit,
+
+ ///
+ /// Block action.
+ ///
+ Block,
+
+ ///
+ /// Allow action
+ ///
+ Allow
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/Scope.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/Scope.cs
new file mode 100644
index 0000000000..9fc4de38fe
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/Scope.cs
@@ -0,0 +1,23 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Text.Json.Serialization;
+
+namespace Microsoft.Agents.AI.Purview.Models.Common;
+
+///
+/// Represents tenant/user/group scopes.
+///
+internal sealed class Scope
+{
+ ///
+ /// The odata type of the scope used to identify what type of scope was returned.
+ ///
+ [JsonPropertyName("@odata.type")]
+ public string? ODataType { get; set; }
+
+ ///
+ /// Gets or sets the scope identifier.
+ ///
+ [JsonPropertyName("identity")]
+ public string? Identity { get; set; }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/TokenInfo.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/TokenInfo.cs
new file mode 100644
index 0000000000..bd1338dd64
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/TokenInfo.cs
@@ -0,0 +1,29 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+namespace Microsoft.Agents.AI.Purview.Models.Common;
+
+///
+/// Info pulled from an auth token.
+///
+internal sealed class TokenInfo
+{
+ ///
+ /// The entra id of the authenticated user. This is null if the auth token is not a user token.
+ ///
+ public string? UserId { get; set; }
+
+ ///
+ /// The tenant id of the auth token.
+ ///
+ public string? TenantId { get; set; }
+
+ ///
+ /// The client id of the auth token.
+ ///
+ public string? ClientId { get; set; }
+
+ ///
+ /// Gets a value indicating whether the token is associated with a user.
+ ///
+ public bool IsUserToken => !string.IsNullOrEmpty(this.UserId);
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Jobs/BackgroundJobBase.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Jobs/BackgroundJobBase.cs
new file mode 100644
index 0000000000..ab8cc8a588
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Jobs/BackgroundJobBase.cs
@@ -0,0 +1,10 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+namespace Microsoft.Agents.AI.Purview.Models.Jobs;
+
+///
+/// Abstract base class for background jobs.
+///
+internal abstract class BackgroundJobBase
+{
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Jobs/ContentActivityJob.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Jobs/ContentActivityJob.cs
new file mode 100644
index 0000000000..513af7f331
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Jobs/ContentActivityJob.cs
@@ -0,0 +1,25 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using Microsoft.Agents.AI.Purview.Models.Requests;
+
+namespace Microsoft.Agents.AI.Purview.Models.Jobs;
+
+///
+/// Class representing a job to send content activities to the Purview service.
+///
+internal sealed class ContentActivityJob : BackgroundJobBase
+{
+ ///
+ /// Create a new instance of the class.
+ ///
+ /// The content activities request to be sent in the background.
+ public ContentActivityJob(ContentActivitiesRequest request)
+ {
+ this.Request = request;
+ }
+
+ ///
+ /// The request to send to the Purview service.
+ ///
+ public ContentActivitiesRequest Request { get; }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Jobs/ProcessContentJob.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Jobs/ProcessContentJob.cs
new file mode 100644
index 0000000000..768588f9d7
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Jobs/ProcessContentJob.cs
@@ -0,0 +1,25 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using Microsoft.Agents.AI.Purview.Models.Requests;
+
+namespace Microsoft.Agents.AI.Purview.Models.Jobs;
+
+///
+/// Class representing a job to process content.
+///
+internal sealed class ProcessContentJob : BackgroundJobBase
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The process content request to be sent in the background.
+ public ProcessContentJob(ProcessContentRequest request)
+ {
+ this.Request = request;
+ }
+
+ ///
+ /// The request to process content.
+ ///
+ public ProcessContentRequest Request { get; }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Requests/ContentActivitiesRequest.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Requests/ContentActivitiesRequest.cs
new file mode 100644
index 0000000000..a754a5a56f
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Requests/ContentActivitiesRequest.cs
@@ -0,0 +1,66 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Text.Json.Serialization;
+using Microsoft.Agents.AI.Purview.Models.Common;
+
+namespace Microsoft.Agents.AI.Purview.Models.Requests;
+
+///
+/// A request class used for contentActivity requests.
+///
+internal sealed class ContentActivitiesRequest
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The entra id of the user who performed the activity.
+ /// The tenant id of the user who performed the activity.
+ /// The metadata about the content that was sent.
+ /// The correlation id of the request.
+ /// The scope identifier of the protection scopes associated with this request.
+ public ContentActivitiesRequest(string userId, string tenantId, ContentToProcess contentMetadata, Guid correlationId = default, string? scopeIdentifier = null)
+ {
+ this.UserId = userId ?? throw new ArgumentNullException(nameof(userId));
+ this.TenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
+ this.ContentMetadata = contentMetadata ?? throw new ArgumentNullException(nameof(contentMetadata));
+ this.CorrelationId = correlationId == default ? Guid.NewGuid() : correlationId;
+ this.ScopeIdentifier = scopeIdentifier;
+ }
+
+ ///
+ /// Gets or sets the ID of the signal.
+ ///
+ [JsonPropertyName("id")]
+ public string Id { get; set; } = Guid.NewGuid().ToString();
+
+ ///
+ /// Gets or sets the user ID of the content that is generating the signal.
+ ///
+ [JsonPropertyName("userId")]
+ public string UserId { get; set; }
+
+ ///
+ /// Gets or sets the scope identifier for the signal.
+ ///
+ [JsonPropertyName("scopeIdentifier")]
+ public string? ScopeIdentifier { get; set; }
+
+ ///
+ /// Gets or sets the content and associated content metadata for the content used to generate the signal.
+ ///
+ [JsonPropertyName("contentMetadata")]
+ public ContentToProcess ContentMetadata { get; set; }
+
+ ///
+ /// Gets or sets the correlation ID for the signal.
+ ///
+ [JsonIgnore]
+ public Guid CorrelationId { get; set; }
+
+ ///
+ /// Gets or sets the tenant id for the signal.
+ ///
+ [JsonIgnore]
+ public string TenantId { get; set; }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Requests/ProcessContentRequest.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Requests/ProcessContentRequest.cs
new file mode 100644
index 0000000000..f8e9602cef
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Requests/ProcessContentRequest.cs
@@ -0,0 +1,56 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Text.Json.Serialization;
+using Microsoft.Agents.AI.Purview.Models.Common;
+
+namespace Microsoft.Agents.AI.Purview.Models.Requests;
+
+///
+/// Request for ProcessContent API
+///
+internal sealed class ProcessContentRequest
+{
+ ///
+ /// Creates a new instance of ProcessContentRequest.
+ ///
+ /// The content and its metadata that will be processed.
+ /// The entra user id of the user making the request.
+ /// The tenant id of the user making the request.
+ public ProcessContentRequest(ContentToProcess contentToProcess, string userId, string tenantId)
+ {
+ this.ContentToProcess = contentToProcess;
+ this.UserId = userId;
+ this.TenantId = tenantId;
+ }
+
+ ///
+ /// The content to process.
+ ///
+ [JsonPropertyName("contentToProcess")]
+ public ContentToProcess ContentToProcess { get; set; }
+
+ ///
+ /// The user id of the user making the request.
+ ///
+ [JsonIgnore]
+ public string UserId { get; set; }
+
+ ///
+ /// The correlation id of the request.
+ ///
+ [JsonIgnore]
+ public Guid CorrelationId { get; set; } = Guid.NewGuid();
+
+ ///
+ /// The tenant id of the user making the request.
+ ///
+ [JsonIgnore]
+ public string TenantId { get; set; }
+
+ ///
+ /// The identifier of the cached protection scopes.
+ ///
+ [JsonIgnore]
+ internal string? ScopeIdentifier { get; set; }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Requests/ProtectionScopesRequest.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Requests/ProtectionScopesRequest.cs
new file mode 100644
index 0000000000..04aba59aff
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Requests/ProtectionScopesRequest.cs
@@ -0,0 +1,86 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Runtime.Serialization;
+using System.Text.Json.Serialization;
+using Microsoft.Agents.AI.Purview.Models.Common;
+
+namespace Microsoft.Agents.AI.Purview.Models.Requests;
+
+///
+/// Request model for user protection scopes requests.
+///
+[DataContract]
+internal sealed class ProtectionScopesRequest
+{
+ ///
+ /// Creates a new instance of ProtectionScopesRequest.
+ ///
+ /// The entra id of the user who made the interaction.
+ /// The tenant id of the user who made the interaction.
+ public ProtectionScopesRequest(string userId, string tenantId)
+ {
+ this.UserId = userId;
+ this.TenantId = tenantId;
+ }
+
+ ///
+ /// Activities to include in the scope
+ ///
+ [DataMember]
+ [JsonPropertyName("activities")]
+ public ProtectionScopeActivities Activities { get; set; }
+
+ ///
+ /// Gets or sets the locations to compute protection scopes for.
+ ///
+ [JsonPropertyName("locations")]
+ public ICollection Locations { get; set; } = Array.Empty();
+
+ ///
+ /// Response aggregation pivot
+ ///
+ [DataMember]
+ [JsonPropertyName("pivotOn")]
+ public PolicyPivotProperty? PivotOn { get; set; }
+
+ ///
+ /// Device metadata
+ ///
+ [DataMember]
+ [JsonPropertyName("deviceMetadata")]
+ public DeviceMetadata? DeviceMetadata { get; set; }
+
+ ///
+ /// Integrated app metadata
+ ///
+ [DataMember]
+ [JsonPropertyName("integratedAppMetadata")]
+ public IntegratedAppMetadata? IntegratedAppMetadata { get; set; }
+
+ ///
+ /// The correlation id of the request.
+ ///
+ [JsonIgnore]
+ public Guid CorrelationId { get; set; } = Guid.NewGuid();
+
+ ///
+ /// Scope ID, used to detect stale client scoping information
+ ///
+ [DataMember]
+ [JsonIgnore]
+ public string ScopeIdentifier { get; set; } = string.Empty;
+
+ ///
+ /// The id of the user making the request.
+ ///
+ [JsonIgnore]
+ public string UserId { get; set; }
+
+ ///
+ /// The tenant id of the user making the request.
+ ///
+ [JsonIgnore]
+ public string TenantId { get; set; }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Responses/ContentActivitiesResponse.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Responses/ContentActivitiesResponse.cs
new file mode 100644
index 0000000000..afdc21618e
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Responses/ContentActivitiesResponse.cs
@@ -0,0 +1,25 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Net;
+using System.Text.Json.Serialization;
+using Microsoft.Agents.AI.Purview.Models.Common;
+
+namespace Microsoft.Agents.AI.Purview.Models.Responses;
+
+///
+/// Represents the response for content activities requests.
+///
+internal sealed class ContentActivitiesResponse
+{
+ ///
+ /// Gets or sets the HTTP status code associated with the response.
+ ///
+ [JsonIgnore]
+ public HttpStatusCode StatusCode { get; set; }
+
+ ///
+ /// Details about any errors returned by the request.
+ ///
+ [JsonPropertyName("error")]
+ public ErrorDetails? Error { get; set; }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Responses/ProcessContentResponse.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Responses/ProcessContentResponse.cs
new file mode 100644
index 0000000000..c685c7786f
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Responses/ProcessContentResponse.cs
@@ -0,0 +1,42 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.Runtime.Serialization;
+using System.Text.Json.Serialization;
+using Microsoft.Agents.AI.Purview.Models.Common;
+
+namespace Microsoft.Agents.AI.Purview.Models.Responses;
+
+///
+/// The response of a process content evaluation.
+///
+internal sealed class ProcessContentResponse
+{
+ ///
+ /// Gets or sets the evaluation id.
+ ///
+ [Key]
+ public string? Id { get; set; }
+
+ ///
+ /// Gets or sets the status of protection scope changes.
+ ///
+ [DataMember]
+ [JsonPropertyName("protectionScopeState")]
+ public ProtectionScopeState? ProtectionScopeState { get; set; }
+
+ ///
+ /// Gets or sets the policy actions to take.
+ ///
+ [DataMember]
+ [JsonPropertyName("policyActions")]
+ public IReadOnlyList? PolicyActions { get; set; }
+
+ ///
+ /// Gets or sets error information about the evaluation.
+ ///
+ [DataMember]
+ [JsonPropertyName("processingErrors")]
+ public IReadOnlyList? ProcessingErrors { get; set; }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Responses/ProtectionScopesResponse.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Responses/ProtectionScopesResponse.cs
new file mode 100644
index 0000000000..fb9b0603d8
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Responses/ProtectionScopesResponse.cs
@@ -0,0 +1,24 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+using Microsoft.Agents.AI.Purview.Models.Common;
+
+namespace Microsoft.Agents.AI.Purview.Models.Responses;
+
+///
+/// A response object containing protection scopes for a tenant.
+///
+internal sealed class ProtectionScopesResponse
+{
+ ///
+ /// The identifier used for caching the user protection scopes.
+ ///
+ public string? ScopeIdentifier { get; set; }
+
+ ///
+ /// The user protection scopes.
+ ///
+ [JsonPropertyName("value")]
+ public IReadOnlyCollection? Scopes { get; set; }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/PurviewAgent.cs b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewAgent.cs
new file mode 100644
index 0000000000..fd2a1950e9
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewAgent.cs
@@ -0,0 +1,70 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Runtime.CompilerServices;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI.Purview;
+
+///
+/// A middleware agent that connects to Microsoft Purview.
+///
+internal class PurviewAgent : AIAgent, IDisposable
+{
+ private readonly AIAgent _innerAgent;
+ private readonly PurviewWrapper _purviewWrapper;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The agent-framework agent that the middleware wraps.
+ /// The purview wrapper used to interact with the Purview service.
+ public PurviewAgent(AIAgent innerAgent, PurviewWrapper purviewWrapper)
+ {
+ this._innerAgent = innerAgent;
+ this._purviewWrapper = purviewWrapper;
+ }
+
+ ///
+ public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
+ {
+ return this._innerAgent.DeserializeThread(serializedThread, jsonSerializerOptions);
+ }
+
+ ///
+ public override AgentThread GetNewThread()
+ {
+ return this._innerAgent.GetNewThread();
+ }
+
+ ///
+ public override Task RunAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
+ {
+ return this._purviewWrapper.ProcessAgentContentAsync(messages, thread, options, this._innerAgent, cancellationToken);
+ }
+
+ ///
+ public override async IAsyncEnumerable RunStreamingAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ var response = await this._purviewWrapper.ProcessAgentContentAsync(messages, thread, options, this._innerAgent, cancellationToken).ConfigureAwait(false);
+ foreach (var update in response.ToAgentRunResponseUpdates())
+ {
+ yield return update;
+ }
+ }
+
+ ///
+ public void Dispose()
+ {
+ if (this._innerAgent is IDisposable disposableAgent)
+ {
+ disposableAgent.Dispose();
+ }
+
+ this._purviewWrapper.Dispose();
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/PurviewAppLocation.cs b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewAppLocation.cs
new file mode 100644
index 0000000000..5e5d7af96f
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewAppLocation.cs
@@ -0,0 +1,53 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using Microsoft.Agents.AI.Purview.Models.Common;
+
+namespace Microsoft.Agents.AI.Purview;
+
+///
+/// An identifier representing the app's location for Purview policy evaluation.
+///
+public class PurviewAppLocation
+{
+ ///
+ /// Creates a new instance of .
+ ///
+ /// The type of location.
+ /// The value of the location.
+ public PurviewAppLocation(PurviewLocationType locationType, string locationValue)
+ {
+ this.LocationType = locationType;
+ this.LocationValue = locationValue;
+ }
+
+ ///
+ /// The type of location.
+ ///
+ public PurviewLocationType LocationType { get; set; }
+
+ ///
+ /// The location value.
+ ///
+ public string LocationValue { get; set; }
+
+ ///
+ /// Returns the model for this .
+ ///
+ /// PolicyLocation request model.
+ /// Thrown when an invalid location type is provided.
+ internal PolicyLocation GetPolicyLocation()
+ {
+ switch (this.LocationType)
+ {
+ case PurviewLocationType.Application:
+ return new PolicyLocation($"{Constants.ODataGraphNamespace}.policyLocationApplication", this.LocationValue);
+ case PurviewLocationType.Uri:
+ return new PolicyLocation($"{Constants.ODataGraphNamespace}.policyLocationUrl", this.LocationValue);
+ case PurviewLocationType.Domain:
+ return new PolicyLocation($"{Constants.ODataGraphNamespace}.policyLocationDomain", this.LocationValue);
+ default:
+ throw new InvalidOperationException("Invalid location type.");
+ }
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/PurviewChatClient.cs b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewChatClient.cs
new file mode 100644
index 0000000000..fded26c0ae
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewChatClient.cs
@@ -0,0 +1,62 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI.Purview;
+
+///
+/// A middleware chat client that connects to Microsoft Purview.
+///
+internal class PurviewChatClient : IChatClient
+{
+ private readonly IChatClient _innerChatClient;
+ private readonly PurviewWrapper _purviewWrapper;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The inner chat client to wrap.
+ /// The purview wrapper used to interact with the Purview service.
+ public PurviewChatClient(IChatClient innerChatClient, PurviewWrapper purviewWrapper)
+ {
+ this._innerChatClient = innerChatClient;
+ this._purviewWrapper = purviewWrapper;
+ }
+
+ ///
+ public void Dispose()
+ {
+ this._purviewWrapper.Dispose();
+ this._innerChatClient.Dispose();
+ }
+
+ ///
+ public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
+ {
+ return this._purviewWrapper.ProcessChatContentAsync(messages, options, this._innerChatClient, cancellationToken);
+ }
+
+ ///
+ public object? GetService(Type serviceType, object? serviceKey = null)
+ {
+ return this._innerChatClient.GetService(serviceType, serviceKey);
+ }
+
+ ///
+ public async IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages,
+ ChatOptions? options = null,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ Task responseTask = this._purviewWrapper.ProcessChatContentAsync(messages, options, this._innerChatClient, cancellationToken);
+
+ foreach (var update in (await responseTask.ConfigureAwait(false)).ToChatResponseUpdates())
+ {
+ yield return update;
+ }
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/PurviewClient.cs b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewClient.cs
new file mode 100644
index 0000000000..7fade4eabb
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewClient.cs
@@ -0,0 +1,311 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Net;
+using System.Net.Http;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization.Metadata;
+using System.Threading;
+using System.Threading.Tasks;
+using Azure.Core;
+using Microsoft.Agents.AI.Purview.Models.Common;
+using Microsoft.Agents.AI.Purview.Models.Requests;
+using Microsoft.Agents.AI.Purview.Models.Responses;
+using Microsoft.Agents.AI.Purview.Serialization;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace Microsoft.Agents.AI.Purview;
+
+///
+/// Client for calling Purview APIs.
+///
+internal sealed class PurviewClient : IPurviewClient
+{
+ private readonly TokenCredential _tokenCredential;
+ private readonly HttpClient _httpClient;
+ private readonly string[] _scopes;
+ private readonly string _graphUri;
+ private readonly ILogger _logger;
+
+ private static PurviewException CreateExceptionForStatusCode(HttpStatusCode statusCode, string endpointName)
+ {
+ // .net framework does not support TooManyRequests, so we have to convert to an int.
+ switch ((int)statusCode)
+ {
+ case 429:
+ return new PurviewRateLimitException($"Rate limit exceeded for {endpointName}.");
+ case 401:
+ case 403:
+ return new PurviewAuthenticationException($"Unauthorized access to {endpointName}. Status code: {statusCode}");
+ case 402:
+ return new PurviewPaymentRequiredException($"Payment required for {endpointName}. Status code: {statusCode}");
+ default:
+ return new PurviewRequestException(statusCode, endpointName);
+ }
+ }
+
+ ///
+ /// Creates a new instance.
+ ///
+ /// The token credential used to authenticate with Purview.
+ /// The settings used for purview requests.
+ /// The HttpClient used to make network requests to Purview.
+ /// The logger used to log information from the middleware.
+ public PurviewClient(TokenCredential tokenCredential, PurviewSettings purviewSettings, HttpClient httpClient, ILogger logger)
+ {
+ this._tokenCredential = tokenCredential;
+ this._httpClient = httpClient;
+
+ this._scopes = new string[] { $"https://{purviewSettings.GraphBaseUri.Host}/.default" };
+ this._graphUri = purviewSettings.GraphBaseUri.ToString().TrimEnd('/');
+ this._logger = logger ?? NullLogger.Instance;
+ }
+
+ private static TokenInfo ExtractTokenInfo(string tokenString)
+ {
+ // Split JWT and decode payload
+ string[] parts = tokenString.Split('.');
+ if (parts.Length < 2)
+ {
+ throw new PurviewRequestException("Invalid JWT access token format.");
+ }
+
+ string payload = parts[1];
+ // Pad base64 string if needed
+ int mod4 = payload.Length % 4;
+ if (mod4 > 0)
+ {
+ payload += new string('=', 4 - mod4);
+ }
+
+ byte[] bytes = Convert.FromBase64String(payload.Replace('-', '+').Replace('_', '/'));
+ string json = Encoding.UTF8.GetString(bytes);
+
+ using var doc = JsonDocument.Parse(json);
+ var root = doc.RootElement;
+
+ string? objectId = root.TryGetProperty("oid", out var oidProp) ? oidProp.GetString() : null;
+ string? idType = root.TryGetProperty("idtyp", out var idtypProp) ? idtypProp.GetString() : null;
+ string? tenant = root.TryGetProperty("tid", out var tidProp) ? tidProp.GetString() : null;
+ string? clientId = root.TryGetProperty("appid", out var appidProp) ? appidProp.GetString() : null;
+
+ string? userId = idType == "user" ? objectId : null;
+
+ return new TokenInfo
+ {
+ UserId = userId,
+ TenantId = tenant,
+ ClientId = clientId
+ };
+ }
+
+ ///
+ public async Task GetUserInfoFromTokenAsync(CancellationToken cancellationToken, string? tenantId = default)
+ {
+ TokenRequestContext tokenRequestContext = tenantId == null ? new(this._scopes) : new(this._scopes, tenantId: tenantId);
+ AccessToken token = await this._tokenCredential.GetTokenAsync(tokenRequestContext, cancellationToken).ConfigureAwait(false);
+
+ string tokenString = token.Token;
+
+ return ExtractTokenInfo(tokenString);
+ }
+
+ ///
+ public async Task ProcessContentAsync(ProcessContentRequest request, CancellationToken cancellationToken)
+ {
+ var token = await this._tokenCredential.GetTokenAsync(new TokenRequestContext(this._scopes, tenantId: request.TenantId), cancellationToken).ConfigureAwait(false);
+ string userId = request.UserId;
+
+ string uri = $"{this._graphUri}/users/{userId}/dataSecurityAndGovernance/processContent";
+
+ using (HttpRequestMessage message = new(HttpMethod.Post, new Uri(uri)))
+ {
+ message.Headers.Add("Authorization", $"Bearer {token.Token}");
+ message.Headers.Add("User-Agent", "agent-framework-dotnet");
+
+ if (request.ScopeIdentifier != null)
+ {
+ message.Headers.Add("If-None-Match", request.ScopeIdentifier);
+ }
+
+ string content = JsonSerializer.Serialize(request, PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ProcessContentRequest)));
+ message.Content = new StringContent(content, Encoding.UTF8, "application/json");
+
+ HttpResponseMessage response;
+ try
+ {
+ response = await this._httpClient.SendAsync(message, cancellationToken).ConfigureAwait(false);
+ }
+ catch (HttpRequestException e)
+ {
+ this._logger.LogError(e, "Http error while processing content.");
+ throw new PurviewRequestException("Http error occurred while processing content.", e);
+ }
+
+#if NET5_0_OR_GREATER
+ // Pass the cancellation token if that method is available.
+ string responseContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
+#else
+ string responseContent = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
+#endif
+
+ if (response.StatusCode == HttpStatusCode.OK || response.StatusCode == HttpStatusCode.Accepted)
+ {
+ ProcessContentResponse? deserializedResponse;
+ try
+ {
+ JsonTypeInfo typeInfo = (JsonTypeInfo)PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ProcessContentResponse));
+ deserializedResponse = JsonSerializer.Deserialize(responseContent, typeInfo);
+ }
+ catch (JsonException jsonException)
+ {
+ const string DeserializeExceptionError = "Failed to deserialize ProcessContent response.";
+ this._logger.LogError(jsonException, DeserializeExceptionError);
+ throw new PurviewRequestException(DeserializeExceptionError, jsonException);
+ }
+
+ if (deserializedResponse != null)
+ {
+ return deserializedResponse;
+ }
+
+ const string DeserializeError = "Failed to deserialize ProcessContent response. Response was null.";
+ this._logger.LogError(DeserializeError);
+ throw new PurviewRequestException(DeserializeError);
+ }
+
+ this._logger.LogError("Failed to process content. Status code: {StatusCode}", response.StatusCode);
+ throw CreateExceptionForStatusCode(response.StatusCode, "processContent");
+ }
+ }
+
+ ///
+ public async Task GetProtectionScopesAsync(ProtectionScopesRequest request, CancellationToken cancellationToken)
+ {
+ var token = await this._tokenCredential.GetTokenAsync(new TokenRequestContext(this._scopes), cancellationToken).ConfigureAwait(false);
+ string userId = request.UserId;
+
+ string uri = $"{this._graphUri}/users/{userId}/dataSecurityAndGovernance/protectionScopes/compute";
+
+ using (HttpRequestMessage message = new(HttpMethod.Post, new Uri(uri)))
+ {
+ message.Headers.Add("Authorization", $"Bearer {token.Token}");
+ message.Headers.Add("User-Agent", "agent-framework-dotnet");
+
+ var typeinfo = PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ProtectionScopesRequest));
+ string content = JsonSerializer.Serialize(request, typeinfo);
+ message.Content = new StringContent(content, Encoding.UTF8, "application/json");
+
+ HttpResponseMessage response;
+ try
+ {
+ response = await this._httpClient.SendAsync(message, cancellationToken).ConfigureAwait(false);
+ }
+ catch (HttpRequestException e)
+ {
+ this._logger.LogError(e, "Http error while retrieving protection scopes.");
+ throw new PurviewRequestException("Http error occurred while retrieving protection scopes.", e);
+ }
+
+ if (response.StatusCode == HttpStatusCode.OK)
+ {
+#if NET5_0_OR_GREATER
+ // Pass the cancellation token if that method is available.
+ string responseContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
+#else
+ string responseContent = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
+#endif
+ ProtectionScopesResponse? deserializedResponse;
+ try
+ {
+ JsonTypeInfo typeInfo = (JsonTypeInfo)PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ProtectionScopesResponse));
+ deserializedResponse = JsonSerializer.Deserialize(responseContent, typeInfo);
+ }
+ catch (JsonException jsonException)
+ {
+ const string DeserializeExceptionError = "Failed to deserialize ProtectionScopes response.";
+ this._logger.LogError(jsonException, DeserializeExceptionError);
+ throw new PurviewRequestException(DeserializeExceptionError, jsonException);
+ }
+
+ if (deserializedResponse != null)
+ {
+ deserializedResponse.ScopeIdentifier = response.Headers.ETag?.Tag;
+ return deserializedResponse;
+ }
+
+ const string DeserializeError = "Failed to deserialize ProtectionScopes response.";
+ this._logger.LogError(DeserializeError);
+ throw new PurviewRequestException(DeserializeError);
+ }
+
+ this._logger.LogError("Failed to retrieve protection scopes. Status code: {StatusCode}", response.StatusCode);
+ throw CreateExceptionForStatusCode(response.StatusCode, "protectionScopes/compute");
+ }
+ }
+
+ ///
+ public async Task SendContentActivitiesAsync(ContentActivitiesRequest request, CancellationToken cancellationToken)
+ {
+ var token = await this._tokenCredential.GetTokenAsync(new TokenRequestContext(this._scopes), cancellationToken).ConfigureAwait(false);
+ string userId = request.UserId;
+
+ string uri = $"{this._graphUri}/{userId}/dataSecurityAndGovernance/activities/contentActivities";
+
+ using (HttpRequestMessage message = new(HttpMethod.Post, new Uri(uri)))
+ {
+ message.Headers.Add("Authorization", $"Bearer {token.Token}");
+ message.Headers.Add("User-Agent", "agent-framework-dotnet");
+ string content = JsonSerializer.Serialize(request, PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ContentActivitiesRequest)));
+ message.Content = new StringContent(content, Encoding.UTF8, "application/json");
+ HttpResponseMessage response;
+
+ try
+ {
+ response = await this._httpClient.SendAsync(message, cancellationToken).ConfigureAwait(false);
+ }
+ catch (HttpRequestException e)
+ {
+ this._logger.LogError(e, "Http error while creating content activities.");
+ throw new PurviewRequestException("Http error occurred while creating content activities.", e);
+ }
+
+ if (response.StatusCode == HttpStatusCode.Created)
+ {
+#if NET5_0_OR_GREATER
+ // Pass the cancellation token if that method is available.
+ string responseContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
+#else
+ string responseContent = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
+#endif
+ ContentActivitiesResponse? deserializedResponse;
+
+ try
+ {
+ JsonTypeInfo typeInfo = (JsonTypeInfo)PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ContentActivitiesResponse));
+ deserializedResponse = JsonSerializer.Deserialize(responseContent, typeInfo);
+ }
+ catch (JsonException jsonException)
+ {
+ const string DeserializeExceptionError = "Failed to deserialize ContentActivities response.";
+ this._logger.LogError(jsonException, DeserializeExceptionError);
+ throw new PurviewRequestException(DeserializeExceptionError, jsonException);
+ }
+
+ if (deserializedResponse != null)
+ {
+ return deserializedResponse;
+ }
+
+ const string DeserializeError = "Failed to deserialize ContentActivities response.";
+ this._logger.LogError(DeserializeError);
+ throw new PurviewRequestException(DeserializeError);
+ }
+
+ this._logger.LogError("Failed to create content activities. Status code: {StatusCode}", response.StatusCode);
+ throw CreateExceptionForStatusCode(response.StatusCode, "contentActivities");
+ }
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/PurviewExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewExtensions.cs
new file mode 100644
index 0000000000..cdeb395d67
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewExtensions.cs
@@ -0,0 +1,122 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Net.Http;
+using System.Threading.Channels;
+using Azure.Core;
+using Microsoft.Agents.AI.Purview.Models.Jobs;
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.Caching.Distributed;
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Options;
+
+namespace Microsoft.Agents.AI.Purview;
+
+///
+/// Extension methods to add Purview capabilities to an .
+///
+public static class PurviewExtensions
+{
+ private static PurviewWrapper CreateWrapper(TokenCredential tokenCredential, PurviewSettings purviewSettings, ILogger? logger = null, IDistributedCache? cache = null)
+ {
+ MemoryDistributedCacheOptions options = new()
+ {
+ SizeLimit = purviewSettings.InMemoryCacheSizeLimit,
+ };
+
+ IDistributedCache distributedCache = cache ?? new MemoryDistributedCache(Options.Create(options));
+
+ ServiceCollection services = new();
+ services.AddSingleton(tokenCredential);
+ services.AddSingleton(purviewSettings);
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton(distributedCache);
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton(logger ?? NullLogger.Instance);
+ services.AddSingleton();
+ services.AddSingleton(Channel.CreateBounded(purviewSettings.PendingBackgroundJobLimit));
+ services.AddSingleton();
+ services.AddSingleton();
+ ServiceProvider serviceProvider = services.BuildServiceProvider();
+
+ return serviceProvider.GetRequiredService();
+ }
+
+ ///
+ /// Adds Purview capabilities to an .
+ ///
+ /// The AI Agent builder for the .
+ /// The token credential used to authenticate with Purview.
+ /// The settings for communication with Purview.
+ /// The logger to use for logging.
+ /// The distributed cache to use for caching Purview responses. An in memory cache will be used if this is null.
+ /// The updated
+ public static AIAgentBuilder WithPurview(this AIAgentBuilder builder, TokenCredential tokenCredential, PurviewSettings purviewSettings, ILogger? logger = null, IDistributedCache? cache = null)
+ {
+ PurviewWrapper purviewWrapper = CreateWrapper(tokenCredential, purviewSettings, logger, cache);
+ return builder.Use((innerAgent) => new PurviewAgent(innerAgent, purviewWrapper));
+ }
+
+ ///
+ /// Adds Purview capabilities to a .
+ ///
+ /// The chat client builder for the .
+ /// The token credential used to authenticate with Purview.
+ /// The settings for communication with Purview.
+ /// The logger to use for logging.
+ /// The distributed cache to use for caching Purview responses. An in memory cache will be used if this is null.
+ /// The updated
+ public static ChatClientBuilder WithPurview(this ChatClientBuilder builder, TokenCredential tokenCredential, PurviewSettings purviewSettings, ILogger? logger = null, IDistributedCache? cache = null)
+ {
+ PurviewWrapper purviewWrapper = CreateWrapper(tokenCredential, purviewSettings, logger, cache);
+ return builder.Use((innerChatClient) => new PurviewChatClient(innerChatClient, purviewWrapper));
+ }
+
+ ///
+ /// Creates a Purview middleware function for use with a .
+ ///
+ /// The token credential used to authenticate with Purview.
+ /// The settings for communication with Purview.
+ /// The logger to use for logging.
+ /// The distributed cache to use for caching Purview responses. An in memory cache will be used if this is null.
+ /// A chat middleware delegate.
+ public static Func PurviewChatMiddleware(TokenCredential tokenCredential, PurviewSettings purviewSettings, ILogger? logger = null, IDistributedCache? cache = null)
+ {
+ PurviewWrapper purviewWrapper = CreateWrapper(tokenCredential, purviewSettings, logger, cache);
+ return (innerChatClient) => new PurviewChatClient(innerChatClient, purviewWrapper);
+ }
+
+ ///
+ /// Creates a Purview middleware function for use with an .
+ ///
+ /// The token credential used to authenticate with Purview.
+ /// The settings for communication with Purview.
+ /// The logger to use for logging.
+ /// The distributed cache to use for caching Purview responses. An in memory cache will be used if this is null.
+ /// An agent middleware delegate.
+ public static Func PurviewAgentMiddleware(TokenCredential tokenCredential, PurviewSettings purviewSettings, ILogger? logger = null, IDistributedCache? cache = null)
+ {
+ PurviewWrapper purviewWrapper = CreateWrapper(tokenCredential, purviewSettings, logger, cache);
+ return (innerAgent) => new PurviewAgent(innerAgent, purviewWrapper);
+ }
+
+ ///
+ /// Sets the user id for a message.
+ ///
+ /// The message.
+ /// The id of the owner of the message.
+ public static void SetUserId(this ChatMessage message, Guid userId)
+ {
+ if (message.AdditionalProperties == null)
+ {
+ message.AdditionalProperties = new AdditionalPropertiesDictionary();
+ }
+
+ message.AdditionalProperties[Constants.UserId] = userId.ToString();
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/PurviewLocationType.cs b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewLocationType.cs
new file mode 100644
index 0000000000..4fcc145f0b
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewLocationType.cs
@@ -0,0 +1,24 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+namespace Microsoft.Agents.AI.Purview;
+
+///
+/// The type of location for Purview policy evaluation.
+///
+public enum PurviewLocationType
+{
+ ///
+ /// An application location.
+ ///
+ Application,
+
+ ///
+ /// A URI location.
+ ///
+ Uri,
+
+ ///
+ /// A domain name location.
+ ///
+ Domain
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/PurviewSettings.cs b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewSettings.cs
new file mode 100644
index 0000000000..cb400805c6
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewSettings.cs
@@ -0,0 +1,88 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+
+namespace Microsoft.Agents.AI.Purview;
+
+///
+/// Represents the configuration settings for a Purview application, including tenant information, application name, and
+/// optional default user settings.
+///
+/// This class is used to encapsulate the necessary configuration details for interacting with Purview
+/// services. It includes the tenant ID and application name, which are required, and an optional default user ID that
+/// can be used for requests where a specific user ID is not provided.
+public class PurviewSettings
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The publicly visible name of the application.
+ public PurviewSettings(string appName)
+ {
+ this.AppName = appName;
+ }
+
+ ///
+ /// The publicly visible app name of the application.
+ ///
+ public string AppName { get; set; }
+
+ ///
+ /// The version string of the application.
+ ///
+ public string? AppVersion { get; set; }
+
+ ///
+ /// The tenant id of the user making the request.
+ /// If this is not provided, the tenant id will be inferred from the token.
+ ///
+ public string? TenantId { get; set; }
+
+ ///
+ /// Gets or sets the location of the Purview resource.
+ /// If this is not provided, a location containing the client id will be used instead.
+ ///
+ public PurviewAppLocation? PurviewAppLocation { get; set; }
+
+ ///
+ /// Gets or sets a flag indicating whether to ignore exceptions when processing Purview requests. False by default.
+ /// If set to true, exceptions calling Purview will be logged but not thrown.
+ ///
+ public bool IgnoreExceptions { get; set; }
+
+ ///
+ /// Gets or sets the base URI for the Microsoft Graph API.
+ /// Set to graph v1.0 by default.
+ ///
+ public Uri GraphBaseUri { get; set; } = new Uri("https://graph.microsoft.com/v1.0/");
+
+ ///
+ /// Gets or sets the message to display when a prompt is blocked by Purview policies.
+ ///
+ public string BlockedPromptMessage { get; set; } = "Prompt blocked by policies";
+
+ ///
+ /// Gets or sets the message to display when a response is blocked by Purview policies.
+ ///
+ public string BlockedResponseMessage { get; set; } = "Response blocked by policies";
+
+ ///
+ /// The size limit of the default in memory cache in bytes. This only applies if no cache is provided when creating Purview resources.
+ ///
+ public long? InMemoryCacheSizeLimit { get; set; } = 100_000_000;
+
+ ///
+ /// The TTL of each cache entry.
+ ///
+ public TimeSpan CacheTTL { get; set; } = TimeSpan.FromMinutes(30);
+
+ ///
+ /// The maximum number of background jobs that can be queued up.
+ ///
+ public int PendingBackgroundJobLimit { get; set; } = 100;
+
+ ///
+ /// The maximum number of concurrent job consumers.
+ ///
+ public int MaxConcurrentJobConsumers { get; set; } = 10;
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/PurviewWrapper.cs b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewWrapper.cs
new file mode 100644
index 0000000000..c8316a4e21
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewWrapper.cs
@@ -0,0 +1,181 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Agents.AI.Purview.Models.Common;
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.Logging;
+
+namespace Microsoft.Agents.AI.Purview;
+
+///
+/// A delegating agent that connects to Microsoft Purview.
+///
+internal sealed class PurviewWrapper : IDisposable
+{
+ private readonly ILogger _logger;
+ private readonly IScopedContentProcessor _scopedProcessor;
+ private readonly PurviewSettings _purviewSettings;
+ private readonly IChannelHandler _channelHandler;
+
+ ///
+ /// Creates a new instance.
+ ///
+ /// The scoped processor used to orchestrate the calls to Purview.
+ /// The settings for Purview integration.
+ /// The logger used for logging.
+ /// The channel handler used to queue background jobs and add job runners.
+ public PurviewWrapper(IScopedContentProcessor scopedProcessor, PurviewSettings purviewSettings, ILogger logger, IChannelHandler channelHandler)
+ {
+ this._scopedProcessor = scopedProcessor;
+ this._purviewSettings = purviewSettings;
+ this._logger = logger;
+ this._channelHandler = channelHandler;
+ }
+
+ private static string GetThreadIdFromAgentThread(AgentThread? thread, IEnumerable messages)
+ {
+ if (thread is ChatClientAgentThread chatClientAgentThread &&
+ chatClientAgentThread.ConversationId != null)
+ {
+ return chatClientAgentThread.ConversationId;
+ }
+
+ foreach (ChatMessage message in messages)
+ {
+ if (message.AdditionalProperties != null &&
+ message.AdditionalProperties.TryGetValue(Constants.ConversationId, out object? conversationId) &&
+ conversationId != null)
+ {
+ return conversationId.ToString() ?? Guid.NewGuid().ToString();
+ }
+ }
+
+ return Guid.NewGuid().ToString();
+ }
+
+ ///
+ /// Processes a prompt and response exchange at a chat client level.
+ ///
+ /// The messages sent to the chat client.
+ /// The chat options used with the chat client.
+ /// The wrapped chat client.
+ /// The cancellation token used to interrupt async operations.
+ /// The chat client's response. This could be the response from the chat client or a message indicating that Purview has blocked the prompt or response.
+ public async Task ProcessChatContentAsync(IEnumerable messages, ChatOptions? options, IChatClient innerChatClient, CancellationToken cancellationToken)
+ {
+ string? resolvedUserId = null;
+
+ try
+ {
+ (bool shouldBlockPrompt, resolvedUserId) = await this._scopedProcessor.ProcessMessagesAsync(messages, options?.ConversationId, Activity.UploadText, this._purviewSettings, null, cancellationToken).ConfigureAwait(false);
+ if (shouldBlockPrompt)
+ {
+ this._logger.LogInformation("Prompt blocked by policy. Sending message: {Message}", this._purviewSettings.BlockedPromptMessage);
+ return new ChatResponse(new ChatMessage(ChatRole.System, this._purviewSettings.BlockedPromptMessage));
+ }
+ }
+ catch (Exception ex)
+ {
+ this._logger.LogError(ex, "Error processing prompt: {ExceptionMessage}", ex.Message);
+
+ if (!this._purviewSettings.IgnoreExceptions)
+ {
+ throw;
+ }
+ }
+
+ ChatResponse response = await innerChatClient.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false);
+
+ try
+ {
+ (bool shouldBlockResponse, _) = await this._scopedProcessor.ProcessMessagesAsync(response.Messages, options?.ConversationId, Activity.UploadText, this._purviewSettings, resolvedUserId, cancellationToken).ConfigureAwait(false);
+ if (shouldBlockResponse)
+ {
+ this._logger.LogInformation("Response blocked by policy. Sending message: {Message}", this._purviewSettings.BlockedResponseMessage);
+ return new ChatResponse(new ChatMessage(ChatRole.System, this._purviewSettings.BlockedResponseMessage));
+ }
+ }
+ catch (Exception ex)
+ {
+ this._logger.LogError(ex, "Error processing response: {ExceptionMessage}", ex.Message);
+
+ if (!this._purviewSettings.IgnoreExceptions)
+ {
+ throw;
+ }
+ }
+
+ return response;
+ }
+
+ ///
+ /// Processes a prompt and response exchange at an agent level.
+ ///
+ /// The messages sent to the agent.
+ /// The thread used for this agent conversation.
+ /// The options used with this agent.
+ /// The wrapped agent.
+ /// The cancellation token used to interrupt async operations.
+ /// The agent's response. This could be the response from the agent or a message indicating that Purview has blocked the prompt or response.
+ public async Task ProcessAgentContentAsync(IEnumerable messages, AgentThread? thread, AgentRunOptions? options, AIAgent innerAgent, CancellationToken cancellationToken)
+ {
+ string threadId = GetThreadIdFromAgentThread(thread, messages);
+
+ string? resolvedUserId = null;
+
+ try
+ {
+ (bool shouldBlockPrompt, resolvedUserId) = await this._scopedProcessor.ProcessMessagesAsync(messages, threadId, Activity.UploadText, this._purviewSettings, null, cancellationToken).ConfigureAwait(false);
+
+ if (shouldBlockPrompt)
+ {
+ this._logger.LogInformation("Prompt blocked by policy. Sending message: {Message}", this._purviewSettings.BlockedPromptMessage);
+ return new AgentRunResponse(new ChatMessage(ChatRole.System, this._purviewSettings.BlockedPromptMessage));
+ }
+ }
+ catch (Exception ex)
+ {
+ this._logger.LogError(ex, "Error processing prompt: {ExceptionMessage}", ex.Message);
+
+ if (!this._purviewSettings.IgnoreExceptions)
+ {
+ throw;
+ }
+ }
+
+ AgentRunResponse response = await innerAgent.RunAsync(messages, thread, options, cancellationToken).ConfigureAwait(false);
+
+ try
+ {
+ (bool shouldBlockResponse, _) = await this._scopedProcessor.ProcessMessagesAsync(response.Messages, threadId, Activity.UploadText, this._purviewSettings, resolvedUserId, cancellationToken).ConfigureAwait(false);
+
+ if (shouldBlockResponse)
+ {
+ this._logger.LogInformation("Response blocked by policy. Sending message: {Message}", this._purviewSettings.BlockedResponseMessage);
+ return new AgentRunResponse(new ChatMessage(ChatRole.System, this._purviewSettings.BlockedResponseMessage));
+ }
+ }
+ catch (Exception ex)
+ {
+ this._logger.LogError(ex, "Error processing response: {ExceptionMessage}", ex.Message);
+
+ if (!this._purviewSettings.IgnoreExceptions)
+ {
+ throw;
+ }
+ }
+
+ return response;
+ }
+
+ ///
+ public void Dispose()
+ {
+#pragma warning disable VSTHRD002 // Need to wait for pending jobs to complete.
+ this._channelHandler.StopAndWaitForCompletionAsync().GetAwaiter().GetResult();
+#pragma warning restore VSTHRD002 // Need to wait for pending jobs to complete.
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/README.md b/dotnet/src/Microsoft.Agents.AI.Purview/README.md
new file mode 100644
index 0000000000..3e46ceff65
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/README.md
@@ -0,0 +1,263 @@
+# Microsoft Agent Framework - Purview Integration (Dotnet)
+
+The Purview plugin for the Microsoft Agent Framework adds Purview policy evaluation to the Microsoft Agent Framework.
+It lets you enforce data security and governance policies on both the *prompt* (user input + conversation history) and the *model response* before they proceed further in your workflow.
+
+> Status: **Preview**
+
+### Key Features
+
+- Middleware-based policy enforcement (agent-level and chat-client level)
+- Blocks or allows content at both ingress (prompt) and egress (response)
+- Works with any `IChatClient` or `AIAgent` using the standard Agent Framework middleware pipeline.
+- Authenticates to Purview using `TokenCredential`s
+- Simple configuration using `PurviewSettings`
+- Configurable caching using `IDistributedCache`
+- `WithPurview` Extension methods to easily apply middleware to a `ChatClientBuilder` or `AIAgentBuilder`
+
+### When to Use
+Add Purview when you need to:
+
+- Prevent sensitive or disallowed content from being sent to an LLM
+- Prevent model output containing disallowed data from leaving the system
+- Apply centrally managed policies without rewriting agent logic
+
+---
+
+
+## Quick Start
+
+``` csharp
+using Azure.AI.OpenAI;
+using Azure.Core;
+using Azure.Identity;
+using Microsoft.Agents.AI;
+using Microsoft.Agents.AI.Purview;
+using Microsoft.Extensions.AI;
+
+Uri endpoint = new Uri("..."); // The endpoint of Azure OpenAI instance.
+string deploymentName = "..."; // The deployment name of your Azure OpenAI instance ex: gpt-4o-mini
+string purviewClientAppId = "..."; // The client id of your entra app registration.
+
+// This will get a user token for an entra app configured to call the Purview API.
+// Any TokenCredential with permissions to call the Purview API can be used here.
+TokenCredential browserCredential = new InteractiveBrowserCredential(
+ new InteractiveBrowserCredentialOptions
+ {
+ ClientId = purviewClientAppId
+ });
+
+IChatClient client = new AzureOpenAIClient(
+ new Uri(endpoint),
+ new AzureCliCredential())
+ .GetOpenAIResponseClient(deploymentName)
+ .AsIChatClient()
+ .AsBuilder()
+ .WithPurview(browserCredential, new PurviewSettings("My Sample App"))
+ .Build();
+
+using (client)
+{
+ Console.WriteLine("Enter a prompt to send to the client:");
+ string? promptText = Console.ReadLine();
+
+ if (!string.IsNullOrEmpty(promptText))
+ {
+ // Invoke the agent and output the text result.
+ Console.WriteLine(await client.GetResponseAsync(promptText));
+ }
+}
+```
+
+If a policy violation is detected on the prompt, the middleware interrupts the run and outputs the message: `"Prompt blocked by policies"`. If on the response, the result becomes `"Response blocked by policies"`.
+
+---
+
+## Authentication
+
+The Purview middleware uses Azure.Core TokenCredential objects for authentication.
+
+The plugin requires the following Graph permissions:
+- ProtectionScopes.Compute.All : [userProtectionScopeContainer](https://learn.microsoft.com/en-us/graph/api/userprotectionscopecontainer-compute)
+- Content.Process.All : [processContent](https://learn.microsoft.com/en-us/graph/api/userdatasecurityandgovernance-processcontent)
+- ContentActivity.Write : [contentActivity](https://learn.microsoft.com/en-us/graph/api/activitiescontainer-post-contentactivities)
+
+Authentication with user tokens is preferred. If authenticating with app tokens, the agent-framework caller will need to provide an entra user id for each `ChatMessage` send to the agent/client. This user id can be set using the `SetUserId` extension method, or by setting the `"userId"` field of the `AdditionalProperties` dictionary.
+
+``` csharp
+// Manually
+var message = new ChatMessage(ChatRole.User, promptText);
+if (message.AdditionalProperties == null)
+{
+ message.AdditionalProperties = new AdditionalPropertiesDictionary();
+}
+message.AdditionalProperties["userId"] = "";
+
+// Or with the extension method
+var message = new ChatMessage(ChatRole.User, promptText);
+message.SetUserId(new Guid(""));
+```
+
+### Tenant Enablement for Purview
+- The tenant requires an e5 license and consumptive billing setup.
+- [Data Loss Prevention](https://learn.microsoft.com/en-us/purview/dlp-create-deploy-policy) or [Data Collection Policies](https://learn.microsoft.com/en-us/purview/collection-policies-policy-reference) policies that apply to the user are required to enable classification and message ingestion (Process Content API). Otherwise, messages will only be logged in Purview's Audit log (Content Activities API).
+
+## Configuration
+
+### Settings
+
+The Purview middleware can be customized and configured using the `PurviewSettings` class.
+
+#### `PurviewSettings`
+
+| Field | Type | Purpose |
+| ----- | ---- | ------- |
+| AppName | string | The publicly visible app name of the application. |
+| AppVersion | string? | (Optional) The version string of the application. |
+| TenantId | string? | (Optional) The tenant id of the user making the request. If not provided, this will be inferred from the token. |
+| PurviewAppLocation | PurviewAppLocation? | (Optional) The location of the Purview resource used during policy evaluation. If not provided, a location containing the application client id will be used instead. |
+| IgnoreExceptions | bool | (Optional, `false` by default) Determines if the exceptions thrown in the Purview middleware should be ignored. If set to true, exceptions will be logged but not thrown. |
+| GraphBaseUri | Uri | (Optional, https://graph.microsoft.com/v1.0/ by default) The base URI used for calls to Purview's Microsoft Graph APIs. |
+| BlockedPromptMessage | string | (Optional, `"Prompt blocked by policies"` by default) The message returned when a prompt is blocked by Purview. |
+| BlockedResponseMessage | string | (Optional, `"Response blocked by policies"` by default) The message returned when a response is blocked by Purview. |
+| InMemoryCacheSizeLimit | long? | (Optional, `100_000_000` by default) The size limit of the default in-memory cache in bytes. This only applies if no cache is provided when creating the Purview middleware. |
+| CacheTTL | TimeSpan | (Optional, 30 minutes by default) The time to live of each cache entry. |
+| PendingBackgroundJobLimit | int | (Optional, 100 by default) The maximum number of pending background jobs that can be queued in the middleware. |
+| MaxConcurrentJobConsumers | int | (Optional, 10 by default) The maximum number of concurrent consumers that can run background jobs in the middleware. |
+
+#### `PurviewAppLocation`
+
+| Field | Type | Purpose |
+| ----- | ---- | ------- |
+| LocationType | PurviewLocationType | The type of the location: Application, Uri, Domain. |
+| LocationValue | string | The value of the location. |
+
+#### Location
+
+The `PurviewAppLocation` field of the `PurviewSettings` object contains the location of the app which is used by Purview for policy evaluation (see [policyLocation](https://learn.microsoft.com/en-us/graph/api/resources/policylocation?view=graph-rest-1.0) for more information).
+This location can be set to the URL of the agent app, the domain of the agent app, or the application id of the agent app.
+
+#### Example
+
+```csharp
+var location = new PurviewAppLocation(PurviewLocationType.Uri, "https://contoso.com/chatagent");
+var settings = new PurviewSettings("My Sample App")
+{
+ AppVersion = "1.0",
+ TenantId = "your-tenant-id",
+ PurviewAppLocation = location,
+ IgnoreExceptions = false,
+ GraphBaseUri = new Uri("https://graph.microsoft.com/v1.0/"),
+ BlockedPromptMessage = "Prompt blocked by policies.",
+ BlockedResponseMessage = "Response blocked by policies.",
+ InMemoryCacheSizeLimit = 100_000_000,
+ CacheTTL = TimeSpan.FromMinutes(30),
+ PendingBackgroundJobLimit = 100,
+ MaxConcurrentJobConsumers = 10,
+};
+
+// ... Set up credential and client builder ...
+
+var client = builder.WithPurview(credential, settings).Build();
+```
+
+#### Customizing Blocked Messages
+
+This is useful for:
+- Providing more user-friendly error messages
+- Including support contact information
+- Localizing messages for different languages
+- Adding branding or specific guidance for your application
+
+``` csharp
+var settings = new PurviewSettings("My Sample App")
+{
+ BlockedPromptMessage = "Your request contains content that violates our policies. Please rephrase and try again.",
+ BlockedResponseMessage = "The response was blocked due to policy restrictions. Please contact support if you need assistance.",
+};
+```
+
+### Selecting Agent vs Chat Middleware
+
+Use the agent middleware when you already have / want the full agent pipeline:
+
+``` csharp
+AIAgent agent = new AzureOpenAIClient(
+ new Uri(endpoint),
+ new AzureCliCredential())
+ .GetChatClient(deploymentName)
+ .CreateAIAgent("You are a helpful assistant.")
+ .AsBuilder()
+ .WithPurview(browserCredential, new PurviewSettings("Agent Framework Test App"))
+ .Build();
+```
+
+Use the chat middleware when you attach directly to a chat client (e.g. minimal agent shell or custom orchestration):
+
+``` csharp
+IChatClient client = new AzureOpenAIClient(
+ new Uri(endpoint),
+ new AzureCliCredential())
+ .GetOpenAIResponseClient(deploymentName)
+ .AsIChatClient()
+ .AsBuilder()
+ .WithPurview(browserCredential, new PurviewSettings("Agent Framework Test App"))
+ .Build();
+```
+
+The policy logic is identical; the only difference is the hook point in the pipeline.
+
+---
+
+## Middleware Lifecycle
+1. Before sending the prompt to the agent, the middleware checks the app and user metadata against Purview's protection scopes and evaluates all the `ChatMessage`s in the prompt.
+2. If the content was blocked, the middleware returns a `ChatResponse` or `AgentRunResponse` containing the `BlockedPromptMessage` text. The blocked content does not get passed to the agent.
+3. If the evaluation did not block the content, the middleware passes the prompt data to the agent and waits for a response.
+4. After receiving a response from the agent, the middleware calls Purview again to evaluate the response content.
+5. If the content was blocked, the middleware returns a response containing the `BlockedResponseMessage`.
+
+The user id from the prompt message(s) is reused for the response evaluation so both evaluations map consistently to the same user.
+
+There are several optimizations to speed up Purview calls. Protection scope lookups (the first step in evaluation) are cached to minimize network calls.
+If the policies allow content to be processed offline, the middleware will add the process content request to a channel and run it in a background worker. Similarly, the middleware will run a background request if no scopes apply and the interaction only has to be logged in Audit.
+
+## Exceptions
+| Exception | Scenario |
+| --------- | -------- |
+| PurviewAuthenticationException | Token acquisition / validation issues |
+| PurviewJobException | Errors thrown by a background job |
+| PurviewJobLimitExceededException | Errors caused by exceeding the background job limit |
+| PurviewPaymentRequiredException | 402 responses from the service |
+| PurviewRateLimitException | 429 responses from the service |
+| PurviewRequestException | Other errors related to Purview requests |
+| PurviewException | Base class for all Purview plugin exceptions |
+
+Callers' exception handling can be fine-grained
+
+``` csharp
+try
+{
+ // Code that uses Purview middleware
+}
+catch (PurviewPaymentRequiredException)
+{
+ this._logger.LogError("Payment required for Purview.");
+}
+catch (PurviewAuthenticationException)
+{
+ this._logger.LogError("Error authenticating to Purview.");
+}
+```
+
+Or broad
+
+``` csharp
+try
+{
+ // Code that uses Purview middleware
+}
+catch (PurviewException e)
+{
+ this._logger.LogError(e, "Purview middleware threw an exception.")
+}
+```
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs b/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs
new file mode 100644
index 0000000000..da9e61a22e
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs
@@ -0,0 +1,358 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Agents.AI.Purview.Models.Common;
+using Microsoft.Agents.AI.Purview.Models.Jobs;
+using Microsoft.Agents.AI.Purview.Models.Requests;
+using Microsoft.Agents.AI.Purview.Models.Responses;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI.Purview;
+
+///
+/// Processor class that combines protectionScopes, processContent, and contentActivities calls.
+///
+internal sealed class ScopedContentProcessor : IScopedContentProcessor
+{
+ private readonly IPurviewClient _purviewClient;
+ private readonly ICacheProvider _cacheProvider;
+ private readonly IChannelHandler _channelHandler;
+
+ ///
+ /// Create a new instance of .
+ ///
+ /// The purview client to use for purview requests.
+ /// The cache used to store Purview data.
+ /// The channel handler used to manage background jobs.
+ public ScopedContentProcessor(IPurviewClient purviewClient, ICacheProvider cacheProvider, IChannelHandler channelHandler)
+ {
+ this._purviewClient = purviewClient;
+ this._cacheProvider = cacheProvider;
+ this._channelHandler = channelHandler;
+ }
+
+ ///
+ public async Task<(bool shouldBlock, string? userId)> ProcessMessagesAsync(IEnumerable messages, string? threadId, Activity activity, PurviewSettings purviewSettings, string? userId, CancellationToken cancellationToken)
+ {
+ List pcRequests = await this.MapMessageToPCRequestsAsync(messages, threadId, activity, purviewSettings, userId, cancellationToken).ConfigureAwait(false);
+
+ bool shouldBlock = false;
+ string? resolvedUserId = null;
+
+ foreach (ProcessContentRequest pcRequest in pcRequests)
+ {
+ resolvedUserId = pcRequest.UserId;
+ ProcessContentResponse processContentResponse = await this.ProcessContentWithProtectionScopesAsync(pcRequest, cancellationToken).ConfigureAwait(false);
+ if (processContentResponse.PolicyActions?.Count > 0)
+ {
+ foreach (DlpActionInfo policyAction in processContentResponse.PolicyActions)
+ {
+ // We need to process all data before blocking, so set the flag and return it outside of this loop.
+ if (policyAction.Action == DlpAction.BlockAccess)
+ {
+ shouldBlock = true;
+ }
+
+ if (policyAction.RestrictionAction == RestrictionAction.Block)
+ {
+ shouldBlock = true;
+ }
+ }
+ }
+ }
+
+ return (shouldBlock, resolvedUserId);
+ }
+
+ private static bool TryGetUserIdFromPayload(IEnumerable messages, out string? userId)
+ {
+ userId = null;
+
+ foreach (ChatMessage message in messages)
+ {
+ if (message.AdditionalProperties != null &&
+ message.AdditionalProperties.TryGetValue(Constants.UserId, out userId) &&
+ !string.IsNullOrEmpty(userId))
+ {
+ return true;
+ }
+ else if (Guid.TryParse(message.AuthorName, out Guid _))
+ {
+ userId = message.AuthorName;
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ /// Transform a list of ChatMessages into a list of ProcessContentRequests.
+ ///
+ /// The messages to transform.
+ /// The id of the message thread.
+ /// The activity performed on the content.
+ /// The settings used for purview integration.
+ /// The entra id of the user who made the interaction.
+ /// The cancellation token used to cancel async operations.
+ /// A list of process content requests.
+ private async Task> MapMessageToPCRequestsAsync(IEnumerable messages, string? threadId, Activity activity, PurviewSettings settings, string? userId, CancellationToken cancellationToken)
+ {
+ List pcRequests = new();
+ TokenInfo? tokenInfo = null;
+
+ bool needUserId = userId == null && TryGetUserIdFromPayload(messages, out userId);
+
+ // Only get user info if the tenant id is null or if there's no location.
+ // If location is missing, we will create a new location using the client id.
+ if (settings.TenantId == null ||
+ settings.PurviewAppLocation == null ||
+ needUserId)
+ {
+ tokenInfo = await this._purviewClient.GetUserInfoFromTokenAsync(cancellationToken, settings.TenantId).ConfigureAwait(false);
+ }
+
+ string tenantId = settings.TenantId ?? tokenInfo?.TenantId ?? throw new PurviewRequestException("No tenant id provided or inferred for Purview request. Please provide a tenant id in PurviewSettings or configure the TokenCredential to authenticate to a tenant.");
+
+ foreach (ChatMessage message in messages)
+ {
+ string messageId = message.MessageId ?? Guid.NewGuid().ToString();
+ ContentBase content = new PurviewTextContent(message.Text);
+ ProcessConversationMetadata conversationmetadata = new(content, messageId, false, $"Agent Framework Message {messageId}")
+ {
+ CorrelationId = threadId ?? Guid.NewGuid().ToString()
+ };
+ ActivityMetadata activityMetadata = new(activity);
+ PolicyLocation policyLocation;
+
+ if (settings.PurviewAppLocation != null)
+ {
+ policyLocation = settings.PurviewAppLocation.GetPolicyLocation();
+ }
+ else if (tokenInfo?.ClientId != null)
+ {
+ policyLocation = new($"{Constants.ODataGraphNamespace}.policyLocationApplication", tokenInfo.ClientId);
+ }
+ else
+ {
+ throw new PurviewRequestException("No app location provided or inferred for Purview request. Please provide an app location in PurviewSettings or configure the TokenCredential to authenticate to an entra app.");
+ }
+
+ string appVersion = !string.IsNullOrEmpty(settings.AppVersion) ? settings.AppVersion : "Unknown";
+
+ ProtectedAppMetadata protectedAppMetadata = new(policyLocation)
+ {
+ Name = settings.AppName,
+ Version = appVersion
+ };
+ IntegratedAppMetadata integratedAppMetadata = new()
+ {
+ Name = settings.AppName,
+ Version = appVersion
+ };
+
+ DeviceMetadata deviceMetadata = new()
+ {
+ OperatingSystemSpecifications = new()
+ {
+ OperatingSystemPlatform = "Unknown",
+ OperatingSystemVersion = "Unknown"
+ }
+ };
+ ContentToProcess contentToProcess = new(new List { conversationmetadata }, activityMetadata, deviceMetadata, integratedAppMetadata, protectedAppMetadata);
+
+ if (userId == null &&
+ tokenInfo?.UserId != null)
+ {
+ userId = tokenInfo.UserId;
+ }
+
+ if (string.IsNullOrEmpty(userId))
+ {
+ throw new PurviewRequestException("No user id provided or inferred for Purview request. Please provide an Entra user id in each message's AuthorName, set a default Entra user id in PurviewSettings, or configure the TokenCredential to authenticate to an Entra user.");
+ }
+
+ ProcessContentRequest pcRequest = new(contentToProcess, userId, tenantId);
+ pcRequests.Add(pcRequest);
+ }
+
+ return pcRequests;
+ }
+
+ ///
+ /// Orchestrates process content and protection scopes calls.
+ ///
+ /// The process content request.
+ /// The cancellation token used to cancel async operations.
+ /// A process content response. This could be a response from the process content API or a response generated from a content activities call.
+ private async Task ProcessContentWithProtectionScopesAsync(ProcessContentRequest pcRequest, CancellationToken cancellationToken)
+ {
+ ProtectionScopesRequest psRequest = CreateProtectionScopesRequest(pcRequest, pcRequest.UserId, pcRequest.TenantId, pcRequest.CorrelationId);
+
+ ProtectionScopesCacheKey cacheKey = new(psRequest);
+
+ ProtectionScopesResponse? cacheResponse = await this._cacheProvider.GetAsync(cacheKey, cancellationToken).ConfigureAwait(false);
+
+ ProtectionScopesResponse psResponse;
+
+ if (cacheResponse != null)
+ {
+ psResponse = cacheResponse;
+ }
+ else
+ {
+ psResponse = await this._purviewClient.GetProtectionScopesAsync(psRequest, cancellationToken).ConfigureAwait(false);
+ await this._cacheProvider.SetAsync(cacheKey, psResponse, cancellationToken).ConfigureAwait(false);
+ }
+
+ pcRequest.ScopeIdentifier = psResponse.ScopeIdentifier;
+
+ (bool shouldProcess, List dlpActions, ExecutionMode executionMode) = CheckApplicableScopes(pcRequest, psResponse);
+
+ if (shouldProcess)
+ {
+ if (executionMode == ExecutionMode.EvaluateOffline)
+ {
+ this._channelHandler.QueueJob(new ProcessContentJob(pcRequest));
+ return new ProcessContentResponse();
+ }
+
+ ProcessContentResponse pcResponse = await this._purviewClient.ProcessContentAsync(pcRequest, cancellationToken).ConfigureAwait(false);
+
+ if (pcResponse.ProtectionScopeState == ProtectionScopeState.Modified)
+ {
+ await this._cacheProvider.RemoveAsync(cacheKey, cancellationToken).ConfigureAwait(false);
+ }
+
+ pcResponse = CombinePolicyActions(pcResponse, dlpActions);
+ return pcResponse;
+ }
+
+ ContentActivitiesRequest caRequest = new(pcRequest.UserId, pcRequest.TenantId, pcRequest.ContentToProcess, pcRequest.CorrelationId);
+ this._channelHandler.QueueJob(new ContentActivityJob(caRequest));
+
+ return new ProcessContentResponse();
+ }
+
+ ///
+ /// Dedupe policy actions received from the service.
+ ///
+ /// The process content response which may contain DLP actions.
+ /// DLP actions returned from protection scopes.
+ /// The process content response with the protection scopes DLP actions added. Actions are deduplicated.
+ private static ProcessContentResponse CombinePolicyActions(ProcessContentResponse pcResponse, List? actionInfos)
+ {
+ if (actionInfos == null || actionInfos.Count == 0)
+ {
+ return pcResponse;
+ }
+
+ if (pcResponse.PolicyActions == null)
+ {
+ pcResponse.PolicyActions = actionInfos;
+ return pcResponse;
+ }
+
+ List pcActionInfos = new(pcResponse.PolicyActions);
+ pcActionInfos.AddRange(actionInfos);
+ pcResponse.PolicyActions = pcActionInfos;
+ return pcResponse;
+ }
+
+ ///
+ /// Check if any scopes are applicable to the request.
+ ///
+ /// The process content request.
+ /// The protection scopes response that was returned for the process content request.
+ /// A bool indicating if the content needs to be processed. A list of applicable actions from the scopes response, and the execution mode for the process content request.
+ private static (bool shouldProcess, List dlpActions, ExecutionMode executionMode) CheckApplicableScopes(ProcessContentRequest pcRequest, ProtectionScopesResponse psResponse)
+ {
+ ProtectionScopeActivities requestActivity = TranslateActivity(pcRequest.ContentToProcess.ActivityMetadata.Activity);
+
+ // The location data type is formatted as microsoft.graph.{locationType}
+ // Sometimes a '#' gets appended by graph during responses, so for the sake of simplicity,
+ // Split it by '.' and take the last segment. We'll do a case-insensitive endsWith later.
+ string[] locationSegments = pcRequest.ContentToProcess.ProtectedAppMetadata.ApplicationLocation.DataType.Split('.');
+ string locationType = locationSegments.Length > 0 ? locationSegments[locationSegments.Length - 1] : pcRequest.ContentToProcess.ProtectedAppMetadata.ApplicationLocation.Value;
+
+ string locationValue = pcRequest.ContentToProcess.ProtectedAppMetadata.ApplicationLocation.Value;
+ List dlpActions = new();
+ bool shouldProcess = false;
+ ExecutionMode executionMode = ExecutionMode.EvaluateOffline;
+
+ foreach (var scope in psResponse.Scopes ?? Array.Empty())
+ {
+ bool activityMatch = scope.Activities.HasFlag(requestActivity);
+ bool locationMatch = false;
+
+ foreach (var location in scope.Locations ?? Array.Empty())
+ {
+ locationMatch = location.DataType.EndsWith(locationType, StringComparison.OrdinalIgnoreCase) && location.Value.Equals(locationValue, StringComparison.OrdinalIgnoreCase);
+ }
+
+ if (activityMatch && locationMatch)
+ {
+ shouldProcess = true;
+
+ if (scope.ExecutionMode == ExecutionMode.EvaluateInline)
+ {
+ executionMode = ExecutionMode.EvaluateInline;
+ }
+
+ if (scope.PolicyActions != null)
+ {
+ dlpActions.AddRange(scope.PolicyActions);
+ }
+ }
+ }
+
+ return (shouldProcess, dlpActions, executionMode);
+ }
+
+ ///
+ /// Create a ProtectionScopesRequest for the given content ProcessContentRequest.
+ ///
+ /// The process content request.
+ /// The entra user id of the user who sent the data.
+ /// The tenant id of the user who sent the data.
+ /// The correlation id of the request.
+ /// The protection scopes request generated from the process content request.
+ private static ProtectionScopesRequest CreateProtectionScopesRequest(ProcessContentRequest pcRequest, string userId, string tenantId, Guid correlationId)
+ {
+ return new ProtectionScopesRequest(userId, tenantId)
+ {
+ Activities = TranslateActivity(pcRequest.ContentToProcess.ActivityMetadata.Activity),
+ Locations = new List { pcRequest.ContentToProcess.ProtectedAppMetadata.ApplicationLocation },
+ DeviceMetadata = pcRequest.ContentToProcess.DeviceMetadata,
+ IntegratedAppMetadata = pcRequest.ContentToProcess.IntegratedAppMetadata,
+ CorrelationId = correlationId
+ };
+ }
+
+ ///
+ /// Map process content activity to protection scope activity.
+ ///
+ /// The process content activity.
+ /// The protection scopes activity.
+ private static ProtectionScopeActivities TranslateActivity(Activity activity)
+ {
+ switch (activity)
+ {
+ case Activity.Unknown:
+ return ProtectionScopeActivities.None;
+ case Activity.UploadText:
+ return ProtectionScopeActivities.UploadText;
+ case Activity.UploadFile:
+ return ProtectionScopeActivities.UploadFile;
+ case Activity.DownloadText:
+ return ProtectionScopeActivities.DownloadText;
+ case Activity.DownloadFile:
+ return ProtectionScopeActivities.DownloadFile;
+ default:
+ return ProtectionScopeActivities.UnknownFutureValue;
+ }
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Serialization/PurviewSerializationUtils.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Serialization/PurviewSerializationUtils.cs
new file mode 100644
index 0000000000..320fbcd3b6
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/Serialization/PurviewSerializationUtils.cs
@@ -0,0 +1,41 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Microsoft.Agents.AI.Purview.Models.Common;
+using Microsoft.Agents.AI.Purview.Models.Requests;
+using Microsoft.Agents.AI.Purview.Models.Responses;
+
+namespace Microsoft.Agents.AI.Purview.Serialization;
+
+///
+/// Source generation context for Purview serialization.
+///
+[JsonSerializable(typeof(ProtectionScopesRequest))]
+[JsonSerializable(typeof(ProtectionScopesResponse))]
+[JsonSerializable(typeof(ProcessContentRequest))]
+[JsonSerializable(typeof(ProcessContentResponse))]
+[JsonSerializable(typeof(ContentActivitiesRequest))]
+[JsonSerializable(typeof(ContentActivitiesResponse))]
+[JsonSerializable(typeof(ProtectionScopesCacheKey))]
+internal sealed partial class SourceGenerationContext : JsonSerializerContext;
+
+///
+/// Utility class for Purview serialization settings.
+///
+internal static class PurviewSerializationUtils
+{
+ ///
+ /// Serialization settings for Purview.
+ ///
+ public static JsonSerializerOptions SerializationSettings { get; } = new JsonSerializerOptions
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ PropertyNameCaseInsensitive = true,
+ WriteIndented = false,
+ AllowTrailingCommas = false,
+ DictionaryKeyPolicy = JsonNamingPolicy.CamelCase,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ TypeInfoResolver = SourceGenerationContext.Default,
+ };
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/Microsoft.Agents.AI.Purview.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/Microsoft.Agents.AI.Purview.UnitTests.csproj
new file mode 100644
index 0000000000..bd07eca8ab
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/Microsoft.Agents.AI.Purview.UnitTests.csproj
@@ -0,0 +1,12 @@
+
+
+
+ $(ProjectsTargetFrameworks)
+
+
+
+
+
+
+
+
diff --git a/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/PurviewClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/PurviewClientTests.cs
new file mode 100644
index 0000000000..b2b0ac45e6
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/PurviewClientTests.cs
@@ -0,0 +1,585 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Net;
+using System.Net.Http;
+using System.Text;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Azure.Core;
+using Microsoft.Agents.AI.Purview.Models.Common;
+using Microsoft.Agents.AI.Purview.Models.Requests;
+using Microsoft.Agents.AI.Purview.Models.Responses;
+using Microsoft.Agents.AI.Purview.Serialization;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace Microsoft.Agents.AI.Purview.UnitTests;
+
+///
+/// Unit tests for the class.
+///
+public sealed class PurviewClientTests : IDisposable
+{
+ private readonly HttpClient _httpClient;
+ private readonly PurviewClientHttpMessageHandlerStub _handler;
+ private readonly PurviewClient _client;
+ private readonly PurviewSettings _settings;
+
+ public PurviewClientTests()
+ {
+ this._handler = new PurviewClientHttpMessageHandlerStub();
+ this._httpClient = new HttpClient(this._handler, false);
+ this._settings = new PurviewSettings("TestApp")
+ {
+ GraphBaseUri = new Uri("https://graph.microsoft.com/v1.0/")
+ };
+ var tokenCredential = new MockTokenCredential();
+ this._client = new PurviewClient(tokenCredential, this._settings, this._httpClient, NullLogger.Instance);
+ }
+
+ #region ProcessContentAsync Tests
+
+ [Fact]
+ public async Task ProcessContentAsync_WithValidRequest_ReturnsSuccessResponseAsync()
+ {
+ // Arrange
+ var request = CreateValidProcessContentRequest();
+ var expectedResponse = new ProcessContentResponse
+ {
+ Id = "test-id-123",
+ ProtectionScopeState = ProtectionScopeState.NotModified,
+ PolicyActions = new List
+ {
+ new() { Action = DlpAction.NotifyUser }
+ }
+ };
+
+ this._handler.StatusCodeToReturn = HttpStatusCode.OK;
+ this._handler.ResponseToReturn = JsonSerializer.Serialize(expectedResponse, PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ProcessContentResponse)));
+
+ // Act
+ var result = await this._client.ProcessContentAsync(request, CancellationToken.None);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Equal(expectedResponse.Id, result.Id);
+ Assert.Equal(ProtectionScopeState.NotModified, result.ProtectionScopeState);
+ Assert.Single(result.PolicyActions!);
+ Assert.Equal(DlpAction.NotifyUser, result.PolicyActions![0].Action);
+
+ // Verify request
+ Assert.Equal("https://graph.microsoft.com/v1.0/users/test-user-id/dataSecurityAndGovernance/processContent", this._handler.RequestUri?.ToString());
+ Assert.Equal(HttpMethod.Post, this._handler.RequestMethod);
+ Assert.Contains("Bearer ", this._handler.AuthorizationHeader);
+ }
+
+ [Fact]
+ public async Task ProcessContentAsync_WithAcceptedStatus_ReturnsSuccessResponseAsync()
+ {
+ // Arrange
+ var request = CreateValidProcessContentRequest();
+ var expectedResponse = new ProcessContentResponse
+ {
+ Id = "test-id-456",
+ ProtectionScopeState = ProtectionScopeState.Modified
+ };
+
+ this._handler.StatusCodeToReturn = HttpStatusCode.Accepted;
+ this._handler.ResponseToReturn = JsonSerializer.Serialize(expectedResponse, PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ProcessContentResponse)));
+
+ // Act
+ var result = await this._client.ProcessContentAsync(request, CancellationToken.None);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Equal(expectedResponse.Id, result.Id);
+ Assert.Equal(ProtectionScopeState.Modified, result.ProtectionScopeState);
+ }
+
+ [Fact]
+ public async Task ProcessContentAsync_WithScopeIdentifier_IncludesIfNoneMatchHeaderAsync()
+ {
+ // Arrange
+ var request = CreateValidProcessContentRequest();
+ request.ScopeIdentifier = "\"test-scope-123\""; // ETags must be quoted
+ var expectedResponse = new ProcessContentResponse { Id = "test-id" };
+
+ this._handler.StatusCodeToReturn = HttpStatusCode.OK;
+ this._handler.ResponseToReturn = JsonSerializer.Serialize(expectedResponse, PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ProcessContentResponse)));
+
+ // Act
+ await this._client.ProcessContentAsync(request, CancellationToken.None);
+
+ // Assert
+ Assert.Equal("\"test-scope-123\"", this._handler.IfNoneMatchHeader);
+ }
+
+ [Fact]
+ public async Task ProcessContentAsync_WithRateLimitError_ThrowsPurviewRateLimitExceptionAsync()
+ {
+ // Arrange
+ var request = CreateValidProcessContentRequest();
+ this._handler.StatusCodeToReturn = (HttpStatusCode)429;
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() =>
+ this._client.ProcessContentAsync(request, CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task ProcessContentAsync_WithUnauthorizedError_ThrowsPurviewAuthenticationExceptionAsync()
+ {
+ // Arrange
+ var request = CreateValidProcessContentRequest();
+ this._handler.StatusCodeToReturn = HttpStatusCode.Unauthorized;
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() =>
+ this._client.ProcessContentAsync(request, CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task ProcessContentAsync_WithForbiddenError_ThrowsPurviewAuthenticationExceptionAsync()
+ {
+ // Arrange
+ var request = CreateValidProcessContentRequest();
+ this._handler.StatusCodeToReturn = HttpStatusCode.Forbidden;
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() =>
+ this._client.ProcessContentAsync(request, CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task ProcessContentAsync_WithPaymentRequiredError_ThrowsPurviewPaymentRequiredExceptionAsync()
+ {
+ // Arrange
+ var request = CreateValidProcessContentRequest();
+ this._handler.StatusCodeToReturn = HttpStatusCode.PaymentRequired;
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() =>
+ this._client.ProcessContentAsync(request, CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task ProcessContentAsync_WithBadRequestError_ThrowsPurviewRequestExceptionAsync()
+ {
+ // Arrange
+ var request = CreateValidProcessContentRequest();
+ this._handler.StatusCodeToReturn = HttpStatusCode.BadRequest;
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() =>
+ this._client.ProcessContentAsync(request, CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task ProcessContentAsync_WithInvalidJsonResponse_ThrowsPurviewExceptionAsync()
+ {
+ // Arrange
+ var request = CreateValidProcessContentRequest();
+ this._handler.StatusCodeToReturn = HttpStatusCode.OK;
+ this._handler.ResponseToReturn = "invalid json";
+
+ // Act & Assert
+ var exception = await Assert.ThrowsAsync(() =>
+ this._client.ProcessContentAsync(request, CancellationToken.None));
+
+ Assert.Contains("Failed to deserialize ProcessContent response", exception.Message);
+ Assert.NotNull(exception.InnerException);
+ Assert.IsType(exception.InnerException);
+ }
+
+ [Fact]
+ public async Task ProcessContentAsync_WithHttpRequestException_ThrowsPurviewRequestExceptionAsync()
+ {
+ // Arrange
+ var request = CreateValidProcessContentRequest();
+ this._handler.ShouldThrowHttpRequestException = true;
+
+ // Act & Assert
+ var exception = await Assert.ThrowsAsync(() =>
+ this._client.ProcessContentAsync(request, CancellationToken.None));
+
+ Assert.Equal("Http error occurred while processing content.", exception.Message);
+ Assert.NotNull(exception.InnerException);
+ Assert.IsType(exception.InnerException);
+ }
+
+ #endregion
+
+ #region GetProtectionScopesAsync Tests
+
+ [Fact]
+ public async Task GetProtectionScopesAsync_WithValidRequest_ReturnsSuccessResponseAsync()
+ {
+ // Arrange
+ var request = new ProtectionScopesRequest("test-user-id", "test-tenant-id")
+ {
+ Activities = ProtectionScopeActivities.UploadText,
+ Locations = new List
+ {
+ new("microsoft.graph.policyLocationApplication", "app-123")
+ }
+ };
+
+ var expectedResponse = new ProtectionScopesResponse
+ {
+ Scopes = new List
+ {
+ new()
+ {
+ Activities = ProtectionScopeActivities.UploadText,
+ Locations = new List
+ {
+ new ("microsoft.graph.policyLocationApplication", "app-123")
+ }
+ }
+ }
+ };
+
+ this._handler.StatusCodeToReturn = HttpStatusCode.OK;
+ this._handler.ResponseToReturn = JsonSerializer.Serialize(expectedResponse, PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ProtectionScopesResponse)));
+ this._handler.ETagToReturn = "\"scope-etag-123\"";
+
+ // Act
+ var result = await this._client.GetProtectionScopesAsync(request, CancellationToken.None);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.NotNull(result.Scopes);
+ Assert.Single(result.Scopes);
+ Assert.Equal("\"scope-etag-123\"", result.ScopeIdentifier); // ETags are stored with quotes
+
+ // Verify request
+ Assert.Equal("https://graph.microsoft.com/v1.0/users/test-user-id/dataSecurityAndGovernance/protectionScopes/compute", this._handler.RequestUri?.ToString());
+ Assert.Equal(HttpMethod.Post, this._handler.RequestMethod);
+ }
+
+ [Fact]
+ public async Task GetProtectionScopesAsync_SetsETagFromResponse_Async()
+ {
+ // Arrange
+ var request = new ProtectionScopesRequest("test-user-id", "test-tenant-id");
+ var expectedResponse = new ProtectionScopesResponse { Scopes = new List() };
+
+ this._handler.StatusCodeToReturn = HttpStatusCode.OK;
+ this._handler.ResponseToReturn = JsonSerializer.Serialize(expectedResponse, PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ProtectionScopesResponse)));
+ this._handler.ETagToReturn = "\"custom-etag-456\"";
+
+ // Act
+ var result = await this._client.GetProtectionScopesAsync(request, CancellationToken.None);
+
+ // Assert
+ Assert.Equal("\"custom-etag-456\"", result.ScopeIdentifier);
+ }
+
+ [Fact]
+ public async Task GetProtectionScopesAsync_WithRateLimitError_ThrowsPurviewRateLimitExceptionAsync()
+ {
+ // Arrange
+ var request = new ProtectionScopesRequest("test-user-id", "test-tenant-id");
+ this._handler.StatusCodeToReturn = (HttpStatusCode)429;
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() =>
+ this._client.GetProtectionScopesAsync(request, CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task GetProtectionScopesAsync_WithUnauthorizedError_ThrowsPurviewAuthenticationExceptionAsync()
+ {
+ // Arrange
+ var request = new ProtectionScopesRequest("test-user-id", "test-tenant-id");
+ this._handler.StatusCodeToReturn = HttpStatusCode.Unauthorized;
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() =>
+ this._client.GetProtectionScopesAsync(request, CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task GetProtectionScopesAsync_WithInvalidJsonResponse_ThrowsPurviewExceptionAsync()
+ {
+ // Arrange
+ var request = new ProtectionScopesRequest("test-user-id", "test-tenant-id");
+ this._handler.StatusCodeToReturn = HttpStatusCode.OK;
+ this._handler.ResponseToReturn = "invalid json";
+
+ // Act & Assert
+ var exception = await Assert.ThrowsAsync(() =>
+ this._client.GetProtectionScopesAsync(request, CancellationToken.None));
+
+ Assert.Contains("Failed to deserialize ProtectionScopes response", exception.Message);
+ Assert.NotNull(exception.InnerException);
+ Assert.IsType(exception.InnerException);
+ }
+
+ [Fact]
+ public async Task GetProtectionScopesAsync_WithHttpRequestException_ThrowsPurviewRequestExceptionAsync()
+ {
+ // Arrange
+ var request = new ProtectionScopesRequest("test-user-id", "test-tenant-id");
+ this._handler.ShouldThrowHttpRequestException = true;
+
+ // Act & Assert
+ var exception = await Assert.ThrowsAsync(() =>
+ this._client.GetProtectionScopesAsync(request, CancellationToken.None));
+
+ Assert.Equal("Http error occurred while retrieving protection scopes.", exception.Message);
+ Assert.NotNull(exception.InnerException);
+ Assert.IsType(exception.InnerException);
+ }
+
+ #endregion
+
+ #region SendContentActivitiesAsync Tests
+
+ [Fact]
+ public async Task SendContentActivitiesAsync_WithValidRequest_ReturnsSuccessResponseAsync()
+ {
+ // Arrange
+ var contentToProcess = CreateValidContentToProcess();
+ var request = new ContentActivitiesRequest("test-user-id", "test-tenant-id", contentToProcess);
+ var expectedResponse = new ContentActivitiesResponse
+ {
+ StatusCode = HttpStatusCode.Created
+ };
+
+ this._handler.StatusCodeToReturn = HttpStatusCode.Created;
+ this._handler.ResponseToReturn = JsonSerializer.Serialize(expectedResponse, PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ContentActivitiesResponse)));
+
+ // Act
+ var result = await this._client.SendContentActivitiesAsync(request, CancellationToken.None);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Null(result.Error);
+
+ // Verify request - note the endpoint is different from ProcessContent
+ Assert.Equal("https://graph.microsoft.com/v1.0/test-user-id/dataSecurityAndGovernance/activities/contentActivities", this._handler.RequestUri?.ToString());
+ Assert.Equal(HttpMethod.Post, this._handler.RequestMethod);
+ }
+
+ [Fact]
+ public async Task SendContentActivitiesAsync_WithError_ReturnsResponseWithErrorAsync()
+ {
+ // Arrange
+ var contentToProcess = CreateValidContentToProcess();
+ var request = new ContentActivitiesRequest("test-user-id", "test-tenant-id", contentToProcess);
+ var expectedResponse = new ContentActivitiesResponse
+ {
+ Error = new ErrorDetails
+ {
+ Code = "InvalidRequest",
+ Message = "The request is invalid"
+ }
+ };
+
+ this._handler.StatusCodeToReturn = HttpStatusCode.Created;
+ this._handler.ResponseToReturn = JsonSerializer.Serialize(expectedResponse, PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ContentActivitiesResponse)));
+
+ // Act
+ var result = await this._client.SendContentActivitiesAsync(request, CancellationToken.None);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.NotNull(result.Error);
+ Assert.Equal("InvalidRequest", result.Error.Code);
+ Assert.Equal("The request is invalid", result.Error.Message);
+ }
+
+ [Fact]
+ public async Task SendContentActivitiesAsync_WithRateLimitError_ThrowsPurviewRateLimitExceptionAsync()
+ {
+ // Arrange
+ var contentToProcess = CreateValidContentToProcess();
+ var request = new ContentActivitiesRequest("test-user-id", "test-tenant-id", contentToProcess);
+ this._handler.StatusCodeToReturn = (HttpStatusCode)429;
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() =>
+ this._client.SendContentActivitiesAsync(request, CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task SendContentActivitiesAsync_WithUnauthorizedError_ThrowsPurviewAuthenticationExceptionAsync()
+ {
+ // Arrange
+ var contentToProcess = CreateValidContentToProcess();
+ var request = new ContentActivitiesRequest("test-user-id", "test-tenant-id", contentToProcess);
+ this._handler.StatusCodeToReturn = HttpStatusCode.Unauthorized;
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() =>
+ this._client.SendContentActivitiesAsync(request, CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task SendContentActivitiesAsync_WithBadRequestError_ThrowsPurviewRequestExceptionAsync()
+ {
+ // Arrange
+ var contentToProcess = CreateValidContentToProcess();
+ var request = new ContentActivitiesRequest("test-user-id", "test-tenant-id", contentToProcess);
+ this._handler.StatusCodeToReturn = HttpStatusCode.BadRequest;
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() =>
+ this._client.SendContentActivitiesAsync(request, CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task SendContentActivitiesAsync_WithInvalidJsonResponse_ThrowsPurviewExceptionAsync()
+ {
+ // Arrange
+ var contentToProcess = CreateValidContentToProcess();
+ var request = new ContentActivitiesRequest("test-user-id", "test-tenant-id", contentToProcess);
+ this._handler.StatusCodeToReturn = HttpStatusCode.Created;
+ this._handler.ResponseToReturn = "invalid json";
+
+ // Act & Assert
+ var exception = await Assert.ThrowsAsync(() =>
+ this._client.SendContentActivitiesAsync(request, CancellationToken.None));
+
+ Assert.Contains("Failed to deserialize ContentActivities response", exception.Message);
+ Assert.NotNull(exception.InnerException);
+ Assert.IsType(exception.InnerException);
+ }
+
+ [Fact]
+ public async Task SendContentActivitiesAsync_WithHttpRequestException_ThrowsPurviewRequestExceptionAsync()
+ {
+ // Arrange
+ var contentToProcess = CreateValidContentToProcess();
+ var request = new ContentActivitiesRequest("test-user-id", "test-tenant-id", contentToProcess);
+ this._handler.ShouldThrowHttpRequestException = true;
+
+ // Act & Assert
+ var exception = await Assert.ThrowsAsync(() =>
+ this._client.SendContentActivitiesAsync(request, CancellationToken.None));
+
+ Assert.Equal("Http error occurred while creating content activities.", exception.Message);
+ Assert.NotNull(exception.InnerException);
+ Assert.IsType(exception.InnerException);
+ }
+
+ #endregion
+
+ #region Helper Methods
+
+ private static ProcessContentRequest CreateValidProcessContentRequest()
+ {
+ var contentToProcess = CreateValidContentToProcess();
+ return new ProcessContentRequest(contentToProcess, "test-user-id", "test-tenant-id");
+ }
+
+ private static ContentToProcess CreateValidContentToProcess()
+ {
+ var content = new PurviewTextContent("Test content");
+ var metadata = new ProcessConversationMetadata(content, "msg-123", false, "Test message");
+ var activityMetadata = new ActivityMetadata(Activity.UploadText);
+ var deviceMetadata = new DeviceMetadata
+ {
+ OperatingSystemSpecifications = new OperatingSystemSpecifications
+ {
+ OperatingSystemPlatform = "Windows",
+ OperatingSystemVersion = "10"
+ }
+ };
+ var integratedAppMetadata = new IntegratedAppMetadata
+ {
+ Name = "TestApp",
+ Version = "1.0"
+ };
+ var policyLocation = new PolicyLocation("microsoft.graph.policyLocationApplication", "app-123");
+ var protectedAppMetadata = new ProtectedAppMetadata(policyLocation)
+ {
+ Name = "TestApp",
+ Version = "1.0"
+ };
+
+ return new ContentToProcess(
+ new List { metadata },
+ activityMetadata,
+ deviceMetadata,
+ integratedAppMetadata,
+ protectedAppMetadata
+ );
+ }
+
+ #endregion
+
+ public void Dispose()
+ {
+ this._handler.Dispose();
+ this._httpClient.Dispose();
+ }
+
+ ///
+ /// Mock HTTP message handler for testing
+ ///
+ internal sealed class PurviewClientHttpMessageHandlerStub : HttpMessageHandler
+ {
+ public HttpStatusCode StatusCodeToReturn { get; set; } = HttpStatusCode.OK;
+ public string? ResponseToReturn { get; set; }
+ public string? ETagToReturn { get; set; }
+ public bool ShouldThrowHttpRequestException { get; set; }
+ public Uri? RequestUri { get; private set; }
+ public HttpMethod? RequestMethod { get; private set; }
+ public string? AuthorizationHeader { get; private set; }
+ public string? IfNoneMatchHeader { get; private set; }
+
+ protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ // Capture request details
+ this.RequestUri = request.RequestUri;
+ this.RequestMethod = request.Method;
+
+ if (request.Headers.Authorization != null)
+ {
+ this.AuthorizationHeader = request.Headers.Authorization.ToString();
+ }
+
+ if (request.Headers.TryGetValues("If-None-Match", out var ifNoneMatchValues))
+ {
+ this.IfNoneMatchHeader = string.Join(", ", ifNoneMatchValues);
+ }
+
+ // Throw HttpRequestException if configured
+ if (this.ShouldThrowHttpRequestException)
+ {
+ throw new HttpRequestException("Simulated network error");
+ }
+
+ var response = new HttpResponseMessage(this.StatusCodeToReturn);
+
+ response.Content = new StringContent(this.ResponseToReturn ?? string.Empty, Encoding.UTF8, "application/json");
+
+ if (!string.IsNullOrEmpty(this.ETagToReturn))
+ {
+ response.Headers.ETag = new System.Net.Http.Headers.EntityTagHeaderValue(this.ETagToReturn);
+ }
+
+ return await Task.FromResult(response);
+ }
+ }
+
+ ///
+ /// Mock token credential for testing
+ ///
+ internal sealed class MockTokenCredential : TokenCredential
+ {
+ public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
+ {
+ return new AccessToken("mock-token", DateTimeOffset.UtcNow.AddHours(1));
+ }
+
+ public override ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
+ {
+ return new ValueTask(new AccessToken("mock-token", DateTimeOffset.UtcNow.AddHours(1)));
+ }
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/PurviewWrapperTests.cs b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/PurviewWrapperTests.cs
new file mode 100644
index 0000000000..22b729dda4
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/PurviewWrapperTests.cs
@@ -0,0 +1,571 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Agents.AI.Purview.Models.Common;
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+
+namespace Microsoft.Agents.AI.Purview.UnitTests;
+
+///
+/// Unit tests for the class.
+///
+public sealed class PurviewWrapperTests : IDisposable
+{
+ private readonly Mock _mockProcessor;
+ private readonly IChannelHandler _channelHandler;
+ private readonly PurviewSettings _settings;
+ private readonly PurviewWrapper _wrapper;
+
+ public PurviewWrapperTests()
+ {
+ this._mockProcessor = new Mock();
+ this._channelHandler = Mock.Of();
+ this._settings = new PurviewSettings("TestApp")
+ {
+ TenantId = "tenant-123",
+ PurviewAppLocation = new PurviewAppLocation(PurviewLocationType.Application, "app-123"),
+ BlockedPromptMessage = "Prompt blocked by policy",
+ BlockedResponseMessage = "Response blocked by policy"
+ };
+ this._wrapper = new PurviewWrapper(this._mockProcessor.Object, this._settings, NullLogger.Instance, this._channelHandler);
+ }
+
+ #region ProcessChatContentAsync Tests
+
+ [Fact]
+ public async Task ProcessChatContentAsync_WithBlockedPrompt_ReturnsBlockedMessageAsync()
+ {
+ // Arrange
+ var messages = new List
+ {
+ new(ChatRole.User, "Sensitive content that should be blocked")
+ };
+ var mockChatClient = new Mock();
+
+ this._mockProcessor.Setup(x => x.ProcessMessagesAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync((true, "user-123"));
+
+ // Act
+ var result = await this._wrapper.ProcessChatContentAsync(messages, null, mockChatClient.Object, CancellationToken.None);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Single(result.Messages);
+ Assert.Equal(ChatRole.System, result.Messages[0].Role);
+ Assert.Equal("Prompt blocked by policy", result.Messages[0].Text);
+ mockChatClient.Verify(x => x.GetResponseAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny()), Times.Never);
+ }
+
+ [Fact]
+ public async Task ProcessChatContentAsync_WithAllowedPromptAndBlockedResponse_ReturnsBlockedMessageAsync()
+ {
+ // Arrange
+ var messages = new List
+ {
+ new(ChatRole.User, "Test message")
+ };
+ var mockChatClient = new Mock();
+ var innerResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Sensitive response"));
+
+ mockChatClient.Setup(x => x.GetResponseAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(innerResponse);
+
+ this._mockProcessor.SetupSequence(x => x.ProcessMessagesAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync((false, "user-123")) // Prompt allowed
+ .ReturnsAsync((true, "user-123")); // Response blocked
+
+ // Act
+ var result = await this._wrapper.ProcessChatContentAsync(messages, null, mockChatClient.Object, CancellationToken.None);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Single(result.Messages);
+ Assert.Equal(ChatRole.System, result.Messages[0].Role);
+ Assert.Equal("Response blocked by policy", result.Messages[0].Text);
+ }
+
+ [Fact]
+ public async Task ProcessChatContentAsync_WithAllowedPromptAndResponse_ReturnsInnerResponseAsync()
+ {
+ // Arrange
+ var messages = new List
+ {
+ new(ChatRole.User, "Test message")
+ };
+ var mockChatClient = new Mock();
+ var innerResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Safe response"));
+
+ mockChatClient.Setup(x => x.GetResponseAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(innerResponse);
+
+ this._mockProcessor.Setup(x => x.ProcessMessagesAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync((false, "user-123"));
+
+ // Act
+ var result = await this._wrapper.ProcessChatContentAsync(messages, null, mockChatClient.Object, CancellationToken.None);
+
+ // Assert
+ Assert.Same(innerResponse, result);
+ }
+
+ [Fact]
+ public async Task ProcessChatContentAsync_WithIgnoreExceptions_ContinuesOnPromptErrorAsync()
+ {
+ // Arrange
+ var settingsWithIgnore = new PurviewSettings("TestApp")
+ {
+ TenantId = "tenant-123",
+ IgnoreExceptions = true,
+ PurviewAppLocation = new PurviewAppLocation(PurviewLocationType.Application, "app-123")
+ };
+ var wrapper = new PurviewWrapper(this._mockProcessor.Object, settingsWithIgnore, NullLogger.Instance, this._channelHandler);
+
+ var messages = new List
+ {
+ new(ChatRole.User, "Test message")
+ };
+
+ var expectedResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Response from inner client"));
+ var mockChatClient = new Mock();
+ mockChatClient.Setup(x => x.GetResponseAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(expectedResponse);
+
+ this._mockProcessor.SetupSequence(x => x.ProcessMessagesAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()))
+ .ThrowsAsync(new PurviewRequestException("Prompt processing error")); // Response processing succeeds
+
+ // Act
+ var result = await wrapper.ProcessChatContentAsync(messages, null, mockChatClient.Object, CancellationToken.None);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Same(expectedResponse, result);
+ }
+
+ [Fact]
+ public async Task ProcessChatContentAsync_WithoutIgnoreExceptions_ThrowsOnPromptErrorAsync()
+ {
+ // Arrange
+ var messages = new List
+ {
+ new(ChatRole.User, "Test message")
+ };
+ var mockChatClient = new Mock();
+
+ this._mockProcessor.Setup(x => x.ProcessMessagesAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()))
+ .ThrowsAsync(new PurviewRequestException("Prompt processing error"));
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() =>
+ this._wrapper.ProcessChatContentAsync(messages, null, mockChatClient.Object, CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task ProcessChatContentAsync_UsesConversationIdFromOptions_Async()
+ {
+ // Arrange
+ var messages = new List
+ {
+ new(ChatRole.User, "Test message")
+ };
+ var options = new ChatOptions { ConversationId = "conversation-123" };
+ var mockChatClient = new Mock();
+ var innerResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Response"));
+
+ mockChatClient.Setup(x => x.GetResponseAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(innerResponse);
+
+ this._mockProcessor.Setup(x => x.ProcessMessagesAsync(
+ It.IsAny>(),
+ "conversation-123",
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync((false, "user-123"));
+
+ // Act
+ await this._wrapper.ProcessChatContentAsync(messages, options, mockChatClient.Object, CancellationToken.None);
+
+ // Assert
+ this._mockProcessor.Verify(x => x.ProcessMessagesAsync(
+ It.IsAny>(),
+ "conversation-123",
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()), Times.Exactly(2));
+ }
+
+ #endregion
+
+ #region ProcessAgentContentAsync Tests
+
+ [Fact]
+ public async Task ProcessAgentContentAsync_WithBlockedPrompt_ReturnsBlockedMessageAsync()
+ {
+ // Arrange
+ var messages = new List
+ {
+ new(ChatRole.User, "Sensitive content")
+ };
+ var mockAgent = new Mock();
+
+ this._mockProcessor.Setup(x => x.ProcessMessagesAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync((true, "user-123"));
+
+ // Act
+ var result = await this._wrapper.ProcessAgentContentAsync(messages, null, null, mockAgent.Object, CancellationToken.None);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Single(result.Messages);
+ Assert.Equal(ChatRole.System, result.Messages[0].Role);
+ Assert.Equal("Prompt blocked by policy", result.Messages[0].Text);
+ mockAgent.Verify(x => x.RunAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()), Times.Never);
+ }
+
+ [Fact]
+ public async Task ProcessAgentContentAsync_WithAllowedPromptAndBlockedResponse_ReturnsBlockedMessageAsync()
+ {
+ // Arrange
+ var messages = new List
+ {
+ new(ChatRole.User, "Test message")
+ };
+ var mockAgent = new Mock();
+ var innerResponse = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Sensitive response"));
+
+ mockAgent.Setup(x => x.RunAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(innerResponse);
+
+ this._mockProcessor.SetupSequence(x => x.ProcessMessagesAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync((false, "user-123")) // Prompt allowed
+ .ReturnsAsync((true, "user-123")); // Response blocked
+
+ // Act
+ var result = await this._wrapper.ProcessAgentContentAsync(messages, null, null, mockAgent.Object, CancellationToken.None);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Single(result.Messages);
+ Assert.Equal(ChatRole.System, result.Messages[0].Role);
+ Assert.Equal("Response blocked by policy", result.Messages[0].Text);
+ }
+
+ [Fact]
+ public async Task ProcessAgentContentAsync_WithAllowedPromptAndResponse_ReturnsInnerResponseAsync()
+ {
+ // Arrange
+ var messages = new List
+ {
+ new(ChatRole.User, "Test message")
+ };
+ var mockAgent = new Mock();
+ var innerResponse = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Safe response"));
+
+ mockAgent.Setup(x => x.RunAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(innerResponse);
+
+ this._mockProcessor.Setup(x => x.ProcessMessagesAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync((false, "user-123"));
+
+ // Act
+ var result = await this._wrapper.ProcessAgentContentAsync(messages, null, null, mockAgent.Object, CancellationToken.None);
+
+ // Assert
+ Assert.Same(innerResponse, result);
+ }
+
+ [Fact]
+ public async Task ProcessAgentContentAsync_WithIgnoreExceptions_ContinuesOnErrorAsync()
+ {
+ // Arrange
+ var settingsWithIgnore = new PurviewSettings("TestApp")
+ {
+ TenantId = "tenant-123",
+ IgnoreExceptions = true,
+ PurviewAppLocation = new PurviewAppLocation(PurviewLocationType.Application, "app-123")
+ };
+ var wrapper = new PurviewWrapper(this._mockProcessor.Object, settingsWithIgnore, NullLogger.Instance, this._channelHandler);
+
+ var messages = new List
+ {
+ new(ChatRole.User, "Test message")
+ };
+
+ var expectedResponse = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Response from inner agent"));
+ var mockAgent = new Mock();
+ mockAgent.Setup(x => x.RunAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny