Merge branch 'main' into feature-foundry-agents

This commit is contained in:
Roger Barreto
2025-11-14 12:21:57 +00:00
committed by GitHub
111 changed files with 6469 additions and 110 deletions
+5
View File
@@ -92,6 +92,9 @@
<Project Path="samples/GettingStarted/AgentWithOpenAI/Agent_OpenAI_Step01_Running/Agent_OpenAI_Step01_Running.csproj" />
<Project Path="samples/GettingStarted/AgentWithOpenAI/Agent_OpenAI_Step02_Reasoning/Agent_OpenAI_Step02_Reasoning.csproj" />
</Folder>
<Folder Name="/Samples/Purview/AgentWithPurview/">
<Project Path="samples/Purview/AgentWithPurview/AgentWithPurview.csproj" />
</Folder>
<Folder Name="/Samples/GettingStarted/AgentWithRAG/">
<File Path="samples/GettingStarted/AgentWithRAG/README.md" />
<Project Path="samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/AgentWithRAG_Step01_BasicTextRAG.csproj" />
@@ -340,6 +343,7 @@
<Project Path="src/Microsoft.Agents.AI.Mem0/Microsoft.Agents.AI.Mem0.csproj" />
<Project Path="src/Microsoft.Agents.AI.OpenAI/Microsoft.Agents.AI.OpenAI.csproj" />
<Project Path="src/Microsoft.Agents.AI.Workflows.Declarative.AzureAI/Microsoft.Agents.AI.Workflows.Declarative.AzureAI.csproj" />
<Project Path="src/Microsoft.Agents.AI.Purview/Microsoft.Agents.AI.Purview.csproj" />
<Project Path="src/Microsoft.Agents.AI.Workflows.Declarative/Microsoft.Agents.AI.Workflows.Declarative.csproj" />
<Project Path="src/Microsoft.Agents.AI.Workflows/Microsoft.Agents.AI.Workflows.csproj" />
<Project Path="src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj" />
@@ -372,6 +376,7 @@
<Project Path="tests/Microsoft.Agents.AI.Hosting.UnitTests/Microsoft.Agents.AI.Hosting.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Mem0.UnitTests/Microsoft.Agents.AI.Mem0.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.OpenAI.UnitTests/Microsoft.Agents.AI.OpenAI.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Purview.UnitTests/Microsoft.Agents.AI.Purview.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.UnitTests/Microsoft.Agents.AI.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Workflows.UnitTests/Microsoft.Agents.AI.Workflows.UnitTests.csproj" />
@@ -24,7 +24,7 @@ AITool mcpTool = new HostedMcpServerTool(serverName: "microsoft_learn", serverAd
// Create an agent with the MCP tool using Azure OpenAI Responses.
AIAgent agent = new AzureOpenAIClient(
new Uri(endpoint),
new AzureCliCredential())
new DefaultAzureCredential())
.GetOpenAIResponseClient(deploymentName)
.CreateAIAgent(
instructions: "You answer questions by searching the Microsoft Learn content only.",
@@ -9,9 +9,11 @@ metadata:
authors:
- Microsoft Agent Framework Team
tags:
- Azure AI AgentServer
- Microsoft Agent Framework
- Model Context Protocol
- MCP
- Tool Call Approval
template:
kind: hosted
name: AgentWithHostedMCP
@@ -10,6 +10,7 @@ metadata:
authors:
- Microsoft Agent Framework Team
tags:
- Azure AI AgentServer
- Microsoft Agent Framework
- Retrieval-Augmented Generation
- RAG
@@ -8,6 +8,7 @@ metadata:
authors:
- Microsoft Agent Framework Team
tags:
- Azure AI AgentServer
- Microsoft Agent Framework
- Workflows
template:
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Purview\Microsoft.Agents.AI.Purview.csproj" />
</ItemGroup>
</Project>
@@ -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));
}
@@ -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;
/// <summary>
/// Service that runs jobs in background threads.
/// </summary>
internal sealed class BackgroundJobRunner
{
private readonly IChannelHandler _channelHandler;
private readonly IPurviewClient _purviewClient;
private readonly ILogger _logger;
/// <summary>
/// Initializes a new instance of the <see cref="BackgroundJobRunner"/> class.
/// </summary>
/// <param name="channelHandler">The channel handler used to manage job channels.</param>
/// <param name="purviewClient">The Purview client used to send requests to Purview.</param>
/// <param name="logger">The logger used to log information about background jobs.</param>
/// <param name="purviewSettings">The settings used to configure Purview client behavior.</param>
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<BackgroundJobBase> 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);
}
}
});
}
}
/// <summary>
/// Runs a job.
/// </summary>
/// <param name="job">The job to run.</param>
/// <returns>A task representing the job.</returns>
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;
}
}
}
@@ -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;
/// <summary>
/// Manages caching of values.
/// </summary>
internal sealed class CacheProvider : ICacheProvider
{
private readonly IDistributedCache _cache;
private readonly PurviewSettings _purviewSettings;
/// <summary>
/// Create a new instance of the <see cref="CacheProvider"/> class.
/// </summary>
/// <param name="cache">The cache where the data is stored.</param>
/// <param name="purviewSettings">The purview integration settings.</param>
public CacheProvider(IDistributedCache cache, PurviewSettings purviewSettings)
{
this._cache = cache;
this._purviewSettings = purviewSettings;
}
/// <summary>
/// Get a value from the cache.
/// </summary>
/// <typeparam name="TKey">The type of the key in the cache. Used for serialization.</typeparam>
/// <typeparam name="TValue">The type of the value in the cache. Used for serialization.</typeparam>
/// <param name="key">The key to look up in the cache.</param>
/// <param name="cancellationToken">A cancellation token for the async operation.</param>
/// <returns>The value in the cache. Null or default if no value is present.</returns>
public async Task<TValue?> GetAsync<TKey, TValue>(TKey key, CancellationToken cancellationToken)
{
JsonTypeInfo<TKey> keyTypeInfo = (JsonTypeInfo<TKey>)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<TValue> valueTypeInfo = (JsonTypeInfo<TValue>)PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(TValue));
return JsonSerializer.Deserialize(data, valueTypeInfo);
}
/// <summary>
/// Set a value in the cache.
/// </summary>
/// <typeparam name="TKey">The type of the key in the cache. Used for serialization.</typeparam>
/// <typeparam name="TValue">The type of the value in the cache. Used for serialization.</typeparam>
/// <param name="key">The key to identify the cache entry.</param>
/// <param name="value">The value to cache.</param>
/// <param name="cancellationToken">A cancellation token for the async operation.</param>
/// <returns>A task for the async operation.</returns>
public Task SetAsync<TKey, TValue>(TKey key, TValue value, CancellationToken cancellationToken)
{
JsonTypeInfo<TKey> keyTypeInfo = (JsonTypeInfo<TKey>)PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(TKey));
string serializedKey = JsonSerializer.Serialize(key, keyTypeInfo);
JsonTypeInfo<TValue> valueTypeInfo = (JsonTypeInfo<TValue>)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);
}
/// <summary>
/// Removes a value from the cache.
/// </summary>
/// <typeparam name="TKey">The type of the key.</typeparam>
/// <param name="key">The key to identify the cache entry.</param>
/// <param name="cancellationToken">The cancellation token for the async operation.</param>
/// <returns>A task for the async operation.</returns>
public Task RemoveAsync<TKey>(TKey key, CancellationToken cancellationToken)
{
JsonTypeInfo<TKey> keyTypeInfo = (JsonTypeInfo<TKey>)PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(TKey));
string serializedKey = JsonSerializer.Serialize(key, keyTypeInfo);
return this._cache.RemoveAsync(serializedKey, cancellationToken);
}
}
@@ -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;
/// <summary>
/// Handler class for background job management.
/// </summary>
internal class ChannelHandler : IChannelHandler
{
private readonly Channel<BackgroundJobBase> _jobChannel;
private readonly List<Task> _channelListeners;
private readonly ILogger _logger;
private readonly PurviewSettings _purviewSettings;
/// <summary>
/// Creates a new instance of JobHandler.
/// </summary>
/// <param name="purviewSettings">The purview integration settings.</param>
/// <param name="logger">The logger used for logging job information.</param>
/// <param name="jobChannel">The job channel used for queuing and reading background jobs.</param>
public ChannelHandler(PurviewSettings purviewSettings, ILogger logger, Channel<BackgroundJobBase> jobChannel)
{
this._purviewSettings = purviewSettings;
this._logger = logger;
this._jobChannel = jobChannel;
this._channelListeners = new List<Task>(this._purviewSettings.MaxConcurrentJobConsumers);
}
/// <inheritdoc/>
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;
}
}
}
/// <inheritdoc/>
public void AddRunner(Func<Channel<BackgroundJobBase>, Task> runnerTask)
{
this._channelListeners.Add(Task.Run(async () => await runnerTask(this._jobChannel).ConfigureAwait(false)));
}
/// <inheritdoc/>
public async Task StopAndWaitForCompletionAsync()
{
this._jobChannel.Writer.Complete();
await this._jobChannel.Reader.Completion.ConfigureAwait(false);
await Task.WhenAll(this._channelListeners).ConfigureAwait(false);
}
}
@@ -0,0 +1,29 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.Purview;
/// <summary>
/// Shared constants for the Purview service.
/// </summary>
internal static class Constants
{
/// <summary>
/// The odata type property name used in requests and responses.
/// </summary>
public const string ODataTypePropertyName = "@odata.type";
/// <summary>
/// The OData Graph namespace used for odata types.
/// </summary>
public const string ODataGraphNamespace = "microsoft.graph";
/// <summary>
/// The name of the property that contains the conversation id.
/// </summary>
public const string ConversationId = "conversationId";
/// <summary>
/// The name of the property that contains the user id.
/// </summary>
public const string UserId = "userId";
}
@@ -0,0 +1,27 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
namespace Microsoft.Agents.AI.Purview;
/// <summary>
/// Exception for authentication errors related to Purview.
/// </summary>
public class PurviewAuthenticationException : PurviewException
{
/// <inheritdoc />
public PurviewAuthenticationException(string message)
: base(message)
{
}
/// <inheritdoc />
public PurviewAuthenticationException() : base()
{
}
/// <inheritdoc />
public PurviewAuthenticationException(string? message, Exception? innerException) : base(message, innerException)
{
}
}
@@ -0,0 +1,27 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
namespace Microsoft.Agents.AI.Purview;
/// <summary>
/// General base exception type for Purview service errors.
/// </summary>
public class PurviewException : Exception
{
/// <inheritdoc />
public PurviewException(string message)
: base(message)
{
}
/// <inheritdoc />
public PurviewException() : base()
{
}
/// <inheritdoc />
public PurviewException(string? message, Exception? innerException) : base(message, innerException)
{
}
}
@@ -0,0 +1,27 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
namespace Microsoft.Agents.AI.Purview;
/// <summary>
/// Represents errors that occur during the execution of a Purview job.
/// </summary>
/// <remarks>This exception is thrown when a Purview job encounters an error that prevents it from completing successfully.</remarks>
internal class PurviewJobException : PurviewException
{
/// <inheritdoc/>
public PurviewJobException(string message) : base(message)
{
}
/// <inheritdoc/>
public PurviewJobException() : base()
{
}
/// <inheritdoc/>
public PurviewJobException(string? message, Exception? innerException) : base(message, innerException)
{
}
}
@@ -0,0 +1,27 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
namespace Microsoft.Agents.AI.Purview;
/// <summary>
/// Represents an exception that is thrown when the maximum number of concurrent Purview jobs has been exceeded.
/// </summary>
/// <remarks>This exception indicates that the Purview service has reached its limit for concurrent job executions.</remarks>
internal class PurviewJobLimitExceededException : PurviewJobException
{
/// <inheritdoc/>
public PurviewJobLimitExceededException(string message) : base(message)
{
}
/// <inheritdoc/>
public PurviewJobLimitExceededException() : base()
{
}
/// <inheritdoc/>
public PurviewJobLimitExceededException(string? message, Exception? innerException) : base(message, innerException)
{
}
}
@@ -0,0 +1,26 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
namespace Microsoft.Agents.AI.Purview;
/// <summary>
/// Exception for payment required errors related to Purview.
/// </summary>
public class PurviewPaymentRequiredException : PurviewException
{
/// <inheritdoc />
public PurviewPaymentRequiredException(string message) : base(message)
{
}
/// <inheritdoc />
public PurviewPaymentRequiredException() : base()
{
}
/// <inheritdoc />
public PurviewPaymentRequiredException(string? message, Exception? innerException) : base(message, innerException)
{
}
}
@@ -0,0 +1,27 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
namespace Microsoft.Agents.AI.Purview;
/// <summary>
/// Exception for rate limit exceeded errors from Purview service.
/// </summary>
public class PurviewRateLimitException : PurviewException
{
/// <inheritdoc />
public PurviewRateLimitException(string message)
: base(message)
{
}
/// <inheritdoc />
public PurviewRateLimitException() : base()
{
}
/// <inheritdoc />
public PurviewRateLimitException(string? message, Exception? innerException) : base(message, innerException)
{
}
}
@@ -0,0 +1,40 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Net;
namespace Microsoft.Agents.AI.Purview;
/// <summary>
/// Exception for general http request errors from Purview.
/// </summary>
public class PurviewRequestException : PurviewException
{
/// <summary>
/// HTTP status code returned by the Purview service.
/// </summary>
public HttpStatusCode StatusCode { get; }
/// <inheritdoc />
public PurviewRequestException(HttpStatusCode statusCode, string endpointName)
: base($"Failed to call {endpointName}. Status code: {statusCode}")
{
this.StatusCode = statusCode;
}
/// <inheritdoc />
public PurviewRequestException(string message)
: base(message)
{
}
/// <inheritdoc />
public PurviewRequestException() : base()
{
}
/// <inheritdoc />
public PurviewRequestException(string? message, Exception? innerException) : base(message, innerException)
{
}
}
@@ -0,0 +1,42 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.Purview;
/// <summary>
/// Manages caching of values.
/// </summary>
internal interface ICacheProvider
{
/// <summary>
/// Get a value from the cache.
/// </summary>
/// <typeparam name="TKey">The type of the key in the cache. Used for serialization.</typeparam>
/// <typeparam name="TValue">The type of the value in the cache. Used for serialization.</typeparam>
/// <param name="key">The key to look up in the cache.</param>
/// <param name="cancellationToken">A cancellation token for the async operation.</param>
/// <returns>The value in the cache. Null or default if no value is present.</returns>
Task<TValue?> GetAsync<TKey, TValue>(TKey key, CancellationToken cancellationToken);
/// <summary>
/// Set a value in the cache.
/// </summary>
/// <typeparam name="TKey">The type of the key in the cache. Used for serialization.</typeparam>
/// <typeparam name="TValue">The type of the value in the cache. Used for serialization.</typeparam>
/// <param name="key">The key to identify the cache entry.</param>
/// <param name="value">The value to cache.</param>
/// <param name="cancellationToken">A cancellation token for the async operation.</param>
/// <returns>A task for the async operation.</returns>
Task SetAsync<TKey, TValue>(TKey key, TValue value, CancellationToken cancellationToken);
/// <summary>
/// Removes a value from the cache.
/// </summary>
/// <typeparam name="TKey">The type of the key.</typeparam>
/// <param name="key">The key to identify the cache entry.</param>
/// <param name="cancellationToken">The cancellation token for the async operation.</param>
/// <returns>A task for the async operation.</returns>
Task RemoveAsync<TKey>(TKey key, CancellationToken cancellationToken);
}
@@ -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;
/// <summary>
/// Interface for a class that controls background job processing.
/// </summary>
internal interface IChannelHandler
{
/// <summary>
/// Queue a job for background processing.
/// </summary>
/// <param name="job">The job queued for background processing.</param>
void QueueJob(BackgroundJobBase job);
/// <summary>
/// Add a runner to the channel handler.
/// </summary>
/// <param name="runnerTask">The runner task used to process jobs.</param>
void AddRunner(Func<Channel<BackgroundJobBase>, Task> runnerTask);
/// <summary>
/// Stop the channel and wait for all runners to complete
/// </summary>
/// <returns>A task representing the job.</returns>
Task StopAndWaitForCompletionAsync();
}
@@ -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;
/// <summary>
/// Defines methods for interacting with the Purview service, including content processing,
/// protection scope management, and activity tracking.
/// </summary>
/// <remarks>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.</remarks>
internal interface IPurviewClient
{
/// <summary>
/// Get user info from auth token.
/// </summary>
/// <param name="cancellationToken">The cancellation token used to cancel async processing.</param>
/// <param name="tenantId">The default tenant id used to retrieve the token and its info.</param>
/// <returns>The token info from the token.</returns>
/// <exception cref="InvalidOperationException">Throw if the token was invalid or could not be retrieved.</exception>
Task<TokenInfo> GetUserInfoFromTokenAsync(CancellationToken cancellationToken, string? tenantId = default);
/// <summary>
/// Call ProcessContent API.
/// </summary>
/// <param name="request">The request containing the content to process.</param>
/// <param name="cancellationToken">The cancellation token used to cancel async processing.</param>
/// <returns>The response from the Purview API.</returns>
/// <exception cref="PurviewException">Thrown for validation, auth, and network errors.</exception>
Task<ProcessContentResponse> ProcessContentAsync(ProcessContentRequest request, CancellationToken cancellationToken);
/// <summary>
/// Call user ProtectionScope API.
/// </summary>
/// <param name="request">The request containing the protection scopes metadata.</param>
/// <param name="cancellationToken">The cancellation token used to cancel async processing.</param>
/// <returns>The protection scopes that apply to the data sent in the request.</returns>
/// <exception cref="PurviewException">Thrown for validation, auth, and network errors.</exception>
Task<ProtectionScopesResponse> GetProtectionScopesAsync(ProtectionScopesRequest request, CancellationToken cancellationToken);
/// <summary>
/// Call contentActivities API.
/// </summary>
/// <param name="request">The request containing the content metadata. Used to generate interaction records.</param>
/// <param name="cancellationToken">The cancellation token used to cancel async processing.</param>
/// <returns>The response from the Purview API.</returns>
/// <exception cref="PurviewException">Thrown for validation, auth, and network errors.</exception>
Task<ContentActivitiesResponse> SendContentActivitiesAsync(ContentActivitiesRequest request, CancellationToken cancellationToken);
}
@@ -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;
/// <summary>
/// Orchestrates the processing of scoped content by combining protection scope, process content, and content activities operations.
/// </summary>
internal interface IScopedContentProcessor
{
/// <summary>
/// Process a list of messages.
/// The list of messages should be a prompt or response.
/// </summary>
/// <param name="messages">A list of <see cref="ChatMessage"/> objects sent to the agent or received from the agent..</param>
/// <param name="threadId">The thread where the messages were sent.</param>
/// <param name="activity">An activity to indicate prompt or response.</param>
/// <param name="purviewSettings">Purview settings containing tenant id, app name, etc.</param>
/// <param name="userId">The user who sent the prompt or is receiving the response.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A bool indicating if the request should be blocked and the user id of the user who made the request.</returns>
Task<(bool shouldBlock, string? userId)> ProcessMessagesAsync(IEnumerable<ChatMessage> messages, string? threadId, Activity activity, PurviewSettings purviewSettings, string? userId, CancellationToken cancellationToken);
}
@@ -0,0 +1,43 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>$(ProjectsTargetFrameworks)</TargetFrameworks>
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsDebugTargetFrameworks)</TargetFrameworks>
<VersionSuffix>alpha</VersionSuffix>
</PropertyGroup>
<PropertyGroup>
<InjectSharedThrow>true</InjectSharedThrow>
<InjectDiagnosticClassesOnLegacy>true</InjectDiagnosticClassesOnLegacy>
<InjectTrimAttributesOnLegacy>true</InjectTrimAttributesOnLegacy>
</PropertyGroup>
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
<ItemGroup>
<ProjectReference Include="..\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
<ProjectReference Include="..\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.Extensions.AI" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="System.Diagnostics.DiagnosticSource" />
</ItemGroup>
<PropertyGroup>
<!-- NuGet Package Settings -->
<Title>Microsoft.Agents.AI.Purview</Title>
<Description>Tools to connect generative AI apps to Microsoft Purview.</Description>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="Microsoft.Agents.AI.Purview.UnitTests" />
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" /> <!-- To let Moq mock internal interfaces -->
</ItemGroup>
<PropertyGroup>
<NoWarn>$(NoWarn);CA1812</NoWarn>
</PropertyGroup>
</Project>
@@ -0,0 +1,29 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Purview.Models.Common;
/// <summary>
/// Info about an AI agent associated with the content.
/// </summary>
internal sealed class AIAgentInfo
{
/// <summary>
/// Gets or sets agent id.
/// </summary>
[JsonPropertyName("identifier")]
public string? Identifier { get; set; }
/// <summary>
/// Gets or sets agent name.
/// </summary>
[JsonPropertyName("name")]
public string? Name { get; set; }
/// <summary>
/// Gets or sets agent version.
/// </summary>
[JsonPropertyName("version")]
public string? Version { get; set; }
}
@@ -0,0 +1,29 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Purview.Models.Common;
/// <summary>
/// Represents a plugin used in an AI interaction within the Purview SDK.
/// </summary>
internal sealed class AIInteractionPlugin
{
/// <summary>
/// Gets or sets Plugin id.
/// </summary>
[JsonPropertyName("identifier")]
public string? Identifier { get; set; }
/// <summary>
/// Gets or sets Plugin Name.
/// </summary>
[JsonPropertyName("name")]
public string? Name { get; set; }
/// <summary>
/// Gets or sets Plugin Version.
/// </summary>
[JsonPropertyName("version")]
public string? Version { get; set; }
}
@@ -0,0 +1,53 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Purview.Models.Common;
/// <summary>
/// Information about a resource accessed during a conversation.
/// </summary>
internal sealed class AccessedResourceDetails
{
/// <summary>
/// Resource ID.
/// </summary>
[JsonPropertyName("identifier")]
public string? Identifier { get; set; }
/// <summary>
/// Resource name.
/// </summary>
[JsonPropertyName("name")]
public string? Name { get; set; }
/// <summary>
/// Resource URL.
/// </summary>
[JsonPropertyName("url")]
public string? Url { get; set; }
/// <summary>
/// Sensitivity label id detected on the resource.
/// </summary>
[JsonPropertyName("labelId")]
public string? LabelId { get; set; }
/// <summary>
/// Access type performed on the resource.
/// </summary>
[JsonPropertyName("accessType")]
public ResourceAccessType AccessType { get; set; }
/// <summary>
/// Status of the access operation.
/// </summary>
[JsonPropertyName("status")]
public ResourceAccessStatus Status { get; set; }
/// <summary>
/// Indicates if cross prompt injection was detected.
/// </summary>
[JsonPropertyName("isCrossPromptInjectionDetected")]
public bool? IsCrossPromptInjectionDetected { get; set; }
}
@@ -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;
/// <summary>
/// Activity definitions
/// </summary>
[DataContract]
[JsonConverter(typeof(JsonStringEnumConverter<Activity>))]
internal enum Activity : int
{
/// <summary>
/// Unknown activity
/// </summary>
[EnumMember(Value = "unknown")]
Unknown = 0,
/// <summary>
/// Upload text
/// </summary>
[EnumMember(Value = "uploadText")]
UploadText = 1,
/// <summary>
/// Upload file
/// </summary>
[EnumMember(Value = "uploadFile")]
UploadFile = 2,
/// <summary>
/// Download text
/// </summary>
[EnumMember(Value = "downloadText")]
DownloadText = 3,
/// <summary>
/// Download file
/// </summary>
[EnumMember(Value = "downloadFile")]
DownloadFile = 4,
}
@@ -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;
/// <summary>
/// Request for metadata information
/// </summary>
[DataContract]
internal sealed class ActivityMetadata
{
/// <summary>
/// Initializes a new instance of the <see cref="ActivityMetadata"/> class.
/// </summary>
/// <param name="activity">The activity performed with the content.</param>
public ActivityMetadata(Activity activity)
{
this.Activity = activity;
}
/// <summary>
/// The activity performed with the content.
/// </summary>
[DataMember]
[JsonConverter(typeof(JsonStringEnumConverter<Activity>))]
[JsonPropertyName("activity")]
public Activity Activity { get; }
}
@@ -0,0 +1,37 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Purview.Models.Common;
/// <summary>
/// Base error contract returned when some exception occurs.
/// </summary>
[JsonDerivedType(typeof(ProcessingError))]
internal class ClassificationErrorBase
{
/// <summary>
/// Gets or sets the error code.
/// </summary>
[JsonPropertyName("code")]
public string? ErrorCode { get; set; }
/// <summary>
/// Gets or sets the message.
/// </summary>
[JsonPropertyName("message")]
public string? Message { get; set; }
/// <summary>
/// Gets or sets target of error.
/// </summary>
[JsonPropertyName("target")]
public string? Target { get; set; }
/// <summary>
/// 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.
/// </summary>
[JsonPropertyName("innerError")]
public ClassificationInnerError? InnerError { get; set; }
}
@@ -0,0 +1,36 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Purview.Models.Common;
/// <summary>
/// Inner classification error.
/// </summary>
internal sealed class ClassificationInnerError
{
/// <summary>
/// Gets or sets date of error.
/// </summary>
[JsonPropertyName("date")]
public DateTime? Date { get; set; }
/// <summary>
/// Gets or sets error code.
/// </summary>
[JsonPropertyName("code")]
public string? ErrorCode { get; set; }
/// <summary>
/// Gets or sets client request ID.
/// </summary>
[JsonPropertyName("clientRequestId")]
public string? ClientRequestId { get; set; }
/// <summary>
/// Gets or sets Activity ID.
/// </summary>
[JsonPropertyName("activityId")]
public string? ActivityId { get; set; }
}
@@ -0,0 +1,21 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Purview.Models.Common;
/// <summary>
/// Base class for content items to be processed by the Purview SDK.
/// </summary>
[JsonDerivedType(typeof(PurviewTextContent))]
[JsonDerivedType(typeof(PurviewBinaryContent))]
internal abstract class ContentBase : GraphDataTypeBase
{
/// <summary>
/// Creates a new instance of the <see cref="ContentBase"/> class.
/// </summary>
/// <param name="dataType">The graph data type of the content.</param>
public ContentBase(string dataType) : base(dataType)
{
}
}
@@ -0,0 +1,27 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Purview.Models.Common;
/// <summary>
/// Type of error that occurred during content processing.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<ContentProcessingErrorType>))]
internal enum ContentProcessingErrorType
{
/// <summary>
/// Error is transient.
/// </summary>
Transient,
/// <summary>
/// Error is permanent.
/// </summary>
Permanent,
/// <summary>
/// Unknown future value placeholder.
/// </summary>
UnknownFutureValue
}
@@ -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;
/// <summary>
/// Content to be processed by process content.
/// </summary>
internal sealed class ContentToProcess
{
/// <summary>
/// Creates a new instance of ContentToProcess.
/// </summary>
/// <param name="contentEntries">The content to send and its associated ids.</param>
/// <param name="activityMetadata">Metadata about the activity performed with the content.</param>
/// <param name="deviceMetadata">Metadata about the device that produced the content.</param>
/// <param name="integratedAppMetadata">Metadata about the application integrating with Purview.</param>
/// <param name="protectedAppMetadata">Metadata about the application being protected by Purview.</param>
public ContentToProcess(
List<ProcessContentMetadataBase> contentEntries,
ActivityMetadata activityMetadata,
DeviceMetadata deviceMetadata,
IntegratedAppMetadata integratedAppMetadata,
ProtectedAppMetadata protectedAppMetadata)
{
this.ContentEntries = contentEntries;
this.ActivityMetadata = activityMetadata;
this.DeviceMetadata = deviceMetadata;
this.IntegratedAppMetadata = integratedAppMetadata;
this.ProtectedAppMetadata = protectedAppMetadata;
}
/// <summary>
/// Gets or sets the content entries.
/// List of activities supported by caller. It is used to trim response to activities interesting to the caller.
/// </summary>
[JsonPropertyName("contentEntries")]
public List<ProcessContentMetadataBase> ContentEntries { get; set; }
/// <summary>
/// Activity metadata
/// </summary>
[DataMember]
[JsonPropertyName("activityMetadata")]
public ActivityMetadata ActivityMetadata { get; set; }
/// <summary>
/// Device metadata
/// </summary>
[DataMember]
[JsonPropertyName("deviceMetadata")]
public DeviceMetadata DeviceMetadata { get; set; }
/// <summary>
/// Integrated app metadata
/// </summary>
[DataMember]
[JsonPropertyName("integratedAppMetadata")]
public IntegratedAppMetadata IntegratedAppMetadata { get; set; }
/// <summary>
/// Protected app metadata
/// </summary>
[DataMember]
[JsonPropertyName("protectedAppMetadata")]
public ProtectedAppMetadata ProtectedAppMetadata { get; set; }
}
@@ -0,0 +1,29 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Purview.Models.Common;
/// <summary>
/// Endpoint device Metdata
/// </summary>
internal sealed class DeviceMetadata
{
/// <summary>
/// Device type
/// </summary>
[JsonPropertyName("deviceType")]
public string? DeviceType { get; set; }
/// <summary>
/// The ip address of the device.
/// </summary>
[JsonPropertyName("ipAddress")]
public string? IpAddress { get; set; }
/// <summary>
/// OS specifications
/// </summary>
[JsonPropertyName("operatingSystemSpecifications")]
public OperatingSystemSpecifications? OperatingSystemSpecifications { get; set; }
}
@@ -0,0 +1,69 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Purview.Models.Common;
/// <summary>
/// Defines all the actions for DLP.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<DlpAction>))]
internal enum DlpAction
{
/// <summary>
/// The DLP action to notify user.
/// </summary>
NotifyUser,
/// <summary>
/// The DLP action is block.
/// </summary>
BlockAccess,
/// <summary>
/// The DLP action to apply restrictions on device.
/// </summary>
DeviceRestriction,
/// <summary>
/// The DLP action to apply restrictions on browsers.
/// </summary>
BrowserRestriction,
/// <summary>
/// The DLP action to generate an alert
/// </summary>
GenerateAlert,
/// <summary>
/// The DLP action to generate an incident report
/// </summary>
GenerateIncidentReportAction,
/// <summary>
/// The DLP action to block anonymous link access in SPO
/// </summary>
SPBlockAnonymousAccess,
/// <summary>
/// DLP Action to disallow guest access in SPO
/// </summary>
SPRuntimeAccessControl,
/// <summary>
/// DLP No Op action for NotifyUser. Used in Block Access V2 rule
/// </summary>
SPSharingNotifyUser,
/// <summary>
/// DLP No Op action for GIR. Used in Block Access V2 rule
/// </summary>
SPSharingGenerateIncidentReport,
/// <summary>
/// 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.
/// </summary>
RestrictAccess,
}
@@ -0,0 +1,23 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Purview.Models.Common;
/// <summary>
/// Base class to define DLP Actions.
/// </summary>
internal sealed class DlpActionInfo
{
/// <summary>
/// Gets or sets the type of the DLP action.
/// </summary>
[JsonPropertyName("action")]
public DlpAction Action { get; set; }
/// <summary>
/// The type of restriction action to take.
/// </summary>
[JsonPropertyName("restrictionAction")]
public RestrictionAction? RestrictionAction { get; set; }
}
@@ -0,0 +1,23 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Purview.Models.Common;
/// <summary>
/// Represents the details of an error.
/// </summary>
internal sealed class ErrorDetails
{
/// <summary>
/// Gets or sets the error code.
/// </summary>
[JsonPropertyName("code")]
public string? Code { get; set; }
/// <summary>
/// Gets or sets the error message.
/// </summary>
[JsonPropertyName("message")]
public string? Message { get; set; }
}
@@ -0,0 +1,27 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Purview.Models.Common;
/// <summary>
/// Request execution mode
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<ExecutionMode>))]
internal enum ExecutionMode : int
{
/// <summary>
/// Evaluate inline.
/// </summary>
EvaluateInline = 1,
/// <summary>
/// Evaluate offline.
/// </summary>
EvaluateOffline = 2,
/// <summary>
/// Unknown future value.
/// </summary>
UnknownFutureValue = 3
}
@@ -0,0 +1,26 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Purview.Models.Common;
/// <summary>
/// Base class for all graph data types used in the Purview SDK.
/// </summary>
internal abstract class GraphDataTypeBase
{
/// <summary>
/// Create a new instance of the <see cref="GraphDataTypeBase"/> class.
/// </summary>
/// <param name="dataType">The data type of the graph object.</param>
public GraphDataTypeBase(string dataType)
{
this.DataType = dataType;
}
/// <summary>
/// The @odata.type property name used in the JSON representation of the object.
/// </summary>
[JsonPropertyName(Constants.ODataTypePropertyName)]
public string DataType { get; set; }
}
@@ -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;
/// <summary>
/// Request for metadata information
/// </summary>
[JsonDerivedType(typeof(ProtectedAppMetadata))]
internal class IntegratedAppMetadata
{
/// <summary>
/// Application name
/// </summary>
[DataMember]
[JsonPropertyName("name")]
public string? Name { get; set; }
/// <summary>
/// Application version
/// </summary>
[DataMember]
[JsonPropertyName("version")]
public string? Version { get; set; }
}
@@ -0,0 +1,23 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Purview.Models.Common;
/// <summary>
/// Operating System Specifications
/// </summary>
internal sealed class OperatingSystemSpecifications
{
/// <summary>
/// OS platform
/// </summary>
[JsonPropertyName("operatingSystemPlatform")]
public string? OperatingSystemPlatform { get; set; }
/// <summary>
/// OS version
/// </summary>
[JsonPropertyName("operatingSystemVersion")]
public string? OperatingSystemVersion { get; set; }
}
@@ -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;
/// <summary>
/// Represents user scoping information, i.e. which users are affected by the policy.
/// </summary>
internal sealed class PolicyBinding
{
/// <summary>
/// Gets or sets the users to be included.
/// </summary>
[JsonPropertyName("inclusions")]
public ICollection<Scope>? Inclusions { get; set; }
/// <summary>
/// Gets or sets the users to be excluded.
/// Exclusions may not be present in the response, thus this property is nullable.
/// </summary>
[JsonPropertyName("exclusions")]
public ICollection<Scope>? Exclusions { get; set; }
}
@@ -0,0 +1,27 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Purview.Models.Common;
/// <summary>
/// Represents a location to which policy is applicable.
/// </summary>
internal sealed class PolicyLocation : GraphDataTypeBase
{
/// <summary>
/// Creates a new instance of the <see cref="PolicyLocation"/> class.
/// </summary>
/// <param name="dataType">The graph data type of the PolicyLocation object.</param>
/// <param name="value">THe value of the policy location: app id, domain, etc.</param>
public PolicyLocation(string dataType, string value) : base(dataType)
{
this.Value = value;
}
/// <summary>
/// Gets or sets the applicable value for location.
/// </summary>
[JsonPropertyName("value")]
public string Value { get; set; }
}
@@ -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;
/// <summary>
/// Property for policy scoping response to aggregate on
/// </summary>
[DataContract]
[JsonConverter(typeof(JsonStringEnumConverter<PolicyPivotProperty>))]
internal enum PolicyPivotProperty : int
{
/// <summary>
/// Unknown activity
/// </summary>
[EnumMember]
[JsonPropertyName("none")]
None = 0,
/// <summary>
/// Pivot on Activity
/// </summary>
[EnumMember]
[JsonPropertyName("activity")]
Activity = 1,
/// <summary>
/// Pivot on location
/// </summary>
[EnumMember]
[JsonPropertyName("location")]
Location = 2,
/// <summary>
/// Pivot on location
/// </summary>
[EnumMember]
[JsonPropertyName("unknownFutureValue")]
UnknownFutureValue = 3,
}
@@ -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;
/// <summary>
/// Represents a scope for policy protection.
/// </summary>
internal sealed class PolicyScopeBase
{
/// <summary>
/// Gets or sets the locations to be protected, e.g. domains or URLs.
/// </summary>
[JsonPropertyName("locations")]
public ICollection<PolicyLocation>? Locations { get; set; }
/// <summary>
/// Gets or sets the activities to be protected, e.g. uploadText, downloadText.
/// </summary>
[JsonPropertyName("activities")]
public ProtectionScopeActivities Activities { get; set; }
/// <summary>
/// Gets or sets how policy should be executed - fire-and-forget or wait for completion.
/// </summary>
[JsonPropertyName("executionMode")]
public ExecutionMode ExecutionMode { get; set; }
/// <summary>
/// Gets or sets the enforcement actions to be taken on activities and locations from this scope.
/// There may be no actions in the response.
/// </summary>
[JsonPropertyName("policyActions")]
public ICollection<DlpActionInfo>? PolicyActions { get; set; }
/// <summary>
/// Gets or sets information about policy applicability to a specific user.
/// </summary>
[JsonPropertyName("policyScope")]
public PolicyBinding? PolicyScope { get; set; }
}
@@ -0,0 +1,94 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Purview.Models.Common;
/// <summary>
/// Base class for process content metadata.
/// </summary>
[JsonDerivedType(typeof(ProcessConversationMetadata))]
[JsonDerivedType(typeof(ProcessFileMetadata))]
internal abstract class ProcessContentMetadataBase : GraphDataTypeBase
{
private const string ProcessConversationMetadataDataType = Constants.ODataGraphNamespace + ".processConversationMetadata";
/// <summary>
/// Creates a new instance of ProcessContentMetadataBase.
/// </summary>
/// <param name="content">The content that will be processed.</param>
/// <param name="identifier">The unique identifier for the content.</param>
/// <param name="isTruncated">Indicates if the content is truncated.</param>
/// <param name="name">The name of the content.</param>
public ProcessContentMetadataBase(ContentBase content, string identifier, bool isTruncated, string name) : base(ProcessConversationMetadataDataType)
{
this.Identifier = identifier;
this.IsTruncated = isTruncated;
this.Content = content;
this.Name = name;
}
/// <summary>
/// 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.
/// </summary>
[JsonPropertyName("identifier")]
public string Identifier { get; set; }
/// <summary>
/// Gets or sets the content.
/// The content to be processed.
/// </summary>
[JsonPropertyName("content")]
public ContentBase Content { get; set; }
/// <summary>
/// Gets or sets the name.
/// Name of the content, e.g., file name or web page title.
/// </summary>
[JsonPropertyName("name")]
public string Name { get; set; }
/// <summary>
/// Gets or sets the correlationId.
/// Identifier to group multiple contents.
/// </summary>
[JsonPropertyName("correlationId")]
public string? CorrelationId { get; set; }
/// <summary>
/// Gets or sets the sequenceNumber.
/// Sequence in which the content was originally generated.
/// </summary>
[JsonPropertyName("sequenceNumber")]
public long? SequenceNumber { get; set; }
/// <summary>
/// Gets or sets the length.
/// Content length in bytes.
/// </summary>
[JsonPropertyName("length")]
public long? Length { get; set; }
/// <summary>
/// Gets or sets the isTruncated.
/// Indicates if the original content has been truncated, e.g., to meet text or file size limits.
/// </summary>
[JsonPropertyName("isTruncated")]
public bool IsTruncated { get; set; }
/// <summary>
/// Gets or sets the createdDateTime.
/// When the content was created. E.g., file created time or the time when a message was sent.
/// </summary>
[JsonPropertyName("createdDateTime")]
public DateTimeOffset CreatedDateTime { get; set; } = DateTime.UtcNow;
/// <summary>
/// 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.
/// </summary>
[JsonPropertyName("modifiedDateTime")]
public DateTimeOffset? ModifiedDateTime { get; set; } = DateTime.UtcNow;
}
@@ -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;
/// <summary>
/// Represents metadata for conversation content to be processed by the Purview SDK.
/// </summary>
internal sealed class ProcessConversationMetadata : ProcessContentMetadataBase
{
private const string ProcessConversationMetadataDataType = Constants.ODataGraphNamespace + ".processConversationMetadata";
/// <summary>
/// Initializes a new instance of the <see cref="ProcessConversationMetadata"/> class.
/// </summary>
public ProcessConversationMetadata(ContentBase contentBase, string identifier, bool isTruncated, string name) : base(contentBase, identifier, isTruncated, name)
{
this.DataType = ProcessConversationMetadataDataType;
}
/// <summary>
/// Gets or sets the parent message ID for nested conversations.
/// </summary>
[JsonPropertyName("parentMessageId")]
public string? ParentMessageId { get; set; }
/// <summary>
/// Gets or sets the accessed resources during message generation for bot messages.
/// </summary>
[JsonPropertyName("accessedResources_v2")]
public List<AccessedResourceDetails>? AccessedResources { get; set; }
/// <summary>
/// Gets or sets the plugins used during message generation for bot messages.
/// </summary>
[JsonPropertyName("plugins")]
public List<AIInteractionPlugin>? Plugins { get; set; }
/// <summary>
/// Gets or sets the collection of AI agent information.
/// </summary>
[JsonPropertyName("agents")]
public List<AIAgentInfo>? Agents { get; set; }
}
@@ -0,0 +1,27 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Purview.Models.Common;
/// <summary>
/// Represents metadata for a file content to be processed by the Purview SDK.
/// </summary>
internal sealed class ProcessFileMetadata : ProcessContentMetadataBase
{
private const string ProcessFileMetadataDataType = Constants.ODataGraphNamespace + ".processFileMetadata";
/// <summary>
/// Initializes a new instance of the <see cref="ProcessFileMetadata"/> class.
/// </summary>
public ProcessFileMetadata(ContentBase contentBase, string identifier, bool isTruncated, string name) : base(contentBase, identifier, isTruncated, name)
{
this.DataType = ProcessFileMetadataDataType;
}
/// <summary>
/// Gets or sets the owner ID.
/// </summary>
[JsonPropertyName("ownerId")]
public string? OwnerId { get; set; }
}
@@ -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;
/// <summary>
/// Contains information about a processing error.
/// </summary>
internal sealed class ProcessingError : ClassificationErrorBase
{
/// <summary>
/// Details about the error.
/// </summary>
[JsonPropertyName("details")]
public List<ClassificationErrorBase>? Details { get; set; }
/// <summary>
/// Gets or sets the error type.
/// </summary>
[JsonPropertyName("type")]
public ContentProcessingErrorType? Type { get; set; }
}
@@ -0,0 +1,26 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Purview.Models.Common;
/// <summary>
/// Represents metadata for a protected application that is integrated with Purview.
/// </summary>
internal sealed class ProtectedAppMetadata : IntegratedAppMetadata
{
/// <summary>
/// Creates a new instance of the <see cref="ProtectedAppMetadata"/> class.
/// </summary>
/// <param name="applicationLocation">The location information of the protected app's data.</param>
public ProtectedAppMetadata(PolicyLocation applicationLocation)
{
this.ApplicationLocation = applicationLocation;
}
/// <summary>
/// The location of the application.
/// </summary>
[JsonPropertyName("applicationLocation")]
public PolicyLocation ApplicationLocation { get; set; }
}
@@ -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;
/// <summary>
/// Activities that can be protected by the Purview Protection Scopes API.
/// </summary>
[Flags]
[DataContract]
[JsonConverter(typeof(JsonStringEnumConverter<ProtectionScopeActivities>))]
internal enum ProtectionScopeActivities
{
/// <summary>
/// None.
/// </summary>
[EnumMember(Value = "none")]
None = 0,
/// <summary>
/// Upload text activity.
/// </summary>
[EnumMember(Value = "uploadText")]
UploadText = 1,
/// <summary>
/// Upload file activity.
/// </summary>
[EnumMember(Value = "uploadFile")]
UploadFile = 2,
/// <summary>
/// Download text activity.
/// </summary>
[EnumMember(Value = "downloadText")]
DownloadText = 4,
/// <summary>
/// Download file activity.
/// </summary>
[EnumMember(Value = "downloadFile")]
DownloadFile = 8,
/// <summary>
/// Unknown future value.
/// </summary>
[EnumMember(Value = "unknownFutureValue")]
UnknownFutureValue = 16
}
@@ -0,0 +1,27 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Purview.Models.Common;
/// <summary>
/// Indicates status of protection scope changes.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<ProtectionScopeState>))]
internal enum ProtectionScopeState
{
/// <summary>
/// Scope state hasn't changed.
/// </summary>
NotModified = 0,
/// <summary>
/// Scope state has changed.
/// </summary>
Modified = 1,
/// <summary>
/// Unknown value placeholder for future use.
/// </summary>
UnknownFutureValue = 2
}
@@ -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;
/// <summary>
/// A cache key for storing protection scope responses.
/// </summary>
internal sealed class ProtectionScopesCacheKey
{
/// <summary>
/// Creates a new instance of <see cref="ProtectionScopesCacheKey"/>.
/// </summary>
/// <param name="userId">The entra id of the user who made the interaction.</param>
/// <param name="tenantId">The tenant id of the user who made the interaction.</param>
/// <param name="activities">The activity performed with the data.</param>
/// <param name="location">The location where the data came from.</param>
/// <param name="pivotOn">The property to pivot on.</param>
/// <param name="deviceMetadata">Metadata about the device that made the interaction.</param>
/// <param name="integratedAppMetadata">Metadata about the app that is integrating with Purview.</param>
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;
}
/// <summary>
/// Creates a mew instance of <see cref="ProtectionScopesCacheKey"/>.
/// </summary>
/// <param name="request">A protection scopes request.</param>
public ProtectionScopesCacheKey(
ProtectionScopesRequest request) : this(
request.UserId,
request.TenantId,
request.Activities,
request.Locations.FirstOrDefault(),
request.PivotOn,
request.DeviceMetadata,
request.IntegratedAppMetadata)
{
}
/// <summary>
/// The id of the user making the request.
/// </summary>
public string UserId { get; set; }
/// <summary>
/// The id of the tenant containing the user making the request.
/// </summary>
public string TenantId { get; set; }
/// <summary>
/// The activity performed with the content.
/// </summary>
public ProtectionScopeActivities Activities { get; set; }
/// <summary>
/// The location of the application.
/// </summary>
public PolicyLocation? Location { get; set; }
/// <summary>
/// The property used to pivot the policy evaluation.
/// </summary>
public PolicyPivotProperty? PivotOn { get; set; }
/// <summary>
/// Metadata about the device used to access the content.
/// </summary>
public DeviceMetadata? DeviceMetadata { get; set; }
/// <summary>
/// Metadata about the integrated app used to access the content.
/// </summary>
public IntegratedAppMetadata? IntegratedAppMetadata { get; set; }
}
@@ -0,0 +1,28 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Purview.Models.Common;
/// <summary>
/// Represents a binary content item to be processed.
/// </summary>
internal sealed class PurviewBinaryContent : ContentBase
{
private const string BinaryContentDataType = Constants.ODataGraphNamespace + ".binaryContent";
/// <summary>
/// Initializes a new instance of the <see cref="PurviewBinaryContent"/> class.
/// </summary>
/// <param name="data">The binary content in byte array format.</param>
public PurviewBinaryContent(byte[] data) : base(BinaryContentDataType)
{
this.Data = data;
}
/// <summary>
/// Gets or sets the binary data.
/// </summary>
[JsonPropertyName("data")]
public byte[] Data { get; set; }
}
@@ -0,0 +1,28 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Purview.Models.Common;
/// <summary>
/// Represents a text content item to be processed.
/// </summary>
internal sealed class PurviewTextContent : ContentBase
{
private const string TextContentDataType = Constants.ODataGraphNamespace + ".textContent";
/// <summary>
/// Initializes a new instance of the <see cref="PurviewTextContent"/> class.
/// </summary>
/// <param name="data">The text content in string format.</param>
public PurviewTextContent(string data) : base(TextContentDataType)
{
this.Data = data;
}
/// <summary>
/// Gets or sets the text data.
/// </summary>
[JsonPropertyName("data")]
public string Data { get; set; }
}
@@ -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;
/// <summary>
/// Status of the access operation.
/// </summary>
[DataContract]
[JsonConverter(typeof(JsonStringEnumConverter<ResourceAccessStatus>))]
internal enum ResourceAccessStatus
{
/// <summary>
/// Represents failed access to the resource.
/// </summary>
[EnumMember(Value = "failure")]
Failure = 0,
/// <summary>
/// Represents successful access to the resource.
/// </summary>
[EnumMember(Value = "success")]
Success = 1,
/// <summary>
/// Unknown future value.
/// </summary>
[EnumMember(Value = "unknownFutureValue")]
UnknownFutureValue = 2
}
@@ -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;
/// <summary>
/// Access type performed on the resource.
/// </summary>
[Flags]
[DataContract]
[JsonConverter(typeof(JsonStringEnumConverter<ResourceAccessType>))]
internal enum ResourceAccessType : long
{
/// <summary>
/// No access type.
/// </summary>
[EnumMember(Value = "none")]
None = 0,
/// <summary>
/// Read access.
/// </summary>
[EnumMember(Value = "read")]
Read = 1 << 0,
/// <summary>
/// Write access.
/// </summary>
[EnumMember(Value = "write")]
Write = 1 << 1,
/// <summary>
/// Create access.
/// </summary>
[EnumMember(Value = "create")]
Create = 1 << 2,
/// <summary>
/// Unknown future value.
/// </summary>
[EnumMember(Value = "unknownFutureValue")]
UnknownFutureValue = 1 << 3
}
@@ -0,0 +1,32 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Purview.Models.Common;
/// <summary>
/// Restriction actions for devices.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<RestrictionAction>))]
internal enum RestrictionAction
{
/// <summary>
/// Warn Action.
/// </summary>
Warn,
/// <summary>
/// Audit action.
/// </summary>
Audit,
/// <summary>
/// Block action.
/// </summary>
Block,
/// <summary>
/// Allow action
/// </summary>
Allow
}
@@ -0,0 +1,23 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Purview.Models.Common;
/// <summary>
/// Represents tenant/user/group scopes.
/// </summary>
internal sealed class Scope
{
/// <summary>
/// The odata type of the scope used to identify what type of scope was returned.
/// </summary>
[JsonPropertyName("@odata.type")]
public string? ODataType { get; set; }
/// <summary>
/// Gets or sets the scope identifier.
/// </summary>
[JsonPropertyName("identity")]
public string? Identity { get; set; }
}
@@ -0,0 +1,29 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.Purview.Models.Common;
/// <summary>
/// Info pulled from an auth token.
/// </summary>
internal sealed class TokenInfo
{
/// <summary>
/// The entra id of the authenticated user. This is null if the auth token is not a user token.
/// </summary>
public string? UserId { get; set; }
/// <summary>
/// The tenant id of the auth token.
/// </summary>
public string? TenantId { get; set; }
/// <summary>
/// The client id of the auth token.
/// </summary>
public string? ClientId { get; set; }
/// <summary>
/// Gets a value indicating whether the token is associated with a user.
/// </summary>
public bool IsUserToken => !string.IsNullOrEmpty(this.UserId);
}
@@ -0,0 +1,10 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.Purview.Models.Jobs;
/// <summary>
/// Abstract base class for background jobs.
/// </summary>
internal abstract class BackgroundJobBase
{
}
@@ -0,0 +1,25 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Purview.Models.Requests;
namespace Microsoft.Agents.AI.Purview.Models.Jobs;
/// <summary>
/// Class representing a job to send content activities to the Purview service.
/// </summary>
internal sealed class ContentActivityJob : BackgroundJobBase
{
/// <summary>
/// Create a new instance of the <see cref="ContentActivityJob"/> class.
/// </summary>
/// <param name="request">The content activities request to be sent in the background.</param>
public ContentActivityJob(ContentActivitiesRequest request)
{
this.Request = request;
}
/// <summary>
/// The request to send to the Purview service.
/// </summary>
public ContentActivitiesRequest Request { get; }
}
@@ -0,0 +1,25 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Purview.Models.Requests;
namespace Microsoft.Agents.AI.Purview.Models.Jobs;
/// <summary>
/// Class representing a job to process content.
/// </summary>
internal sealed class ProcessContentJob : BackgroundJobBase
{
/// <summary>
/// Initializes a new instance of the <see cref="ProcessContentJob"/> class.
/// </summary>
/// <param name="request">The process content request to be sent in the background.</param>
public ProcessContentJob(ProcessContentRequest request)
{
this.Request = request;
}
/// <summary>
/// The request to process content.
/// </summary>
public ProcessContentRequest Request { get; }
}
@@ -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;
/// <summary>
/// A request class used for contentActivity requests.
/// </summary>
internal sealed class ContentActivitiesRequest
{
/// <summary>
/// Initializes a new instance of the <see cref="ContentActivitiesRequest"/> class.
/// </summary>
/// <param name="userId">The entra id of the user who performed the activity.</param>
/// <param name="tenantId">The tenant id of the user who performed the activity.</param>
/// <param name="contentMetadata">The metadata about the content that was sent.</param>
/// <param name="correlationId">The correlation id of the request.</param>
/// <param name="scopeIdentifier">The scope identifier of the protection scopes associated with this request.</param>
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;
}
/// <summary>
/// Gets or sets the ID of the signal.
/// </summary>
[JsonPropertyName("id")]
public string Id { get; set; } = Guid.NewGuid().ToString();
/// <summary>
/// Gets or sets the user ID of the content that is generating the signal.
/// </summary>
[JsonPropertyName("userId")]
public string UserId { get; set; }
/// <summary>
/// Gets or sets the scope identifier for the signal.
/// </summary>
[JsonPropertyName("scopeIdentifier")]
public string? ScopeIdentifier { get; set; }
/// <summary>
/// Gets or sets the content and associated content metadata for the content used to generate the signal.
/// </summary>
[JsonPropertyName("contentMetadata")]
public ContentToProcess ContentMetadata { get; set; }
/// <summary>
/// Gets or sets the correlation ID for the signal.
/// </summary>
[JsonIgnore]
public Guid CorrelationId { get; set; }
/// <summary>
/// Gets or sets the tenant id for the signal.
/// </summary>
[JsonIgnore]
public string TenantId { get; set; }
}
@@ -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;
/// <summary>
/// Request for ProcessContent API
/// </summary>
internal sealed class ProcessContentRequest
{
/// <summary>
/// Creates a new instance of ProcessContentRequest.
/// </summary>
/// <param name="contentToProcess">The content and its metadata that will be processed.</param>
/// <param name="userId">The entra user id of the user making the request.</param>
/// <param name="tenantId">The tenant id of the user making the request.</param>
public ProcessContentRequest(ContentToProcess contentToProcess, string userId, string tenantId)
{
this.ContentToProcess = contentToProcess;
this.UserId = userId;
this.TenantId = tenantId;
}
/// <summary>
/// The content to process.
/// </summary>
[JsonPropertyName("contentToProcess")]
public ContentToProcess ContentToProcess { get; set; }
/// <summary>
/// The user id of the user making the request.
/// </summary>
[JsonIgnore]
public string UserId { get; set; }
/// <summary>
/// The correlation id of the request.
/// </summary>
[JsonIgnore]
public Guid CorrelationId { get; set; } = Guid.NewGuid();
/// <summary>
/// The tenant id of the user making the request.
/// </summary>
[JsonIgnore]
public string TenantId { get; set; }
/// <summary>
/// The identifier of the cached protection scopes.
/// </summary>
[JsonIgnore]
internal string? ScopeIdentifier { get; set; }
}
@@ -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;
/// <summary>
/// Request model for user protection scopes requests.
/// </summary>
[DataContract]
internal sealed class ProtectionScopesRequest
{
/// <summary>
/// Creates a new instance of ProtectionScopesRequest.
/// </summary>
/// <param name="userId">The entra id of the user who made the interaction.</param>
/// <param name="tenantId">The tenant id of the user who made the interaction.</param>
public ProtectionScopesRequest(string userId, string tenantId)
{
this.UserId = userId;
this.TenantId = tenantId;
}
/// <summary>
/// Activities to include in the scope
/// </summary>
[DataMember]
[JsonPropertyName("activities")]
public ProtectionScopeActivities Activities { get; set; }
/// <summary>
/// Gets or sets the locations to compute protection scopes for.
/// </summary>
[JsonPropertyName("locations")]
public ICollection<PolicyLocation> Locations { get; set; } = Array.Empty<PolicyLocation>();
/// <summary>
/// Response aggregation pivot
/// </summary>
[DataMember]
[JsonPropertyName("pivotOn")]
public PolicyPivotProperty? PivotOn { get; set; }
/// <summary>
/// Device metadata
/// </summary>
[DataMember]
[JsonPropertyName("deviceMetadata")]
public DeviceMetadata? DeviceMetadata { get; set; }
/// <summary>
/// Integrated app metadata
/// </summary>
[DataMember]
[JsonPropertyName("integratedAppMetadata")]
public IntegratedAppMetadata? IntegratedAppMetadata { get; set; }
/// <summary>
/// The correlation id of the request.
/// </summary>
[JsonIgnore]
public Guid CorrelationId { get; set; } = Guid.NewGuid();
/// <summary>
/// Scope ID, used to detect stale client scoping information
/// </summary>
[DataMember]
[JsonIgnore]
public string ScopeIdentifier { get; set; } = string.Empty;
/// <summary>
/// The id of the user making the request.
/// </summary>
[JsonIgnore]
public string UserId { get; set; }
/// <summary>
/// The tenant id of the user making the request.
/// </summary>
[JsonIgnore]
public string TenantId { get; set; }
}
@@ -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;
/// <summary>
/// Represents the response for content activities requests.
/// </summary>
internal sealed class ContentActivitiesResponse
{
/// <summary>
/// Gets or sets the HTTP status code associated with the response.
/// </summary>
[JsonIgnore]
public HttpStatusCode StatusCode { get; set; }
/// <summary>
/// Details about any errors returned by the request.
/// </summary>
[JsonPropertyName("error")]
public ErrorDetails? Error { get; set; }
}
@@ -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;
/// <summary>
/// The response of a process content evaluation.
/// </summary>
internal sealed class ProcessContentResponse
{
/// <summary>
/// Gets or sets the evaluation id.
/// </summary>
[Key]
public string? Id { get; set; }
/// <summary>
/// Gets or sets the status of protection scope changes.
/// </summary>
[DataMember]
[JsonPropertyName("protectionScopeState")]
public ProtectionScopeState? ProtectionScopeState { get; set; }
/// <summary>
/// Gets or sets the policy actions to take.
/// </summary>
[DataMember]
[JsonPropertyName("policyActions")]
public IReadOnlyList<DlpActionInfo>? PolicyActions { get; set; }
/// <summary>
/// Gets or sets error information about the evaluation.
/// </summary>
[DataMember]
[JsonPropertyName("processingErrors")]
public IReadOnlyList<ProcessingError>? ProcessingErrors { get; set; }
}
@@ -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;
/// <summary>
/// A response object containing protection scopes for a tenant.
/// </summary>
internal sealed class ProtectionScopesResponse
{
/// <summary>
/// The identifier used for caching the user protection scopes.
/// </summary>
public string? ScopeIdentifier { get; set; }
/// <summary>
/// The user protection scopes.
/// </summary>
[JsonPropertyName("value")]
public IReadOnlyCollection<PolicyScopeBase>? Scopes { get; set; }
}
@@ -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;
/// <summary>
/// A middleware agent that connects to Microsoft Purview.
/// </summary>
internal class PurviewAgent : AIAgent, IDisposable
{
private readonly AIAgent _innerAgent;
private readonly PurviewWrapper _purviewWrapper;
/// <summary>
/// Initializes a new instance of the <see cref="PurviewAgent"/> class.
/// </summary>
/// <param name="innerAgent">The agent-framework agent that the middleware wraps.</param>
/// <param name="purviewWrapper">The purview wrapper used to interact with the Purview service.</param>
public PurviewAgent(AIAgent innerAgent, PurviewWrapper purviewWrapper)
{
this._innerAgent = innerAgent;
this._purviewWrapper = purviewWrapper;
}
/// <inheritdoc/>
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
{
return this._innerAgent.DeserializeThread(serializedThread, jsonSerializerOptions);
}
/// <inheritdoc/>
public override AgentThread GetNewThread()
{
return this._innerAgent.GetNewThread();
}
/// <inheritdoc/>
public override Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
return this._purviewWrapper.ProcessAgentContentAsync(messages, thread, options, this._innerAgent, cancellationToken);
}
/// <inheritdoc/>
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(IEnumerable<ChatMessage> 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;
}
}
/// <inheritdoc/>
public void Dispose()
{
if (this._innerAgent is IDisposable disposableAgent)
{
disposableAgent.Dispose();
}
this._purviewWrapper.Dispose();
}
}
@@ -0,0 +1,53 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using Microsoft.Agents.AI.Purview.Models.Common;
namespace Microsoft.Agents.AI.Purview;
/// <summary>
/// An identifier representing the app's location for Purview policy evaluation.
/// </summary>
public class PurviewAppLocation
{
/// <summary>
/// Creates a new instance of <see cref="PurviewAppLocation"/>.
/// </summary>
/// <param name="locationType">The type of location.</param>
/// <param name="locationValue">The value of the location.</param>
public PurviewAppLocation(PurviewLocationType locationType, string locationValue)
{
this.LocationType = locationType;
this.LocationValue = locationValue;
}
/// <summary>
/// The type of location.
/// </summary>
public PurviewLocationType LocationType { get; set; }
/// <summary>
/// The location value.
/// </summary>
public string LocationValue { get; set; }
/// <summary>
/// Returns the <see cref="PolicyLocation"/> model for this <see cref="PurviewAppLocation"/>.
/// </summary>
/// <returns>PolicyLocation request model.</returns>
/// <exception cref="InvalidOperationException">Thrown when an invalid location type is provided.</exception>
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.");
}
}
}
@@ -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;
/// <summary>
/// A middleware chat client that connects to Microsoft Purview.
/// </summary>
internal class PurviewChatClient : IChatClient
{
private readonly IChatClient _innerChatClient;
private readonly PurviewWrapper _purviewWrapper;
/// <summary>
/// Initializes a new instance of the <see cref="PurviewChatClient"/> class.
/// </summary>
/// <param name="innerChatClient">The inner chat client to wrap.</param>
/// <param name="purviewWrapper">The purview wrapper used to interact with the Purview service.</param>
public PurviewChatClient(IChatClient innerChatClient, PurviewWrapper purviewWrapper)
{
this._innerChatClient = innerChatClient;
this._purviewWrapper = purviewWrapper;
}
/// <inheritdoc/>
public void Dispose()
{
this._purviewWrapper.Dispose();
this._innerChatClient.Dispose();
}
/// <inheritdoc/>
public Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
{
return this._purviewWrapper.ProcessChatContentAsync(messages, options, this._innerChatClient, cancellationToken);
}
/// <inheritdoc/>
public object? GetService(Type serviceType, object? serviceKey = null)
{
return this._innerChatClient.GetService(serviceType, serviceKey);
}
/// <inheritdoc/>
public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
Task<ChatResponse> responseTask = this._purviewWrapper.ProcessChatContentAsync(messages, options, this._innerChatClient, cancellationToken);
foreach (var update in (await responseTask.ConfigureAwait(false)).ToChatResponseUpdates())
{
yield return update;
}
}
}
@@ -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;
/// <summary>
/// Client for calling Purview APIs.
/// </summary>
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);
}
}
/// <summary>
/// Creates a new <see cref="PurviewClient"/> instance.
/// </summary>
/// <param name="tokenCredential">The token credential used to authenticate with Purview.</param>
/// <param name="purviewSettings">The settings used for purview requests.</param>
/// <param name="httpClient">The HttpClient used to make network requests to Purview.</param>
/// <param name="logger">The logger used to log information from the middleware.</param>
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
};
}
/// <inheritdoc/>
public async Task<TokenInfo> 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);
}
/// <inheritdoc/>
public async Task<ProcessContentResponse> 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<ProcessContentResponse> typeInfo = (JsonTypeInfo<ProcessContentResponse>)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");
}
}
/// <inheritdoc/>
public async Task<ProtectionScopesResponse> 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<ProtectionScopesResponse> typeInfo = (JsonTypeInfo<ProtectionScopesResponse>)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");
}
}
/// <inheritdoc/>
public async Task<ContentActivitiesResponse> 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<ContentActivitiesResponse> typeInfo = (JsonTypeInfo<ContentActivitiesResponse>)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");
}
}
}
@@ -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;
/// <summary>
/// Extension methods to add Purview capabilities to an <see cref="AIAgent"/>.
/// </summary>
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<IPurviewClient, PurviewClient>();
services.AddSingleton<IScopedContentProcessor, ScopedContentProcessor>();
services.AddSingleton(distributedCache);
services.AddSingleton<ICacheProvider, CacheProvider>();
services.AddSingleton<HttpClient>();
services.AddSingleton(logger ?? NullLogger.Instance);
services.AddSingleton<PurviewWrapper>();
services.AddSingleton(Channel.CreateBounded<BackgroundJobBase>(purviewSettings.PendingBackgroundJobLimit));
services.AddSingleton<IChannelHandler, ChannelHandler>();
services.AddSingleton<BackgroundJobRunner>();
ServiceProvider serviceProvider = services.BuildServiceProvider();
return serviceProvider.GetRequiredService<PurviewWrapper>();
}
/// <summary>
/// Adds Purview capabilities to an <see cref="AIAgentBuilder"/>.
/// </summary>
/// <param name="builder">The AI Agent builder for the <see cref="AIAgent"/>.</param>
/// <param name="tokenCredential">The token credential used to authenticate with Purview.</param>
/// <param name="purviewSettings">The settings for communication with Purview.</param>
/// <param name="logger">The logger to use for logging.</param>
/// <param name="cache">The distributed cache to use for caching Purview responses. An in memory cache will be used if this is null.</param>
/// <returns>The updated <see cref="AIAgentBuilder"/></returns>
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));
}
/// <summary>
/// Adds Purview capabilities to a <see cref="ChatClientBuilder"/>.
/// </summary>
/// <param name="builder">The chat client builder for the <see cref="IChatClient"/>.</param>
/// <param name="tokenCredential">The token credential used to authenticate with Purview.</param>
/// <param name="purviewSettings">The settings for communication with Purview.</param>
/// <param name="logger">The logger to use for logging.</param>
/// <param name="cache">The distributed cache to use for caching Purview responses. An in memory cache will be used if this is null.</param>
/// <returns>The updated <see cref="ChatClientBuilder"/></returns>
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));
}
/// <summary>
/// Creates a Purview middleware function for use with a <see cref="IChatClient"/>.
/// </summary>
/// <param name="tokenCredential">The token credential used to authenticate with Purview.</param>
/// <param name="purviewSettings">The settings for communication with Purview.</param>
/// <param name="logger">The logger to use for logging.</param>
/// <param name="cache">The distributed cache to use for caching Purview responses. An in memory cache will be used if this is null.</param>
/// <returns>A chat middleware delegate.</returns>
public static Func<IChatClient, IChatClient> PurviewChatMiddleware(TokenCredential tokenCredential, PurviewSettings purviewSettings, ILogger? logger = null, IDistributedCache? cache = null)
{
PurviewWrapper purviewWrapper = CreateWrapper(tokenCredential, purviewSettings, logger, cache);
return (innerChatClient) => new PurviewChatClient(innerChatClient, purviewWrapper);
}
/// <summary>
/// Creates a Purview middleware function for use with an <see cref="AIAgent"/>.
/// </summary>
/// <param name="tokenCredential">The token credential used to authenticate with Purview.</param>
/// <param name="purviewSettings">The settings for communication with Purview.</param>
/// <param name="logger">The logger to use for logging.</param>
/// <param name="cache">The distributed cache to use for caching Purview responses. An in memory cache will be used if this is null.</param>
/// <returns>An agent middleware delegate.</returns>
public static Func<AIAgent, AIAgent> PurviewAgentMiddleware(TokenCredential tokenCredential, PurviewSettings purviewSettings, ILogger? logger = null, IDistributedCache? cache = null)
{
PurviewWrapper purviewWrapper = CreateWrapper(tokenCredential, purviewSettings, logger, cache);
return (innerAgent) => new PurviewAgent(innerAgent, purviewWrapper);
}
/// <summary>
/// Sets the user id for a message.
/// </summary>
/// <param name="message">The message.</param>
/// <param name="userId">The id of the owner of the message.</param>
public static void SetUserId(this ChatMessage message, Guid userId)
{
if (message.AdditionalProperties == null)
{
message.AdditionalProperties = new AdditionalPropertiesDictionary();
}
message.AdditionalProperties[Constants.UserId] = userId.ToString();
}
}
@@ -0,0 +1,24 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.Purview;
/// <summary>
/// The type of location for Purview policy evaluation.
/// </summary>
public enum PurviewLocationType
{
/// <summary>
/// An application location.
/// </summary>
Application,
/// <summary>
/// A URI location.
/// </summary>
Uri,
/// <summary>
/// A domain name location.
/// </summary>
Domain
}
@@ -0,0 +1,88 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
namespace Microsoft.Agents.AI.Purview;
/// <summary>
/// Represents the configuration settings for a Purview application, including tenant information, application name, and
/// optional default user settings.
/// </summary>
/// <remarks>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.</remarks>
public class PurviewSettings
{
/// <summary>
/// Initializes a new instance of the <see cref="PurviewSettings"/> class.
/// </summary>
/// <param name="appName">The publicly visible name of the application.</param>
public PurviewSettings(string appName)
{
this.AppName = appName;
}
/// <summary>
/// The publicly visible app name of the application.
/// </summary>
public string AppName { get; set; }
/// <summary>
/// The version string of the application.
/// </summary>
public string? AppVersion { get; set; }
/// <summary>
/// The tenant id of the user making the request.
/// If this is not provided, the tenant id will be inferred from the token.
/// </summary>
public string? TenantId { get; set; }
/// <summary>
/// Gets or sets the location of the Purview resource.
/// If this is not provided, a location containing the client id will be used instead.
/// </summary>
public PurviewAppLocation? PurviewAppLocation { get; set; }
/// <summary>
/// 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.
/// </summary>
public bool IgnoreExceptions { get; set; }
/// <summary>
/// Gets or sets the base URI for the Microsoft Graph API.
/// Set to graph v1.0 by default.
/// </summary>
public Uri GraphBaseUri { get; set; } = new Uri("https://graph.microsoft.com/v1.0/");
/// <summary>
/// Gets or sets the message to display when a prompt is blocked by Purview policies.
/// </summary>
public string BlockedPromptMessage { get; set; } = "Prompt blocked by policies";
/// <summary>
/// Gets or sets the message to display when a response is blocked by Purview policies.
/// </summary>
public string BlockedResponseMessage { get; set; } = "Response blocked by policies";
/// <summary>
/// The size limit of the default in memory cache in bytes. This only applies if no cache is provided when creating Purview resources.
/// </summary>
public long? InMemoryCacheSizeLimit { get; set; } = 100_000_000;
/// <summary>
/// The TTL of each cache entry.
/// </summary>
public TimeSpan CacheTTL { get; set; } = TimeSpan.FromMinutes(30);
/// <summary>
/// The maximum number of background jobs that can be queued up.
/// </summary>
public int PendingBackgroundJobLimit { get; set; } = 100;
/// <summary>
/// The maximum number of concurrent job consumers.
/// </summary>
public int MaxConcurrentJobConsumers { get; set; } = 10;
}
@@ -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;
/// <summary>
/// A delegating agent that connects to Microsoft Purview.
/// </summary>
internal sealed class PurviewWrapper : IDisposable
{
private readonly ILogger _logger;
private readonly IScopedContentProcessor _scopedProcessor;
private readonly PurviewSettings _purviewSettings;
private readonly IChannelHandler _channelHandler;
/// <summary>
/// Creates a new <see cref="PurviewWrapper"/> instance.
/// </summary>
/// <param name="scopedProcessor">The scoped processor used to orchestrate the calls to Purview.</param>
/// <param name="purviewSettings">The settings for Purview integration.</param>
/// <param name="logger">The logger used for logging.</param>
/// <param name="channelHandler">The channel handler used to queue background jobs and add job runners.</param>
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<ChatMessage> 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();
}
/// <summary>
/// Processes a prompt and response exchange at a chat client level.
/// </summary>
/// <param name="messages">The messages sent to the chat client.</param>
/// <param name="options">The chat options used with the chat client.</param>
/// <param name="innerChatClient">The wrapped chat client.</param>
/// <param name="cancellationToken">The cancellation token used to interrupt async operations.</param>
/// <returns>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.</returns>
public async Task<ChatResponse> ProcessChatContentAsync(IEnumerable<ChatMessage> 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;
}
/// <summary>
/// Processes a prompt and response exchange at an agent level.
/// </summary>
/// <param name="messages">The messages sent to the agent.</param>
/// <param name="thread">The thread used for this agent conversation.</param>
/// <param name="options">The options used with this agent.</param>
/// <param name="innerAgent">The wrapped agent.</param>
/// <param name="cancellationToken">The cancellation token used to interrupt async operations.</param>
/// <returns>The agent's response. This could be the response from the agent or a message indicating that Purview has blocked the prompt or response.</returns>
public async Task<AgentRunResponse> ProcessAgentContentAsync(IEnumerable<ChatMessage> 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;
}
/// <inheritdoc/>
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.
}
}
@@ -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"] = "<your-entra-user-id-here>";
// Or with the extension method
var message = new ChatMessage(ChatRole.User, promptText);
message.SetUserId(new Guid("<your-entra-user-id-here>"));
```
### 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.")
}
```
@@ -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;
/// <summary>
/// Processor class that combines protectionScopes, processContent, and contentActivities calls.
/// </summary>
internal sealed class ScopedContentProcessor : IScopedContentProcessor
{
private readonly IPurviewClient _purviewClient;
private readonly ICacheProvider _cacheProvider;
private readonly IChannelHandler _channelHandler;
/// <summary>
/// Create a new instance of <see cref="ScopedContentProcessor"/>.
/// </summary>
/// <param name="purviewClient">The purview client to use for purview requests.</param>
/// <param name="cacheProvider">The cache used to store Purview data.</param>
/// <param name="channelHandler">The channel handler used to manage background jobs.</param>
public ScopedContentProcessor(IPurviewClient purviewClient, ICacheProvider cacheProvider, IChannelHandler channelHandler)
{
this._purviewClient = purviewClient;
this._cacheProvider = cacheProvider;
this._channelHandler = channelHandler;
}
/// <inheritdoc/>
public async Task<(bool shouldBlock, string? userId)> ProcessMessagesAsync(IEnumerable<ChatMessage> messages, string? threadId, Activity activity, PurviewSettings purviewSettings, string? userId, CancellationToken cancellationToken)
{
List<ProcessContentRequest> 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<ChatMessage> 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;
}
/// <summary>
/// Transform a list of ChatMessages into a list of ProcessContentRequests.
/// </summary>
/// <param name="messages">The messages to transform.</param>
/// <param name="threadId">The id of the message thread.</param>
/// <param name="activity">The activity performed on the content.</param>
/// <param name="settings">The settings used for purview integration.</param>
/// <param name="userId">The entra id of the user who made the interaction.</param>
/// <param name="cancellationToken">The cancellation token used to cancel async operations.</param>
/// <returns>A list of process content requests.</returns>
private async Task<List<ProcessContentRequest>> MapMessageToPCRequestsAsync(IEnumerable<ChatMessage> messages, string? threadId, Activity activity, PurviewSettings settings, string? userId, CancellationToken cancellationToken)
{
List<ProcessContentRequest> 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<ProcessContentMetadataBase> { 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;
}
/// <summary>
/// Orchestrates process content and protection scopes calls.
/// </summary>
/// <param name="pcRequest">The process content request.</param>
/// <param name="cancellationToken">The cancellation token used to cancel async operations.</param>
/// <returns>A process content response. This could be a response from the process content API or a response generated from a content activities call.</returns>
private async Task<ProcessContentResponse> 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<ProtectionScopesCacheKey, ProtectionScopesResponse>(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<DlpActionInfo> 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();
}
/// <summary>
/// Dedupe policy actions received from the service.
/// </summary>
/// <param name="pcResponse">The process content response which may contain DLP actions.</param>
/// <param name="actionInfos">DLP actions returned from protection scopes.</param>
/// <returns>The process content response with the protection scopes DLP actions added. Actions are deduplicated.</returns>
private static ProcessContentResponse CombinePolicyActions(ProcessContentResponse pcResponse, List<DlpActionInfo>? actionInfos)
{
if (actionInfos == null || actionInfos.Count == 0)
{
return pcResponse;
}
if (pcResponse.PolicyActions == null)
{
pcResponse.PolicyActions = actionInfos;
return pcResponse;
}
List<DlpActionInfo> pcActionInfos = new(pcResponse.PolicyActions);
pcActionInfos.AddRange(actionInfos);
pcResponse.PolicyActions = pcActionInfos;
return pcResponse;
}
/// <summary>
/// Check if any scopes are applicable to the request.
/// </summary>
/// <param name="pcRequest">The process content request.</param>
/// <param name="psResponse">The protection scopes response that was returned for the process content request.</param>
/// <returns>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.</returns>
private static (bool shouldProcess, List<DlpActionInfo> 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<DlpActionInfo> dlpActions = new();
bool shouldProcess = false;
ExecutionMode executionMode = ExecutionMode.EvaluateOffline;
foreach (var scope in psResponse.Scopes ?? Array.Empty<PolicyScopeBase>())
{
bool activityMatch = scope.Activities.HasFlag(requestActivity);
bool locationMatch = false;
foreach (var location in scope.Locations ?? Array.Empty<PolicyLocation>())
{
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);
}
/// <summary>
/// Create a ProtectionScopesRequest for the given content ProcessContentRequest.
/// </summary>
/// <param name="pcRequest">The process content request.</param>
/// <param name="userId">The entra user id of the user who sent the data.</param>
/// <param name="tenantId">The tenant id of the user who sent the data.</param>
/// <param name="correlationId">The correlation id of the request.</param>
/// <returns>The protection scopes request generated from the process content request.</returns>
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<PolicyLocation> { pcRequest.ContentToProcess.ProtectedAppMetadata.ApplicationLocation },
DeviceMetadata = pcRequest.ContentToProcess.DeviceMetadata,
IntegratedAppMetadata = pcRequest.ContentToProcess.IntegratedAppMetadata,
CorrelationId = correlationId
};
}
/// <summary>
/// Map process content activity to protection scope activity.
/// </summary>
/// <param name="activity">The process content activity.</param>
/// <returns>The protection scopes activity.</returns>
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;
}
}
}
@@ -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;
/// <summary>
/// Source generation context for Purview serialization.
/// </summary>
[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;
/// <summary>
/// Utility class for Purview serialization settings.
/// </summary>
internal static class PurviewSerializationUtils
{
/// <summary>
/// Serialization settings for Purview.
/// </summary>
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,
};
}
@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>$(ProjectsTargetFrameworks)</TargetFrameworks>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Purview\Microsoft.Agents.AI.Purview.csproj" />
</ItemGroup>
</Project>
@@ -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;
/// <summary>
/// Unit tests for the <see cref="PurviewClient"/> class.
/// </summary>
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<DlpActionInfo>
{
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<PurviewRateLimitException>(() =>
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<PurviewAuthenticationException>(() =>
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<PurviewAuthenticationException>(() =>
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<PurviewPaymentRequiredException>(() =>
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<PurviewRequestException>(() =>
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<PurviewRequestException>(() =>
this._client.ProcessContentAsync(request, CancellationToken.None));
Assert.Contains("Failed to deserialize ProcessContent response", exception.Message);
Assert.NotNull(exception.InnerException);
Assert.IsType<JsonException>(exception.InnerException);
}
[Fact]
public async Task ProcessContentAsync_WithHttpRequestException_ThrowsPurviewRequestExceptionAsync()
{
// Arrange
var request = CreateValidProcessContentRequest();
this._handler.ShouldThrowHttpRequestException = true;
// Act & Assert
var exception = await Assert.ThrowsAsync<PurviewRequestException>(() =>
this._client.ProcessContentAsync(request, CancellationToken.None));
Assert.Equal("Http error occurred while processing content.", exception.Message);
Assert.NotNull(exception.InnerException);
Assert.IsType<HttpRequestException>(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<PolicyLocation>
{
new("microsoft.graph.policyLocationApplication", "app-123")
}
};
var expectedResponse = new ProtectionScopesResponse
{
Scopes = new List<PolicyScopeBase>
{
new()
{
Activities = ProtectionScopeActivities.UploadText,
Locations = new List<PolicyLocation>
{
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<PolicyScopeBase>() };
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<PurviewRateLimitException>(() =>
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<PurviewAuthenticationException>(() =>
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<PurviewRequestException>(() =>
this._client.GetProtectionScopesAsync(request, CancellationToken.None));
Assert.Contains("Failed to deserialize ProtectionScopes response", exception.Message);
Assert.NotNull(exception.InnerException);
Assert.IsType<JsonException>(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<PurviewRequestException>(() =>
this._client.GetProtectionScopesAsync(request, CancellationToken.None));
Assert.Equal("Http error occurred while retrieving protection scopes.", exception.Message);
Assert.NotNull(exception.InnerException);
Assert.IsType<HttpRequestException>(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<PurviewRateLimitException>(() =>
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<PurviewAuthenticationException>(() =>
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<PurviewRequestException>(() =>
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<PurviewRequestException>(() =>
this._client.SendContentActivitiesAsync(request, CancellationToken.None));
Assert.Contains("Failed to deserialize ContentActivities response", exception.Message);
Assert.NotNull(exception.InnerException);
Assert.IsType<JsonException>(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<PurviewRequestException>(() =>
this._client.SendContentActivitiesAsync(request, CancellationToken.None));
Assert.Equal("Http error occurred while creating content activities.", exception.Message);
Assert.NotNull(exception.InnerException);
Assert.IsType<HttpRequestException>(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<ProcessContentMetadataBase> { metadata },
activityMetadata,
deviceMetadata,
integratedAppMetadata,
protectedAppMetadata
);
}
#endregion
public void Dispose()
{
this._handler.Dispose();
this._httpClient.Dispose();
}
/// <summary>
/// Mock HTTP message handler for testing
/// </summary>
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<HttpResponseMessage> 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);
}
}
/// <summary>
/// Mock token credential for testing
/// </summary>
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<AccessToken> GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
{
return new ValueTask<AccessToken>(new AccessToken("mock-token", DateTimeOffset.UtcNow.AddHours(1)));
}
}
}
@@ -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;
/// <summary>
/// Unit tests for the <see cref="PurviewWrapper"/> class.
/// </summary>
public sealed class PurviewWrapperTests : IDisposable
{
private readonly Mock<IScopedContentProcessor> _mockProcessor;
private readonly IChannelHandler _channelHandler;
private readonly PurviewSettings _settings;
private readonly PurviewWrapper _wrapper;
public PurviewWrapperTests()
{
this._mockProcessor = new Mock<IScopedContentProcessor>();
this._channelHandler = Mock.Of<IChannelHandler>();
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<ChatMessage>
{
new(ChatRole.User, "Sensitive content that should be blocked")
};
var mockChatClient = new Mock<IChatClient>();
this._mockProcessor.Setup(x => x.ProcessMessagesAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<string>(),
It.IsAny<Activity>(),
It.IsAny<PurviewSettings>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.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<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task ProcessChatContentAsync_WithAllowedPromptAndBlockedResponse_ReturnsBlockedMessageAsync()
{
// Arrange
var messages = new List<ChatMessage>
{
new(ChatRole.User, "Test message")
};
var mockChatClient = new Mock<IChatClient>();
var innerResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Sensitive response"));
mockChatClient.Setup(x => x.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(innerResponse);
this._mockProcessor.SetupSequence(x => x.ProcessMessagesAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<string>(),
It.IsAny<Activity>(),
It.IsAny<PurviewSettings>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.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<ChatMessage>
{
new(ChatRole.User, "Test message")
};
var mockChatClient = new Mock<IChatClient>();
var innerResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Safe response"));
mockChatClient.Setup(x => x.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(innerResponse);
this._mockProcessor.Setup(x => x.ProcessMessagesAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<string>(),
It.IsAny<Activity>(),
It.IsAny<PurviewSettings>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.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<ChatMessage>
{
new(ChatRole.User, "Test message")
};
var expectedResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Response from inner client"));
var mockChatClient = new Mock<IChatClient>();
mockChatClient.Setup(x => x.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedResponse);
this._mockProcessor.SetupSequence(x => x.ProcessMessagesAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<string>(),
It.IsAny<Activity>(),
It.IsAny<PurviewSettings>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.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<ChatMessage>
{
new(ChatRole.User, "Test message")
};
var mockChatClient = new Mock<IChatClient>();
this._mockProcessor.Setup(x => x.ProcessMessagesAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<string>(),
It.IsAny<Activity>(),
It.IsAny<PurviewSettings>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new PurviewRequestException("Prompt processing error"));
// Act & Assert
await Assert.ThrowsAsync<PurviewRequestException>(() =>
this._wrapper.ProcessChatContentAsync(messages, null, mockChatClient.Object, CancellationToken.None));
}
[Fact]
public async Task ProcessChatContentAsync_UsesConversationIdFromOptions_Async()
{
// Arrange
var messages = new List<ChatMessage>
{
new(ChatRole.User, "Test message")
};
var options = new ChatOptions { ConversationId = "conversation-123" };
var mockChatClient = new Mock<IChatClient>();
var innerResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Response"));
mockChatClient.Setup(x => x.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(innerResponse);
this._mockProcessor.Setup(x => x.ProcessMessagesAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
"conversation-123",
It.IsAny<Activity>(),
It.IsAny<PurviewSettings>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync((false, "user-123"));
// Act
await this._wrapper.ProcessChatContentAsync(messages, options, mockChatClient.Object, CancellationToken.None);
// Assert
this._mockProcessor.Verify(x => x.ProcessMessagesAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
"conversation-123",
It.IsAny<Activity>(),
It.IsAny<PurviewSettings>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()), Times.Exactly(2));
}
#endregion
#region ProcessAgentContentAsync Tests
[Fact]
public async Task ProcessAgentContentAsync_WithBlockedPrompt_ReturnsBlockedMessageAsync()
{
// Arrange
var messages = new List<ChatMessage>
{
new(ChatRole.User, "Sensitive content")
};
var mockAgent = new Mock<AIAgent>();
this._mockProcessor.Setup(x => x.ProcessMessagesAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<string>(),
It.IsAny<Activity>(),
It.IsAny<PurviewSettings>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.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<IEnumerable<ChatMessage>>(),
It.IsAny<AgentThread>(),
It.IsAny<AgentRunOptions>(),
It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task ProcessAgentContentAsync_WithAllowedPromptAndBlockedResponse_ReturnsBlockedMessageAsync()
{
// Arrange
var messages = new List<ChatMessage>
{
new(ChatRole.User, "Test message")
};
var mockAgent = new Mock<AIAgent>();
var innerResponse = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Sensitive response"));
mockAgent.Setup(x => x.RunAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<AgentThread>(),
It.IsAny<AgentRunOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(innerResponse);
this._mockProcessor.SetupSequence(x => x.ProcessMessagesAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<string>(),
It.IsAny<Activity>(),
It.IsAny<PurviewSettings>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.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<ChatMessage>
{
new(ChatRole.User, "Test message")
};
var mockAgent = new Mock<AIAgent>();
var innerResponse = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Safe response"));
mockAgent.Setup(x => x.RunAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<AgentThread>(),
It.IsAny<AgentRunOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(innerResponse);
this._mockProcessor.Setup(x => x.ProcessMessagesAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<string>(),
It.IsAny<Activity>(),
It.IsAny<PurviewSettings>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.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<ChatMessage>
{
new(ChatRole.User, "Test message")
};
var expectedResponse = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Response from inner agent"));
var mockAgent = new Mock<AIAgent>();
mockAgent.Setup(x => x.RunAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<AgentThread>(),
It.IsAny<AgentRunOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedResponse);
this._mockProcessor.SetupSequence(x => x.ProcessMessagesAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<string>(),
It.IsAny<Activity>(),
It.IsAny<PurviewSettings>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new PurviewRequestException("Prompt processing error"))
.ReturnsAsync((false, "user-123")); // Response processing succeeds
// Act
var result = await wrapper.ProcessAgentContentAsync(messages, null, null, mockAgent.Object, CancellationToken.None);
// Assert
Assert.NotNull(result);
Assert.Same(expectedResponse, result);
}
[Fact]
public async Task ProcessAgentContentAsync_WithoutIgnoreExceptions_ThrowsOnErrorAsync()
{
// Arrange
var messages = new List<ChatMessage>
{
new(ChatRole.User, "Test message")
};
var mockAgent = new Mock<AIAgent>();
this._mockProcessor.Setup(x => x.ProcessMessagesAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<string>(),
It.IsAny<Activity>(),
It.IsAny<PurviewSettings>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new PurviewRequestException("Processing error"));
// Act & Assert
await Assert.ThrowsAsync<PurviewRequestException>(() =>
this._wrapper.ProcessAgentContentAsync(messages, null, null, mockAgent.Object, CancellationToken.None));
}
[Fact]
public async Task ProcessAgentContentAsync_ExtractsThreadIdFromMessageAdditionalProperties_Async()
{
// Arrange
var messages = new List<ChatMessage>
{
new(ChatRole.User, "Test message")
{
AdditionalProperties = new AdditionalPropertiesDictionary
{
{ "conversationId", "conversation-from-props" }
}
}
};
var expectedResponse = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Response"));
var mockAgent = new Mock<AIAgent>();
mockAgent.Setup(x => x.RunAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<AgentThread>(),
It.IsAny<AgentRunOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedResponse);
this._mockProcessor.Setup(x => x.ProcessMessagesAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
"conversation-from-props",
It.IsAny<Activity>(),
It.IsAny<PurviewSettings>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync((false, "user-123"));
// Act
var result = await this._wrapper.ProcessAgentContentAsync(messages, null, null, mockAgent.Object, CancellationToken.None);
// Assert
Assert.NotNull(result);
this._mockProcessor.Verify(x => x.ProcessMessagesAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
"conversation-from-props",
It.IsAny<Activity>(),
It.IsAny<PurviewSettings>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()), Times.Exactly(2));
}
[Fact]
public async Task ProcessAgentContentAsync_GeneratesThreadId_WhenNotProvidedAsync()
{
// Arrange
var messages = new List<ChatMessage>
{
new(ChatRole.User, "Test message")
};
var expectedResponse = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Response"));
var mockAgent = new Mock<AIAgent>();
mockAgent.Setup(x => x.RunAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<AgentThread>(),
It.IsAny<AgentRunOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedResponse);
string? capturedThreadId = null;
this._mockProcessor.Setup(x => x.ProcessMessagesAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<string>(),
It.IsAny<Activity>(),
It.IsAny<PurviewSettings>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, string, Activity, PurviewSettings, string, CancellationToken>(
(_, threadId, _, _, _, _) => capturedThreadId = threadId)
.ReturnsAsync((false, "user-123"));
// Act
var result = await this._wrapper.ProcessAgentContentAsync(messages, null, null, mockAgent.Object, CancellationToken.None);
// Assert
Assert.NotNull(result);
Assert.NotNull(capturedThreadId);
Assert.True(Guid.TryParse(capturedThreadId, out _), "Generated thread ID should be a valid GUID");
}
[Fact]
public async Task ProcessAgentContentAsync_PassesResolvedUserId_ToResponseProcessingAsync()
{
// Arrange
var messages = new List<ChatMessage>
{
new(ChatRole.User, "Test message")
};
var mockAgent = new Mock<AIAgent>();
var innerResponse = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Response"));
mockAgent.Setup(x => x.RunAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<AgentThread>(),
It.IsAny<AgentRunOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(innerResponse);
var callCount = 0;
string? firstCallUserId = null;
string? secondCallUserId = null;
this._mockProcessor.Setup(x => x.ProcessMessagesAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<string>(),
It.IsAny<Activity>(),
It.IsAny<PurviewSettings>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, string, Activity, PurviewSettings, string, CancellationToken>(
(_, _, _, _, userId, _) =>
{
if (callCount == 0)
{
firstCallUserId = userId;
}
else if (callCount == 1)
{
secondCallUserId = userId;
}
callCount++;
})
.ReturnsAsync((false, "resolved-user-456"));
// Act
await this._wrapper.ProcessAgentContentAsync(messages, null, null, mockAgent.Object, CancellationToken.None);
// Assert
Assert.Null(firstCallUserId); // First call (prompt) should have null userId
Assert.Equal("resolved-user-456", secondCallUserId); // Second call (response) should have resolved userId from first call
}
#endregion
public void Dispose()
{
this._wrapper.Dispose();
}
}
@@ -0,0 +1,501 @@
// 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;
using Moq;
namespace Microsoft.Agents.AI.Purview.UnitTests;
/// <summary>
/// Unit tests for the <see cref="ScopedContentProcessor"/> class.
/// </summary>
public sealed class ScopedContentProcessorTests
{
private readonly Mock<IPurviewClient> _mockPurviewClient;
private readonly Mock<ICacheProvider> _mockCacheProvider;
private readonly Mock<IChannelHandler> _mockChannelHandler;
private readonly ScopedContentProcessor _processor;
public ScopedContentProcessorTests()
{
this._mockPurviewClient = new Mock<IPurviewClient>();
this._mockCacheProvider = new Mock<ICacheProvider>();
this._mockChannelHandler = new Mock<IChannelHandler>();
this._processor = new ScopedContentProcessor(
this._mockPurviewClient.Object,
this._mockCacheProvider.Object,
this._mockChannelHandler.Object);
}
#region ProcessMessagesAsync Tests
[Fact]
public async Task ProcessMessagesAsync_WithBlockAccessAction_ReturnsShouldBlockTrueAsync()
{
// Arrange
var messages = new List<ChatMessage>
{
new (ChatRole.User, "Test message")
};
var settings = CreateValidPurviewSettings();
var tokenInfo = new TokenInfo { TenantId = "tenant-123", UserId = "user-123", ClientId = "client-123" };
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
.ReturnsAsync(tokenInfo);
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((ProtectionScopesResponse?)null);
var psResponse = new ProtectionScopesResponse
{
Scopes = new List<PolicyScopeBase>
{
new()
{
Activities = ProtectionScopeActivities.UploadText,
Locations = new List<PolicyLocation>
{
new ("microsoft.graph.policyLocationApplication", "app-123")
},
ExecutionMode = ExecutionMode.EvaluateInline
}
}
};
this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync(
It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(psResponse);
var pcResponse = new ProcessContentResponse
{
PolicyActions = new List<DlpActionInfo>
{
new() { Action = DlpAction.BlockAccess }
}
};
this._mockPurviewClient.Setup(x => x.ProcessContentAsync(
It.IsAny<ProcessContentRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(pcResponse);
// Act
var result = await this._processor.ProcessMessagesAsync(
messages, "thread-123", Activity.UploadText, settings, "user-123", CancellationToken.None);
// Assert
Assert.True(result.shouldBlock);
Assert.Equal("user-123", result.userId);
}
[Fact]
public async Task ProcessMessagesAsync_WithRestrictionActionBlock_ReturnsShouldBlockTrueAsync()
{
// Arrange
var messages = new List<ChatMessage>
{
new (ChatRole.User, "Test message")
};
var settings = CreateValidPurviewSettings();
var tokenInfo = new TokenInfo { TenantId = "tenant-123", UserId = "user-123", ClientId = "client-123" };
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
.ReturnsAsync(tokenInfo);
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((ProtectionScopesResponse?)null);
var psResponse = new ProtectionScopesResponse
{
Scopes = new List<PolicyScopeBase>
{
new()
{
Activities = ProtectionScopeActivities.UploadText,
Locations = new List<PolicyLocation>
{
new ("microsoft.graph.policyLocationApplication", "app-123")
},
ExecutionMode = ExecutionMode.EvaluateInline
}
}
};
this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync(
It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(psResponse);
var pcResponse = new ProcessContentResponse
{
PolicyActions = new List<DlpActionInfo>
{
new() { RestrictionAction = RestrictionAction.Block }
}
};
this._mockPurviewClient.Setup(x => x.ProcessContentAsync(
It.IsAny<ProcessContentRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(pcResponse);
// Act
var result = await this._processor.ProcessMessagesAsync(
messages, "thread-123", Activity.UploadText, settings, "user-123", CancellationToken.None);
// Assert
Assert.True(result.shouldBlock);
Assert.Equal("user-123", result.userId);
}
[Fact]
public async Task ProcessMessagesAsync_WithNoBlockingActions_ReturnsShouldBlockFalseAsync()
{
// Arrange
var messages = new List<ChatMessage>
{
new (ChatRole.User, "Test message")
};
var settings = CreateValidPurviewSettings();
var tokenInfo = new TokenInfo { TenantId = "tenant-123", UserId = "user-123", ClientId = "client-123" };
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
.ReturnsAsync(tokenInfo);
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((ProtectionScopesResponse?)null);
var psResponse = new ProtectionScopesResponse
{
Scopes = new List<PolicyScopeBase>
{
new()
{
Activities = ProtectionScopeActivities.UploadText,
Locations = new List<PolicyLocation>
{
new("microsoft.graph.policyLocationApplication", "app-123")
},
ExecutionMode = ExecutionMode.EvaluateInline
}
}
};
this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync(
It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(psResponse);
var pcResponse = new ProcessContentResponse
{
PolicyActions = new List<DlpActionInfo>
{
new() { Action = DlpAction.NotifyUser }
}
};
this._mockPurviewClient.Setup(x => x.ProcessContentAsync(
It.IsAny<ProcessContentRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(pcResponse);
// Act
var result = await this._processor.ProcessMessagesAsync(
messages, "thread-123", Activity.UploadText, settings, "user-123", CancellationToken.None);
// Assert
Assert.False(result.shouldBlock);
Assert.Equal("user-123", result.userId);
}
[Fact]
public async Task ProcessMessagesAsync_UsesCachedProtectionScopes_WhenAvailableAsync()
{
// Arrange
var messages = new List<ChatMessage>
{
new (ChatRole.User, "Test message")
};
var settings = CreateValidPurviewSettings();
var tokenInfo = new TokenInfo { TenantId = "tenant-123", UserId = "user-123", ClientId = "client-123" };
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
.ReturnsAsync(tokenInfo);
var cachedPsResponse = new ProtectionScopesResponse
{
Scopes = new List<PolicyScopeBase>
{
new()
{
Activities = ProtectionScopeActivities.UploadText,
Locations = new List<PolicyLocation>
{
new ("microsoft.graph.policyLocationApplication", "app-123")
},
ExecutionMode = ExecutionMode.EvaluateInline
}
}
};
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(cachedPsResponse);
var pcResponse = new ProcessContentResponse
{
PolicyActions = new List<DlpActionInfo>()
};
this._mockPurviewClient.Setup(x => x.ProcessContentAsync(
It.IsAny<ProcessContentRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(pcResponse);
// Act
await this._processor.ProcessMessagesAsync(
messages, "thread-123", Activity.UploadText, settings, "user-123", CancellationToken.None);
// Assert
this._mockPurviewClient.Verify(x => x.GetProtectionScopesAsync(
It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task ProcessMessagesAsync_InvalidatesCache_WhenProtectionScopeModifiedAsync()
{
// Arrange
var messages = new List<ChatMessage>
{
new (ChatRole.User, "Test message")
};
var settings = CreateValidPurviewSettings();
var tokenInfo = new TokenInfo { TenantId = "tenant-123", UserId = "user-123", ClientId = "client-123" };
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
.ReturnsAsync(tokenInfo);
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((ProtectionScopesResponse?)null);
var psResponse = new ProtectionScopesResponse
{
Scopes = new List<PolicyScopeBase>
{
new()
{
Activities = ProtectionScopeActivities.UploadText,
Locations = new List<PolicyLocation>
{
new ("microsoft.graph.policyLocationApplication", "app-123")
},
ExecutionMode = ExecutionMode.EvaluateInline
}
}
};
this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync(
It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(psResponse);
var pcResponse = new ProcessContentResponse
{
ProtectionScopeState = ProtectionScopeState.Modified,
PolicyActions = new List<DlpActionInfo>()
};
this._mockPurviewClient.Setup(x => x.ProcessContentAsync(
It.IsAny<ProcessContentRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(pcResponse);
// Act
await this._processor.ProcessMessagesAsync(
messages, "thread-123", Activity.UploadText, settings, "user-123", CancellationToken.None);
// Assert
this._mockCacheProvider.Verify(x => x.RemoveAsync(
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task ProcessMessagesAsync_SendsContentActivities_WhenNoApplicableScopesAsync()
{
// Arrange
var messages = new List<ChatMessage>
{
new (ChatRole.User, "Test message")
};
var settings = CreateValidPurviewSettings();
var tokenInfo = new TokenInfo { TenantId = "tenant-123", UserId = "user-123", ClientId = "client-123" };
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
.ReturnsAsync(tokenInfo);
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((ProtectionScopesResponse?)null);
var psResponse = new ProtectionScopesResponse
{
Scopes = new List<PolicyScopeBase>
{
new()
{
Activities = ProtectionScopeActivities.UploadText,
Locations = new List<PolicyLocation>
{
new ("microsoft.graph.policyLocationApplication", "app-456")
}
}
}
};
this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync(
It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(psResponse);
// Act
await this._processor.ProcessMessagesAsync(
messages, "thread-123", Activity.UploadText, settings, "user-123", CancellationToken.None);
// Assert
// Content activities are now queued as background jobs, not called directly
this._mockChannelHandler.Verify(x => x.QueueJob(It.IsAny<ContentActivityJob>()), Times.Once);
this._mockPurviewClient.Verify(x => x.ProcessContentAsync(
It.IsAny<ProcessContentRequest>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task ProcessMessagesAsync_WithNoTenantId_ThrowsPurviewExceptionAsync()
{
// Arrange
var messages = new List<ChatMessage>
{
new (ChatRole.User, "Test message")
};
var settings = new PurviewSettings("TestApp"); // No TenantId
var tokenInfo = new TokenInfo { UserId = "user-123", ClientId = "client-123" }; // No TenantId
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
.ReturnsAsync(tokenInfo);
// Act & Assert
var exception = await Assert.ThrowsAsync<PurviewRequestException>(() =>
this._processor.ProcessMessagesAsync(messages, "thread-123", Activity.UploadText, settings, "user-123", CancellationToken.None));
Assert.Contains("No tenant id provided or inferred", exception.Message);
}
[Fact]
public async Task ProcessMessagesAsync_WithNoUserId_ThrowsPurviewExceptionAsync()
{
// Arrange
var messages = new List<ChatMessage>
{
new (ChatRole.User, "Test message")
};
var settings = CreateValidPurviewSettings();
var tokenInfo = new TokenInfo { TenantId = "tenant-123", ClientId = "client-123" }; // No UserId
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
.ReturnsAsync(tokenInfo);
// Act & Assert
var exception = await Assert.ThrowsAsync<PurviewRequestException>(() =>
this._processor.ProcessMessagesAsync(messages, "thread-123", Activity.UploadText, settings, null, CancellationToken.None));
Assert.Contains("No user id provided or inferred", exception.Message);
}
[Fact]
public async Task ProcessMessagesAsync_ExtractsUserIdFromMessageAdditionalProperties_Async()
{
// Arrange
var messages = new List<ChatMessage>
{
new (ChatRole.User, "Test message")
{
AdditionalProperties = new AdditionalPropertiesDictionary
{
{ "userId", "user-from-props" }
}
}
};
var settings = CreateValidPurviewSettings();
var tokenInfo = new TokenInfo { TenantId = "tenant-123", ClientId = "client-123" };
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
.ReturnsAsync(tokenInfo);
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((ProtectionScopesResponse?)null);
var psResponse = new ProtectionScopesResponse { Scopes = new List<PolicyScopeBase>() };
this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync(
It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(psResponse);
// Act
var result = await this._processor.ProcessMessagesAsync(
messages, "thread-123", Activity.UploadText, settings, null, CancellationToken.None);
// Assert
Assert.Equal("user-from-props", result.userId);
}
[Fact]
public async Task ProcessMessagesAsync_ExtractsUserIdFromMessageAuthorName_WhenValidGuidAsync()
{
// Arrange
var userId = Guid.NewGuid().ToString();
var messages = new List<ChatMessage>
{
new (ChatRole.User, "Test message")
{
AuthorName = userId
}
};
var settings = CreateValidPurviewSettings();
var tokenInfo = new TokenInfo { TenantId = "tenant-123", ClientId = "client-123" };
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
.ReturnsAsync(tokenInfo);
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((ProtectionScopesResponse?)null);
var psResponse = new ProtectionScopesResponse { Scopes = new List<PolicyScopeBase>() };
this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync(
It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(psResponse);
// Act
var result = await this._processor.ProcessMessagesAsync(
messages, "thread-123", Activity.UploadText, settings, null, CancellationToken.None);
// Assert
Assert.Equal(userId, result.userId);
}
#endregion
#region Helper Methods
private static PurviewSettings CreateValidPurviewSettings()
{
return new PurviewSettings("TestApp")
{
TenantId = "tenant-123",
PurviewAppLocation = new PurviewAppLocation(PurviewLocationType.Application, "app-123")
};
}
#endregion
}
@@ -73,7 +73,7 @@ class AzureAIClient(OpenAIBaseResponsesClient):
Keyword Args:
project_client: An existing AIProjectClient to use. If not provided, one will be created.
agent_name: The name to use when creating new agents.
agent_name: The name to use when creating new agents or using existing agents.
agent_version: The version of the agent to use.
conversation_id: Default conversation ID to use for conversations. Can be overridden by
conversation_id property when making a request.
@@ -194,17 +194,21 @@ class AzureAIClient(OpenAIBaseResponsesClient):
"""Determine which agent to use and create if needed.
Returns:
str: The agent_name to use
dict[str, str]: The agent reference to use.
"""
agent_name = self.agent_name or "UnnamedAgent"
# Agent name must be explicitly provided by the user.
if self.agent_name is None:
raise ServiceInitializationError(
"Agent name is required. Provide 'agent_name' when initializing AzureAIClient "
"or 'name' when initializing ChatAgent."
)
# If no agent_version is provided, either use latest version or create a new agent:
if self.agent_version is None:
# Try to use latest version if requested and agent exists
if self.use_latest_version:
try:
existing_agent = await self.project_client.agents.get(agent_name)
self.agent_name = existing_agent.name
existing_agent = await self.project_client.agents.get(self.agent_name)
self.agent_version = existing_agent.versions.latest.version
return {"name": self.agent_name, "version": self.agent_version, "type": "agent_reference"}
except ResourceNotFoundError:
@@ -241,13 +245,12 @@ class AzureAIClient(OpenAIBaseResponsesClient):
args["instructions"] = "".join(combined_instructions)
created_agent = await self.project_client.agents.create_version(
agent_name=agent_name, definition=PromptAgentDefinition(**args)
agent_name=self.agent_name, definition=PromptAgentDefinition(**args)
)
self.agent_name = created_agent.name
self.agent_version = created_agent.version
return {"name": agent_name, "version": self.agent_version, "type": "agent_reference"}
return {"name": self.agent_name, "version": self.agent_version, "type": "agent_reference"}
async def _close_client_if_needed(self) -> None:
"""Close project_client session if we created it."""
@@ -276,6 +279,7 @@ class AzureAIClient(OpenAIBaseResponsesClient):
async def prepare_options(
self, messages: MutableSequence[ChatMessage], chat_options: ChatOptions
) -> dict[str, Any]:
"""Take ChatOptions and create the specific options for Azure AI."""
chat_options.store = bool(chat_options.store or chat_options.store is None)
prepared_messages, instructions = self._prepare_input(messages)
run_options = await super().prepare_options(prepared_messages, chat_options)
@@ -161,6 +161,16 @@ async def test_azure_ai_client_get_agent_reference_or_create_existing_version(
assert agent_ref == {"name": "existing-agent", "version": "1.0", "type": "agent_reference"}
async def test_azure_ai_client_get_agent_reference_or_create_missing_agent_name(
mock_project_client: MagicMock,
) -> None:
"""Test _get_agent_reference_or_create raises when agent_name is missing."""
client = create_test_azure_ai_client(mock_project_client, agent_name=None)
with pytest.raises(ServiceInitializationError, match="Agent name is required"):
await client._get_agent_reference_or_create({}, None) # type: ignore
async def test_azure_ai_client_get_agent_reference_or_create_new_agent(
mock_project_client: MagicMock,
azure_ai_unit_test_env: dict[str, str],
+59 -19
View File
@@ -81,10 +81,16 @@ def _mcp_type_to_ai_content(
case types.TextContent():
return TextContent(text=mcp_type.text, raw_representation=mcp_type)
case types.ImageContent() | types.AudioContent():
return DataContent(uri=mcp_type.data, media_type=mcp_type.mimeType, raw_representation=mcp_type)
return DataContent(
uri=mcp_type.data,
media_type=mcp_type.mimeType,
raw_representation=mcp_type,
)
case types.ResourceLink():
return UriContent(
uri=str(mcp_type.uri), media_type=mcp_type.mimeType or "application/json", raw_representation=mcp_type
uri=str(mcp_type.uri),
media_type=mcp_type.mimeType or "application/json",
raw_representation=mcp_type,
)
case _:
match mcp_type.resource:
@@ -92,14 +98,14 @@ def _mcp_type_to_ai_content(
return TextContent(
text=mcp_type.resource.text,
raw_representation=mcp_type,
additional_properties=mcp_type.annotations.model_dump() if mcp_type.annotations else None,
additional_properties=(mcp_type.annotations.model_dump() if mcp_type.annotations else None),
)
case types.BlobResourceContents():
return DataContent(
uri=mcp_type.resource.blob,
media_type=mcp_type.resource.mimeType,
raw_representation=mcp_type,
additional_properties=mcp_type.annotations.model_dump() if mcp_type.annotations else None,
additional_properties=(mcp_type.annotations.model_dump() if mcp_type.annotations else None),
)
@@ -124,9 +130,11 @@ def _ai_content_to_mcp_types(
# uri's are not limited in MCP but they have to be set.
# the uri of data content, contains the data uri, which
# is not the uri meant here, UriContent would match this.
uri=content.additional_properties.get("uri", "af://binary")
if content.additional_properties
else "af://binary", # type: ignore[reportArgumentType]
uri=(
content.additional_properties.get("uri", "af://binary")
if content.additional_properties
else "af://binary"
), # type: ignore[reportArgumentType]
),
)
return None
@@ -135,9 +143,9 @@ def _ai_content_to_mcp_types(
type="resource_link",
uri=content.uri, # type: ignore[reportArgumentType]
mimeType=content.media_type,
name=content.additional_properties.get("name", "Unknown")
if content.additional_properties
else "Unknown",
name=(
content.additional_properties.get("name", "Unknown") if content.additional_properties else "Unknown"
),
)
case _:
return None
@@ -272,7 +280,7 @@ class MCPTool:
self,
name: str,
description: str | None = None,
approval_mode: Literal["always_require", "never_require"] | HostedMCPSpecificApproval | None = None,
approval_mode: (Literal["always_require", "never_require"] | HostedMCPSpecificApproval | None) = None,
allowed_tools: Collection[str] | None = None,
load_tools: bool = True,
load_prompts: bool = True,
@@ -300,6 +308,8 @@ class MCPTool:
self.chat_client = chat_client
self._functions: list[AIFunction[Any, Any]] = []
self.is_connected: bool = False
self._tools_loaded: bool = False
self._prompts_loaded: bool = False
def __str__(self) -> str:
return f"MCPTool(name={self.name}, description={self.description})"
@@ -336,7 +346,9 @@ class MCPTool:
ClientSession(
read_stream=transport[0],
write_stream=transport[1],
read_timeout_seconds=timedelta(seconds=self.request_timeout) if self.request_timeout else None,
read_timeout_seconds=(
timedelta(seconds=self.request_timeout) if self.request_timeout else None
),
message_handler=self.message_handler,
logging_callback=self.logging_callback,
sampling_callback=self.sampling_callback,
@@ -345,7 +357,8 @@ class MCPTool:
except Exception as ex:
await self._exit_stack.aclose()
raise ToolException(
message="Failed to create MCP session. Please check your configuration.", inner_exception=ex
message="Failed to create MCP session. Please check your configuration.",
inner_exception=ex,
) from ex
try:
await session.initialize()
@@ -368,8 +381,10 @@ class MCPTool:
self.is_connected = True
if self.load_tools_flag:
await self.load_tools()
self._tools_loaded = True
if self.load_prompts_flag:
await self.load_prompts()
self._prompts_loaded = True
if logger.level != logging.NOTSET:
try:
@@ -380,7 +395,9 @@ class MCPTool:
logger.warning("Failed to set log level to %s", logger.level, exc_info=exc)
async def sampling_callback(
self, context: RequestContext[ClientSession, Any], params: types.CreateMessageRequestParams
self,
context: RequestContext[ClientSession, Any],
params: types.CreateMessageRequestParams,
) -> types.CreateMessageResult | types.ErrorData:
"""Callback function for sampling.
@@ -458,7 +475,7 @@ class MCPTool:
async def message_handler(
self,
message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception,
message: (RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception),
) -> None:
"""Handle messages from the MCP server.
@@ -517,8 +534,17 @@ class MCPTool:
exc_info=exc,
)
prompt_list = None
# Track existing function names to prevent duplicates
existing_names = {func.name for func in self._functions}
for prompt in prompt_list.prompts if prompt_list else []:
local_name = _normalize_mcp_name(prompt.name)
# Skip if already loaded
if local_name in existing_names:
continue
input_model = _get_input_model_from_mcp_prompt(prompt)
approval_mode = self._determine_approval_mode(local_name)
func: AIFunction[BaseModel, list[ChatMessage]] = AIFunction(
@@ -529,6 +555,7 @@ class MCPTool:
input_model=input_model,
)
self._functions.append(func)
existing_names.add(local_name)
async def load_tools(self) -> None:
"""Load tools from the MCP server.
@@ -549,8 +576,17 @@ class MCPTool:
exc_info=exc,
)
tool_list = None
# Track existing function names to prevent duplicates
existing_names = {func.name for func in self._functions}
for tool in tool_list.tools if tool_list else []:
local_name = _normalize_mcp_name(tool.name)
# Skip if already loaded
if local_name in existing_names:
continue
input_model = _get_input_model_from_mcp_tool(tool)
approval_mode = self._determine_approval_mode(local_name)
# Create AIFunctions out of each tool
@@ -562,6 +598,7 @@ class MCPTool:
input_model=input_model,
)
self._functions.append(func)
existing_names.add(local_name)
async def close(self) -> None:
"""Disconnect from the MCP server.
@@ -662,7 +699,10 @@ class MCPTool:
raise ToolExecutionException("Failed to enter context manager.", inner_exception=ex) from ex
async def __aexit__(
self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: Any
self,
exc_type: type[BaseException] | None,
exc_value: BaseException | None,
traceback: Any,
) -> None:
"""Exit the async context manager.
@@ -714,7 +754,7 @@ class MCPStdioTool(MCPTool):
request_timeout: int | None = None,
session: ClientSession | None = None,
description: str | None = None,
approval_mode: Literal["always_require", "never_require"] | HostedMCPSpecificApproval | None = None,
approval_mode: (Literal["always_require", "never_require"] | HostedMCPSpecificApproval | None) = None,
allowed_tools: Collection[str] | None = None,
args: list[str] | None = None,
env: dict[str, str] | None = None,
@@ -824,7 +864,7 @@ class MCPStreamableHTTPTool(MCPTool):
request_timeout: int | None = None,
session: ClientSession | None = None,
description: str | None = None,
approval_mode: Literal["always_require", "never_require"] | HostedMCPSpecificApproval | None = None,
approval_mode: (Literal["always_require", "never_require"] | HostedMCPSpecificApproval | None) = None,
allowed_tools: Collection[str] | None = None,
headers: dict[str, Any] | None = None,
timeout: float | None = None,
@@ -939,7 +979,7 @@ class MCPWebsocketTool(MCPTool):
request_timeout: int | None = None,
session: ClientSession | None = None,
description: str | None = None,
approval_mode: Literal["always_require", "never_require"] | HostedMCPSpecificApproval | None = None,
approval_mode: (Literal["always_require", "never_require"] | HostedMCPSpecificApproval | None) = None,
allowed_tools: Collection[str] | None = None,
chat_client: "ChatClientProtocol | None" = None,
additional_properties: dict[str, Any] | None = None,
+204 -16
View File
@@ -38,9 +38,11 @@ from agent_framework.exceptions import ToolException, ToolExecutionException
# Integration test skip condition
skip_if_mcp_integration_tests_disabled = pytest.mark.skipif(
os.getenv("RUN_INTEGRATION_TESTS", "false").lower() != "true" or os.getenv("LOCAL_MCP_URL", "") == "",
reason="No LOCAL_MCP_URL provided; skipping integration tests."
if os.getenv("RUN_INTEGRATION_TESTS", "false").lower() == "true"
else "Integration tests are disabled.",
reason=(
"No LOCAL_MCP_URL provided; skipping integration tests."
if os.getenv("RUN_INTEGRATION_TESTS", "false").lower() == "true"
else "Integration tests are disabled."
),
)
@@ -137,7 +139,9 @@ def test_mcp_content_types_to_ai_content_resource_link():
def test_mcp_content_types_to_ai_content_embedded_resource_text():
"""Test conversion of MCP embedded text resource to AI content."""
text_resource = types.TextResourceContents(
uri=AnyUrl("file://test.txt"), mimeType="text/plain", text="Embedded text content"
uri=AnyUrl("file://test.txt"),
mimeType="text/plain",
text="Embedded text content",
)
mcp_content = types.EmbeddedResource(type="resource", resource=text_resource)
ai_content = _mcp_type_to_ai_content(mcp_content)
@@ -198,7 +202,10 @@ def test_ai_content_to_mcp_content_types_data_audio():
def test_ai_content_to_mcp_content_types_data_binary():
"""Test conversion of AI data content to MCP content."""
ai_content = DataContent(uri="data:application/octet-stream;base64,xyz", media_type="application/octet-stream")
ai_content = DataContent(
uri="data:application/octet-stream;base64,xyz",
media_type="application/octet-stream",
)
mcp_content = _ai_content_to_mcp_types(ai_content)
assert isinstance(mcp_content, types.EmbeddedResource)
@@ -221,7 +228,10 @@ def test_ai_content_to_mcp_content_types_uri():
def test_chat_message_to_mcp_types():
message = ChatMessage(
role="user",
contents=[TextContent(text="test"), DataContent(uri="data:image/png;base64,xyz", media_type="image/png")],
contents=[
TextContent(text="test"),
DataContent(uri="data:image/png;base64,xyz", media_type="image/png"),
],
)
mcp_contents = _chat_message_to_mcp_types(message)
assert len(mcp_contents) == 2
@@ -583,7 +593,10 @@ async def test_local_mcp_server_prompt_execution():
return_value=types.GetPromptResult(
description="Generated prompt",
messages=[
types.PromptMessage(role="user", content=types.TextContent(type="text", text="Test message"))
types.PromptMessage(
role="user",
content=types.TextContent(type="text", text="Test message"),
)
],
)
)
@@ -607,10 +620,16 @@ async def test_local_mcp_server_prompt_execution():
@pytest.mark.parametrize(
"approval_mode,expected_approvals",
[
("always_require", {"tool_one": "always_require", "tool_two": "always_require"}),
(
"always_require",
{"tool_one": "always_require", "tool_two": "always_require"},
),
("never_require", {"tool_one": "never_require", "tool_two": "never_require"}),
(
{"always_require_approval": ["tool_one"], "never_require_approval": ["tool_two"]},
{
"always_require_approval": ["tool_one"],
"never_require_approval": ["tool_two"],
},
{"tool_one": "always_require", "tool_two": "never_require"},
),
],
@@ -664,9 +683,17 @@ async def test_mcp_tool_approval_mode(approval_mode, expected_approvals):
@pytest.mark.parametrize(
"allowed_tools,expected_count,expected_names",
[
(None, 3, ["tool_one", "tool_two", "tool_three"]), # None means all tools are allowed
(
None,
3,
["tool_one", "tool_two", "tool_three"],
), # None means all tools are allowed
(["tool_one"], 1, ["tool_one"]), # Only tool_one is allowed
(["tool_one", "tool_three"], 2, ["tool_one", "tool_three"]), # Two tools allowed
(
["tool_one", "tool_three"],
2,
["tool_one", "tool_three"],
), # Two tools allowed
(["nonexistent_tool"], 0, []), # No matching tools
],
)
@@ -884,7 +911,12 @@ async def test_mcp_tool_sampling_callback_no_valid_content():
mock_response.messages = [
ChatMessage(
role=Role.ASSISTANT,
contents=[DataContent(uri="data:application/json;base64,e30K", media_type="application/json")],
contents=[
DataContent(
uri="data:application/json;base64,e30K",
media_type="application/json",
)
],
)
]
mock_response.model_id = "test-model"
@@ -1011,14 +1043,24 @@ async def test_connect_cleanup_on_initialization_failure():
def test_mcp_stdio_tool_get_mcp_client_with_env_and_kwargs():
"""Test MCPStdioTool.get_mcp_client() with environment variables and client kwargs."""
env_vars = {"PATH": "/usr/bin", "DEBUG": "1"}
tool = MCPStdioTool(name="test", command="test-command", env=env_vars, custom_param="value1", another_param=42)
tool = MCPStdioTool(
name="test",
command="test-command",
env=env_vars,
custom_param="value1",
another_param=42,
)
with patch("agent_framework._mcp.stdio_client"), patch("agent_framework._mcp.StdioServerParameters") as mock_params:
tool.get_mcp_client()
# Verify all parameters including custom kwargs were passed
mock_params.assert_called_once_with(
command="test-command", args=[], env=env_vars, custom_param="value1", another_param=42
command="test-command",
args=[],
env=env_vars,
custom_param="value1",
another_param=42,
)
@@ -1051,7 +1093,11 @@ def test_mcp_streamable_http_tool_get_mcp_client_all_params():
def test_mcp_websocket_tool_get_mcp_client_with_kwargs():
"""Test MCPWebsocketTool.get_mcp_client() with client kwargs."""
tool = MCPWebsocketTool(
name="test", url="wss://example.com", max_size=1024, ping_interval=30, compression="deflate"
name="test",
url="wss://example.com",
max_size=1024,
ping_interval=30,
compression="deflate",
)
with patch("agent_framework._mcp.websocket_client") as mock_ws_client:
@@ -1059,5 +1105,147 @@ def test_mcp_websocket_tool_get_mcp_client_with_kwargs():
# Verify all kwargs were passed
mock_ws_client.assert_called_once_with(
url="wss://example.com", max_size=1024, ping_interval=30, compression="deflate"
url="wss://example.com",
max_size=1024,
ping_interval=30,
compression="deflate",
)
@pytest.mark.asyncio
async def test_mcp_tool_deduplication():
"""Test that MCP tools are not duplicated in MCPTool"""
from agent_framework._mcp import MCPTool
from agent_framework._tools import AIFunction
# Create MCPStreamableHTTPTool instance
tool = MCPTool(name="test_mcp_tool")
# Manually set up functions list
tool._functions = []
# Add initial functions
func1 = AIFunction(
func=lambda x: f"Result: {x}",
name="analyze_content",
description="Analyzes content",
)
func2 = AIFunction(
func=lambda x: f"Extract: {x}",
name="extract_info",
description="Extracts information",
)
tool._functions.append(func1)
tool._functions.append(func2)
# Verify initial state
assert len(tool._functions) == 2
assert len({f.name for f in tool._functions}) == 2
# Simulate deduplication logic
existing_names = {func.name for func in tool._functions}
# Attempt to add duplicates
test_tools = [
("analyze_content", "Duplicate"),
("extract_info", "Duplicate"),
("new_function", "New"),
]
added_count = 0
for tool_name, description in test_tools:
if tool_name in existing_names:
continue # Skip duplicates
new_func = AIFunction(func=lambda x: f"Process: {x}", name=tool_name, description=description)
tool._functions.append(new_func)
existing_names.add(tool_name)
added_count += 1
# Verify results
final_names = [f.name for f in tool._functions]
unique_names = set(final_names)
# Should have exactly 3 functions (2 original + 1 new)
assert len(tool._functions) == 3
assert len(unique_names) == 3
assert len(final_names) == len(unique_names) # No duplicates
assert added_count == 1 # Only 1 new function added
@pytest.mark.asyncio
async def test_load_tools_prevents_multiple_calls():
"""Test that connect() prevents calling load_tools() multiple times"""
from unittest.mock import AsyncMock, MagicMock
from agent_framework._mcp import MCPTool
tool = MCPTool(name="test_tool")
# Verify initial state
assert tool._tools_loaded is False
# Mock the session and list_tools
mock_session = AsyncMock()
mock_tool_list = MagicMock()
mock_tool_list.tools = []
mock_session.list_tools = AsyncMock(return_value=mock_tool_list)
mock_session.initialize = AsyncMock()
tool.session = mock_session
tool.load_tools_flag = True
tool.load_prompts_flag = False
# Simulate connect() behavior
if tool.load_tools_flag and not tool._tools_loaded:
await tool.load_tools()
tool._tools_loaded = True
assert tool._tools_loaded is True
assert mock_session.list_tools.call_count == 1
# Second call to connect should be skipped
if tool.load_tools_flag and not tool._tools_loaded:
await tool.load_tools()
tool._tools_loaded = True
assert mock_session.list_tools.call_count == 1 # Still 1, not incremented
@pytest.mark.asyncio
async def test_load_prompts_prevents_multiple_calls():
"""Test that connect() prevents calling load_prompts() multiple times"""
from unittest.mock import AsyncMock, MagicMock
from agent_framework._mcp import MCPTool
tool = MCPTool(name="test_tool")
# Verify initial state
assert tool._prompts_loaded is False
# Mock the session and list_prompts
mock_session = AsyncMock()
mock_prompt_list = MagicMock()
mock_prompt_list.prompts = []
mock_session.list_prompts = AsyncMock(return_value=mock_prompt_list)
tool.session = mock_session
tool.load_tools_flag = False
tool.load_prompts_flag = True
# Simulate connect() behavior
if tool.load_prompts_flag and not tool._prompts_loaded:
await tool.load_prompts()
tool._prompts_loaded = True
assert tool._prompts_loaded is True
assert mock_session.list_prompts.call_count == 1
# Second call to connect should be skipped
if tool.load_prompts_flag and not tool._prompts_loaded:
await tool.load_prompts()
tool._prompts_loaded = True
assert mock_session.list_prompts.call_count == 1 # Still 1, not incremented
@@ -0,0 +1,2 @@
OPENAI_CHAT_MODEL_ID="gpt-4o-2024-08-06"
OPENAI_API_KEY="your-openai-api-key"
@@ -0,0 +1,16 @@
FROM python:3.12-slim
WORKDIR /app
COPY . user_agent/
WORKDIR /app/user_agent
RUN if [ -f requirements.txt ]; then \
pip install -r requirements.txt; \
else \
echo "No requirements.txt found"; \
fi
EXPOSE 8088
CMD ["python", "main.py"]
@@ -0,0 +1,47 @@
# Hosted Agents with Hosted MCP Demo
This demo showcases an agent that has access to a MCP tool that can talk to the Microsoft Learn documentation platform, hosted as an agent endpoint running locally in a Docker container.
## What the Project Does
This project demonstrates how to:
- Create an agent with a hosted MCP tool using the Agent Framework
- Host the agent as an agent endpoint running in a Docker container
## Prerequisites
- OpenAI API access and credentials
- Required environment variables (see Configuration section)
## Configuration
Follow the `.env.example` file to set up the necessary environment variables for OpenAI.
## Docker Deployment
Build and run using Docker:
```bash
# Build the Docker image
docker build -t hosted-agent-mcp .
# Run the container
docker run -p 8088:8088 hosted-agent-mcp
```
> If you update the environment variables in the `.env` file or change the code or the dockerfile, make sure to rebuild the Docker image to apply the changes.
## Testing the Agent
Once the agent is running, you can test it by sending queries that contain the trigger keywords. For example:
```bash
curl -sS -H "Content-Type: application/json" -X POST http://localhost:8088/responses -d '{"input": "How to create an Azure storage account using az cli?","stream":false}'
```
Expected response:
```bash
{"object":"response","metadata":{},"agent":null,"conversation":{"id":"conv_6Y7osWAQ1ASyUZ7Ze0LL6dgPubmQv52jHb7G9QDqpV5yakc3ay"},"type":"message","role":"assistant","temperature":1.0,"top_p":1.0,"user":"","id":"resp_Vfd6mdmnmTZ2RNirwfldfqldWLhaxD6fO2UkXsVUg1jYJgftL9","created_at":1763075575,"output":[{"id":"msg_6Y7osWAQ1ASyUZ7Ze0PwiK2V4Bb7NOPaaEpQoBvFRZ5h6OfW4u","type":"message","status":"completed","role":"assistant","content":[{"type":"output_text","text":"To create an Azure Storage account using the Azure CLI, you'll need to follow these steps:\n\n1. **Install Azure CLI**: Make sure the Azure CLI is installed on your machine. You can download it from [here](https://docs.microsoft.com/en-us/cli/azure/install-azure-cli).\n\n2. **Log in to Azure**: Open your terminal or command prompt and use the following command to log in to your Azure account:\n\n ```bash\n az login\n ```\n\n This command will open a web browser where you can log in with your Azure account credentials. If you're using a service principal, you would use `az login --service-principal ...` with the appropriate parameters.\n\n3. **Select the Subscription**: If you have multiple Azure subscriptions, set the default subscription that you want to use:\n\n ```bash\n az account set --subscription \"Your Subscription Name\"\n ```\n\n4. **Create a Resource Group**: If you dont already have a resource group, create one using:\n\n ```bash\n az group create --name myResourceGroup --location eastus\n ```\n\n Replace `myResourceGroup` and `eastus` with your desired resource group name and location.\n\n5. **Create the Storage Account**: Use the following command to create the storage account:\n\n ```bash\n az storage account create --name mystorageaccount --resource-group myResourceGroup --location eastus --sku Standard_LRS\n ```\n\n Replace `mystorageaccount` with a unique name for your storage account. The storage account name must be between 3 and 24 characters in length, and may contain numbers and lowercase letters only. You can also choose other `--sku` options like `Standard_GRS`, `Standard_RAGRS`, `Standard_ZRS`, `Premium_LRS`, based on your redundancy and performance needs.\n\nBy following these steps, you'll create a new Azure Storage account in the specified resource group and location with the specified SKU.","annotations":[],"logprobs":[]}]}],"parallel_tool_calls":true,"status":"completed"}
```
@@ -0,0 +1,25 @@
# Copyright (c) Microsoft. All rights reserved.
from agent_framework import HostedMCPTool
from agent_framework.openai import OpenAIChatClient
from azure.ai.agentserver.agentframework import from_agent_framework # pyright: ignore[reportUnknownVariableType]
def main():
# Create an Agent using the OpenAI Chat Client with a MCP Tool that connects to Microsoft Learn MCP
agent = OpenAIChatClient().create_agent(
name="DocsAgent",
instructions="You are a helpful assistant that can help with microsoft documentation questions.",
tools=HostedMCPTool(
name="Microsoft Learn MCP",
url="https://learn.microsoft.com/api/mcp",
),
)
# Run the agent as a hosted agent
from_agent_framework(agent).run()
if __name__ == "__main__":
main()
@@ -0,0 +1,2 @@
azure-ai-agentserver-agentframework==1.0.0b3
agent-framework
@@ -0,0 +1,2 @@
OPENAI_CHAT_MODEL_ID="gpt-4o-2024-08-06"
OPENAI_API_KEY="your-openai-api-key"
@@ -0,0 +1,16 @@
FROM python:3.12-slim
WORKDIR /app
COPY . user_agent/
WORKDIR /app/user_agent
RUN if [ -f requirements.txt ]; then \
pip install -r requirements.txt; \
else \
echo "No requirements.txt found"; \
fi
EXPOSE 8088
CMD ["python", "main.py"]
@@ -0,0 +1,54 @@
# Hosted Agents with Text Search RAG Demo
This demo showcases an agent that uses Retrieval-Augmented Generation (RAG) with text search capabilities that will be hosted as an agent endpoint running locally in a Docker container.
## What the Project Does
This project demonstrates how to:
- Build a customer support agent using the Agent Framework
- Implement a custom `TextSearchContextProvider` that simulates document retrieval
- Host the agent as an agent endpoint running in a Docker container
The agent responds to customer inquiries about:
- **Return & Refund Policies** - Triggered by keywords: "return", "refund"
- **Shipping Information** - Triggered by keyword: "shipping"
- **Product Care Instructions** - Triggered by keywords: "tent", "fabric"
## Prerequisites
- OpenAI API access and credentials
- Required environment variables (see Configuration section)
## Configuration
Follow the `.env.example` file to set up the necessary environment variables for OpenAI.
## Docker Deployment
Build and run using Docker:
```bash
# Build the Docker image
docker build -t hosted-agent-rag .
# Run the container
docker run -p 8088:8088 hosted-agent-rag
```
> If you update the environment variables in the `.env` file or change the code or the dockerfile, make sure to rebuild the Docker image to apply the changes.
## Testing the Agent
Once the agent is running, you can test it by sending queries that contain the trigger keywords. For example:
```bash
curl -sS -H "Content-Type: application/json" -X POST http://localhost:8088/responses -d '{"input": "What is the return policy","stream":false}'
```
Expected response:
```bash
{"object":"response","metadata":{},"agent":null,"conversation":{"id":"conv_2GbSxDpJJ89B6N4FQkKhrHaz78Hjtxy9b30JEPuY9YFjJM0uw3"},"type":"message","role":"assistant","temperature":1.0,"top_p":1.0,"user":"","id":"resp_Bvffxq0iIzlVkx2I8x7hV4fglm9RBPWfMCpNtEpDT6ciV2IG6z","created_at":1763071467,"output":[{"id":"msg_2GbSxDpJJ89B6N4FQknLsnxkwwFS2FULJqRV9jMey2BOXljqUz","type":"message","status":"completed","role":"assistant","content":[{"type":"output_text","text":"As of the most recent update, Contoso Outdoors' return policy allows customers to return products within 30 days of purchase for a full refund or exchange, provided the items are in their original condition and packaging. However, make sure to check your purchase receipt or the company's website for the most updated and specific details, as policies can vary by location and may change over time.","annotations":[],"logprobs":[]}]}],"parallel_tool_calls":true,"status":"completed"}
```
@@ -0,0 +1,109 @@
# Copyright (c) Microsoft. All rights reserved.
import json
import sys
from collections.abc import MutableSequence
from dataclasses import dataclass
from typing import Any
from agent_framework import ChatMessage, Context, ContextProvider, Role
from agent_framework.openai import OpenAIChatClient
from azure.ai.agentserver.agentframework import from_agent_framework # pyright: ignore[reportUnknownVariableType]
if sys.version_info >= (3, 12):
from typing import override
else:
from typing_extensions import override
@dataclass
class TextSearchResult:
source_name: str
source_link: str
text: str
class TextSearchContextProvider(ContextProvider):
"""A simple context provider that simulates text search results based on keywords in the user's message."""
def _get_most_recent_message(self, messages: ChatMessage | MutableSequence[ChatMessage]) -> ChatMessage:
"""Helper method to extract the most recent message from the input."""
if isinstance(messages, ChatMessage):
return messages
if messages:
return messages[-1]
raise ValueError("No messages provided")
@override
async def invoking(self, messages: ChatMessage | MutableSequence[ChatMessage], **kwargs: Any) -> Context:
message = self._get_most_recent_message(messages)
query = message.text.lower()
results: list[TextSearchResult] = []
if "return" in query and "refund" in query:
results.append(
TextSearchResult(
source_name="Contoso Outdoors Return Policy",
source_link="https://contoso.com/policies/returns",
text=(
"Customers may return any item within 30 days of delivery. "
"Items should be unused and include original packaging. "
"Refunds are issued to the original payment method within 5 business days of inspection."
),
)
)
if "shipping" in query:
results.append(
TextSearchResult(
source_name="Contoso Outdoors Shipping Guide",
source_link="https://contoso.com/help/shipping",
text=(
"Standard shipping is free on orders over $50 and typically arrives in 3-5 business days "
"within the continental United States. Expedited options are available at checkout."
),
)
)
if "tent" in query or "fabric" in query:
results.append(
TextSearchResult(
source_name="TrailRunner Tent Care Instructions",
source_link="https://contoso.com/manuals/trailrunner-tent",
text=(
"Clean the tent fabric with lukewarm water and a non-detergent soap. "
"Allow it to air dry completely before storage and avoid prolonged UV "
"exposure to extend the lifespan of the waterproof coating."
),
)
)
if not results:
return Context()
return Context(
messages=[
ChatMessage(
role=Role.USER, text="\n\n".join(json.dumps(result.__dict__, indent=2) for result in results)
)
]
)
def main():
# Create an Agent using the OpenAI Chat Client
agent = OpenAIChatClient().create_agent(
name="SupportSpecialist",
instructions=(
"You are a helpful support specialist for Contoso Outdoors. "
"Answer questions using the provided context and cite the source document when available."
),
context_providers=TextSearchContextProvider(),
)
# Run the agent as a hosted agent
from_agent_framework(agent).run()
if __name__ == "__main__":
main()
@@ -0,0 +1,2 @@
azure-ai-agentserver-agentframework==1.0.0b3
agent-framework
@@ -0,0 +1,2 @@
OPENAI_CHAT_MODEL_ID="gpt-4o-2024-08-06"
OPENAI_API_KEY="your-openai-api-key"
@@ -0,0 +1,16 @@
FROM python:3.12-slim
WORKDIR /app
COPY . user_agent/
WORKDIR /app/user_agent
RUN if [ -f requirements.txt ]; then \
pip install -r requirements.txt; \
else \
echo "No requirements.txt found"; \
fi
EXPOSE 8088
CMD ["python", "main.py"]

Some files were not shown because too many files have changed in this diff Show More