diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 102b24b4cc..05f98369ea 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -92,6 +92,9 @@ + + + @@ -340,6 +343,7 @@ + @@ -372,6 +376,7 @@ + diff --git a/dotnet/samples/HostedAgents/AgentWithHostedMCP/Program.cs b/dotnet/samples/HostedAgents/AgentWithHostedMCP/Program.cs index d6bfca7bf7..9cbea8b73a 100644 --- a/dotnet/samples/HostedAgents/AgentWithHostedMCP/Program.cs +++ b/dotnet/samples/HostedAgents/AgentWithHostedMCP/Program.cs @@ -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.", diff --git a/dotnet/samples/HostedAgents/AgentWithHostedMCP/agent.yaml b/dotnet/samples/HostedAgents/AgentWithHostedMCP/agent.yaml index 1a484bfade..6444f1aad0 100644 --- a/dotnet/samples/HostedAgents/AgentWithHostedMCP/agent.yaml +++ b/dotnet/samples/HostedAgents/AgentWithHostedMCP/agent.yaml @@ -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 diff --git a/dotnet/samples/HostedAgents/AgentWithTextSearchRag/agent.yaml b/dotnet/samples/HostedAgents/AgentWithTextSearchRag/agent.yaml index 78e45e9c56..1366071b17 100644 --- a/dotnet/samples/HostedAgents/AgentWithTextSearchRag/agent.yaml +++ b/dotnet/samples/HostedAgents/AgentWithTextSearchRag/agent.yaml @@ -10,6 +10,7 @@ metadata: authors: - Microsoft Agent Framework Team tags: + - Azure AI AgentServer - Microsoft Agent Framework - Retrieval-Augmented Generation - RAG diff --git a/dotnet/samples/HostedAgents/AgentsInWorkflows/agent.yaml b/dotnet/samples/HostedAgents/AgentsInWorkflows/agent.yaml index 5e9360efd9..900f05d513 100644 --- a/dotnet/samples/HostedAgents/AgentsInWorkflows/agent.yaml +++ b/dotnet/samples/HostedAgents/AgentsInWorkflows/agent.yaml @@ -8,6 +8,7 @@ metadata: authors: - Microsoft Agent Framework Team tags: + - Azure AI AgentServer - Microsoft Agent Framework - Workflows template: diff --git a/dotnet/samples/Purview/AgentWithPurview/AgentWithPurview.csproj b/dotnet/samples/Purview/AgentWithPurview/AgentWithPurview.csproj new file mode 100644 index 0000000000..8dc509efed --- /dev/null +++ b/dotnet/samples/Purview/AgentWithPurview/AgentWithPurview.csproj @@ -0,0 +1,21 @@ + + + + Exe + net9.0 + + enable + enable + + + + + + + + + + + + + diff --git a/dotnet/samples/Purview/AgentWithPurview/Program.cs b/dotnet/samples/Purview/AgentWithPurview/Program.cs new file mode 100644 index 0000000000..842917b427 --- /dev/null +++ b/dotnet/samples/Purview/AgentWithPurview/Program.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample shows how to create and use a simple AI agent with Purview integration. +// It uses Azure OpenAI as the backend, but any IChatClient can be used. +// Authentication to Purview is done using an InteractiveBrowserCredential. +// Any TokenCredential with Purview API permissions can be used here. + +using Azure.AI.OpenAI; +using Azure.Core; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Purview; +using Microsoft.Extensions.AI; + +var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); +var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +var purviewClientAppId = Environment.GetEnvironmentVariable("PURVIEW_CLIENT_APP_ID") ?? throw new InvalidOperationException("PURVIEW_CLIENT_APP_ID is not set."); + +// This will get a user token for an entra app configured to call the Purview API. +// Any TokenCredential with permissions to call the Purview API can be used here. +TokenCredential browserCredential = new InteractiveBrowserCredential( + new InteractiveBrowserCredentialOptions + { + ClientId = purviewClientAppId + }); + +using IChatClient client = new AzureOpenAIClient( + new Uri(endpoint), + new AzureCliCredential()) + .GetOpenAIResponseClient(deploymentName) + .AsIChatClient() + .AsBuilder() + .WithPurview(browserCredential, new PurviewSettings("Agent Framework Test App")) + .Build(); + +Console.WriteLine("Enter a prompt to send to the client:"); +string? promptText = Console.ReadLine(); + +if (!string.IsNullOrEmpty(promptText)) +{ + // Invoke the agent and output the text result. + Console.WriteLine(await client.GetResponseAsync(promptText)); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/BackgroundJobRunner.cs b/dotnet/src/Microsoft.Agents.AI.Purview/BackgroundJobRunner.cs new file mode 100644 index 0000000000..d55c5a6a66 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/BackgroundJobRunner.cs @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Purview.Models.Jobs; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Agents.AI.Purview; + +/// +/// Service that runs jobs in background threads. +/// +internal sealed class BackgroundJobRunner +{ + private readonly IChannelHandler _channelHandler; + private readonly IPurviewClient _purviewClient; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The channel handler used to manage job channels. + /// The Purview client used to send requests to Purview. + /// The logger used to log information about background jobs. + /// The settings used to configure Purview client behavior. + public BackgroundJobRunner(IChannelHandler channelHandler, IPurviewClient purviewClient, ILogger logger, PurviewSettings purviewSettings) + { + this._channelHandler = channelHandler; + this._purviewClient = purviewClient; + this._logger = logger; + + for (int i = 0; i < purviewSettings.MaxConcurrentJobConsumers; i++) + { + this._channelHandler.AddRunner(async (Channel channel) => + { + await foreach (BackgroundJobBase job in channel.Reader.ReadAllAsync().ConfigureAwait(false)) + { + try + { + await this.RunJobAsync(job).ConfigureAwait(false); + } + catch (Exception e) when ( + !(e is OperationCanceledException) && + !(e is SystemException)) + { + this._logger.LogError(e, "Error running background job {BackgroundJobError}.", e.Message); + } + } + }); + } + } + + /// + /// Runs a job. + /// + /// The job to run. + /// A task representing the job. + private async Task RunJobAsync(BackgroundJobBase job) + { + switch (job) + { + case ProcessContentJob processContentJob: + _ = await this._purviewClient.ProcessContentAsync(processContentJob.Request, CancellationToken.None).ConfigureAwait(false); + break; + case ContentActivityJob contentActivityJob: + _ = await this._purviewClient.SendContentActivitiesAsync(contentActivityJob.Request, CancellationToken.None).ConfigureAwait(false); + break; + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/CacheProvider.cs b/dotnet/src/Microsoft.Agents.AI.Purview/CacheProvider.cs new file mode 100644 index 0000000000..472b53c50b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/CacheProvider.cs @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Purview.Serialization; +using Microsoft.Extensions.Caching.Distributed; + +namespace Microsoft.Agents.AI.Purview; + +/// +/// Manages caching of values. +/// +internal sealed class CacheProvider : ICacheProvider +{ + private readonly IDistributedCache _cache; + private readonly PurviewSettings _purviewSettings; + + /// + /// Create a new instance of the class. + /// + /// The cache where the data is stored. + /// The purview integration settings. + public CacheProvider(IDistributedCache cache, PurviewSettings purviewSettings) + { + this._cache = cache; + this._purviewSettings = purviewSettings; + } + + /// + /// Get a value from the cache. + /// + /// The type of the key in the cache. Used for serialization. + /// The type of the value in the cache. Used for serialization. + /// The key to look up in the cache. + /// A cancellation token for the async operation. + /// The value in the cache. Null or default if no value is present. + public async Task GetAsync(TKey key, CancellationToken cancellationToken) + { + JsonTypeInfo keyTypeInfo = (JsonTypeInfo)PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(TKey)); + string serializedKey = JsonSerializer.Serialize(key, keyTypeInfo); + byte[]? data = await this._cache.GetAsync(serializedKey, cancellationToken).ConfigureAwait(false); + if (data == null) + { + return default; + } + + JsonTypeInfo valueTypeInfo = (JsonTypeInfo)PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(TValue)); + + return JsonSerializer.Deserialize(data, valueTypeInfo); + } + + /// + /// Set a value in the cache. + /// + /// The type of the key in the cache. Used for serialization. + /// The type of the value in the cache. Used for serialization. + /// The key to identify the cache entry. + /// The value to cache. + /// A cancellation token for the async operation. + /// A task for the async operation. + public Task SetAsync(TKey key, TValue value, CancellationToken cancellationToken) + { + JsonTypeInfo keyTypeInfo = (JsonTypeInfo)PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(TKey)); + string serializedKey = JsonSerializer.Serialize(key, keyTypeInfo); + JsonTypeInfo valueTypeInfo = (JsonTypeInfo)PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(TValue)); + byte[] serializedValue = JsonSerializer.SerializeToUtf8Bytes(value, valueTypeInfo); + + DistributedCacheEntryOptions cacheOptions = new() { AbsoluteExpirationRelativeToNow = this._purviewSettings.CacheTTL }; + + return this._cache.SetAsync(serializedKey, serializedValue, cacheOptions, cancellationToken); + } + + /// + /// Removes a value from the cache. + /// + /// The type of the key. + /// The key to identify the cache entry. + /// The cancellation token for the async operation. + /// A task for the async operation. + public Task RemoveAsync(TKey key, CancellationToken cancellationToken) + { + JsonTypeInfo keyTypeInfo = (JsonTypeInfo)PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(TKey)); + string serializedKey = JsonSerializer.Serialize(key, keyTypeInfo); + + return this._cache.RemoveAsync(serializedKey, cancellationToken); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/ChannelHandler.cs b/dotnet/src/Microsoft.Agents.AI.Purview/ChannelHandler.cs new file mode 100644 index 0000000000..ed3111fb3f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/ChannelHandler.cs @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading.Channels; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Purview.Models.Jobs; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Agents.AI.Purview; + +/// +/// Handler class for background job management. +/// +internal class ChannelHandler : IChannelHandler +{ + private readonly Channel _jobChannel; + private readonly List _channelListeners; + private readonly ILogger _logger; + private readonly PurviewSettings _purviewSettings; + + /// + /// Creates a new instance of JobHandler. + /// + /// The purview integration settings. + /// The logger used for logging job information. + /// The job channel used for queuing and reading background jobs. + public ChannelHandler(PurviewSettings purviewSettings, ILogger logger, Channel jobChannel) + { + this._purviewSettings = purviewSettings; + this._logger = logger; + this._jobChannel = jobChannel; + + this._channelListeners = new List(this._purviewSettings.MaxConcurrentJobConsumers); + } + + /// + public void QueueJob(BackgroundJobBase job) + { + try + { + if (job == null) + { + throw new PurviewJobException("Cannot queue null job."); + } + + if (this._channelListeners.Count == 0) + { + this._logger.LogWarning("No listeners are available to process the job."); + throw new PurviewJobException("No listeners are available to process the job."); + } + + bool canQueue = this._jobChannel.Writer.TryWrite(job); + + if (!canQueue) + { + int jobCount = this._jobChannel.Reader.Count; + this._logger.LogError("Could not queue a job for background processing."); + + if (this._jobChannel.Reader.Completion.IsCompleted) + { + throw new PurviewJobException("Job channel is closed or completed. Cannot queue job."); + } + else if (jobCount >= this._purviewSettings.PendingBackgroundJobLimit) + { + throw new PurviewJobLimitExceededException($"Job queue is full. Current pending jobs: {jobCount}. Maximum number of queued jobs: {this._purviewSettings.PendingBackgroundJobLimit}"); + } + else + { + throw new PurviewJobException("Could not queue job for background processing."); + } + } + } + catch (Exception e) + { + if (this._purviewSettings.IgnoreExceptions) + { + this._logger.LogError(e, "Error queuing job: {ExceptionMessage}", e.Message); + } + else + { + throw; + } + } + } + + /// + public void AddRunner(Func, Task> runnerTask) + { + this._channelListeners.Add(Task.Run(async () => await runnerTask(this._jobChannel).ConfigureAwait(false))); + } + + /// + public async Task StopAndWaitForCompletionAsync() + { + this._jobChannel.Writer.Complete(); + await this._jobChannel.Reader.Completion.ConfigureAwait(false); + await Task.WhenAll(this._channelListeners).ConfigureAwait(false); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Constants.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Constants.cs new file mode 100644 index 0000000000..610f0748bc --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Constants.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Purview; + +/// +/// Shared constants for the Purview service. +/// +internal static class Constants +{ + /// + /// The odata type property name used in requests and responses. + /// + public const string ODataTypePropertyName = "@odata.type"; + + /// + /// The OData Graph namespace used for odata types. + /// + public const string ODataGraphNamespace = "microsoft.graph"; + + /// + /// The name of the property that contains the conversation id. + /// + public const string ConversationId = "conversationId"; + + /// + /// The name of the property that contains the user id. + /// + public const string UserId = "userId"; +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewAuthenticationException.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewAuthenticationException.cs new file mode 100644 index 0000000000..83f80f3eb8 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewAuthenticationException.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.AI.Purview; + +/// +/// Exception for authentication errors related to Purview. +/// +public class PurviewAuthenticationException : PurviewException +{ + /// + public PurviewAuthenticationException(string message) + : base(message) + { + } + + /// + public PurviewAuthenticationException() : base() + { + } + + /// + public PurviewAuthenticationException(string? message, Exception? innerException) : base(message, innerException) + { + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewException.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewException.cs new file mode 100644 index 0000000000..36c859d9b1 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewException.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.AI.Purview; + +/// +/// General base exception type for Purview service errors. +/// +public class PurviewException : Exception +{ + /// + public PurviewException(string message) + : base(message) + { + } + + /// + public PurviewException() : base() + { + } + + /// + public PurviewException(string? message, Exception? innerException) : base(message, innerException) + { + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewJobException.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewJobException.cs new file mode 100644 index 0000000000..1737b70f1f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewJobException.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.AI.Purview; + +/// +/// Represents errors that occur during the execution of a Purview job. +/// +/// This exception is thrown when a Purview job encounters an error that prevents it from completing successfully. +internal class PurviewJobException : PurviewException +{ + /// + public PurviewJobException(string message) : base(message) + { + } + + /// + public PurviewJobException() : base() + { + } + + /// + public PurviewJobException(string? message, Exception? innerException) : base(message, innerException) + { + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewJobLimitExceededException.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewJobLimitExceededException.cs new file mode 100644 index 0000000000..7560000a55 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewJobLimitExceededException.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.AI.Purview; + +/// +/// Represents an exception that is thrown when the maximum number of concurrent Purview jobs has been exceeded. +/// +/// This exception indicates that the Purview service has reached its limit for concurrent job executions. +internal class PurviewJobLimitExceededException : PurviewJobException +{ + /// + public PurviewJobLimitExceededException(string message) : base(message) + { + } + + /// + public PurviewJobLimitExceededException() : base() + { + } + + /// + public PurviewJobLimitExceededException(string? message, Exception? innerException) : base(message, innerException) + { + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewPaymentRequiredException.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewPaymentRequiredException.cs new file mode 100644 index 0000000000..28a6c70323 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewPaymentRequiredException.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.AI.Purview; + +/// +/// Exception for payment required errors related to Purview. +/// +public class PurviewPaymentRequiredException : PurviewException +{ + /// + public PurviewPaymentRequiredException(string message) : base(message) + { + } + + /// + public PurviewPaymentRequiredException() : base() + { + } + + /// + public PurviewPaymentRequiredException(string? message, Exception? innerException) : base(message, innerException) + { + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewRateLimitException.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewRateLimitException.cs new file mode 100644 index 0000000000..71483886d2 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewRateLimitException.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.AI.Purview; + +/// +/// Exception for rate limit exceeded errors from Purview service. +/// +public class PurviewRateLimitException : PurviewException +{ + /// + public PurviewRateLimitException(string message) + : base(message) + { + } + + /// + public PurviewRateLimitException() : base() + { + } + + /// + public PurviewRateLimitException(string? message, Exception? innerException) : base(message, innerException) + { + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewRequestException.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewRequestException.cs new file mode 100644 index 0000000000..a34fad6ce4 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewRequestException.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Net; + +namespace Microsoft.Agents.AI.Purview; + +/// +/// Exception for general http request errors from Purview. +/// +public class PurviewRequestException : PurviewException +{ + /// + /// HTTP status code returned by the Purview service. + /// + public HttpStatusCode StatusCode { get; } + + /// + public PurviewRequestException(HttpStatusCode statusCode, string endpointName) + : base($"Failed to call {endpointName}. Status code: {statusCode}") + { + this.StatusCode = statusCode; + } + + /// + public PurviewRequestException(string message) + : base(message) + { + } + + /// + public PurviewRequestException() : base() + { + } + + /// + public PurviewRequestException(string? message, Exception? innerException) : base(message, innerException) + { + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/ICacheProvider.cs b/dotnet/src/Microsoft.Agents.AI.Purview/ICacheProvider.cs new file mode 100644 index 0000000000..6d6dad527c --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/ICacheProvider.cs @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.Purview; + +/// +/// Manages caching of values. +/// +internal interface ICacheProvider +{ + /// + /// Get a value from the cache. + /// + /// The type of the key in the cache. Used for serialization. + /// The type of the value in the cache. Used for serialization. + /// The key to look up in the cache. + /// A cancellation token for the async operation. + /// The value in the cache. Null or default if no value is present. + Task GetAsync(TKey key, CancellationToken cancellationToken); + + /// + /// Set a value in the cache. + /// + /// The type of the key in the cache. Used for serialization. + /// The type of the value in the cache. Used for serialization. + /// The key to identify the cache entry. + /// The value to cache. + /// A cancellation token for the async operation. + /// A task for the async operation. + Task SetAsync(TKey key, TValue value, CancellationToken cancellationToken); + + /// + /// Removes a value from the cache. + /// + /// The type of the key. + /// The key to identify the cache entry. + /// The cancellation token for the async operation. + /// A task for the async operation. + Task RemoveAsync(TKey key, CancellationToken cancellationToken); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/IChannelHandler.cs b/dotnet/src/Microsoft.Agents.AI.Purview/IChannelHandler.cs new file mode 100644 index 0000000000..d8593abd48 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/IChannelHandler.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Channels; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Purview.Models.Jobs; + +namespace Microsoft.Agents.AI.Purview; + +/// +/// Interface for a class that controls background job processing. +/// +internal interface IChannelHandler +{ + /// + /// Queue a job for background processing. + /// + /// The job queued for background processing. + void QueueJob(BackgroundJobBase job); + + /// + /// Add a runner to the channel handler. + /// + /// The runner task used to process jobs. + void AddRunner(Func, Task> runnerTask); + + /// + /// Stop the channel and wait for all runners to complete + /// + /// A task representing the job. + Task StopAndWaitForCompletionAsync(); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/IPurviewClient.cs b/dotnet/src/Microsoft.Agents.AI.Purview/IPurviewClient.cs new file mode 100644 index 0000000000..00de9051ef --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/IPurviewClient.cs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Purview.Models.Common; +using Microsoft.Agents.AI.Purview.Models.Requests; +using Microsoft.Agents.AI.Purview.Models.Responses; + +namespace Microsoft.Agents.AI.Purview; + +/// +/// Defines methods for interacting with the Purview service, including content processing, +/// protection scope management, and activity tracking. +/// +/// This interface provides methods to interact with various Purview APIs. It includes processing content, managing protection +/// scopes, and sending content activity data. Implementations of this interface are expected to handle communication +/// with the Purview service and manage any necessary authentication or error handling. +internal interface IPurviewClient +{ + /// + /// Get user info from auth token. + /// + /// The cancellation token used to cancel async processing. + /// The default tenant id used to retrieve the token and its info. + /// The token info from the token. + /// Throw if the token was invalid or could not be retrieved. + Task GetUserInfoFromTokenAsync(CancellationToken cancellationToken, string? tenantId = default); + + /// + /// Call ProcessContent API. + /// + /// The request containing the content to process. + /// The cancellation token used to cancel async processing. + /// The response from the Purview API. + /// Thrown for validation, auth, and network errors. + Task ProcessContentAsync(ProcessContentRequest request, CancellationToken cancellationToken); + + /// + /// Call user ProtectionScope API. + /// + /// The request containing the protection scopes metadata. + /// The cancellation token used to cancel async processing. + /// The protection scopes that apply to the data sent in the request. + /// Thrown for validation, auth, and network errors. + Task GetProtectionScopesAsync(ProtectionScopesRequest request, CancellationToken cancellationToken); + + /// + /// Call contentActivities API. + /// + /// The request containing the content metadata. Used to generate interaction records. + /// The cancellation token used to cancel async processing. + /// The response from the Purview API. + /// Thrown for validation, auth, and network errors. + Task SendContentActivitiesAsync(ContentActivitiesRequest request, CancellationToken cancellationToken); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/IScopedContentProcessor.cs b/dotnet/src/Microsoft.Agents.AI.Purview/IScopedContentProcessor.cs new file mode 100644 index 0000000000..059e7c4d2d --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/IScopedContentProcessor.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Purview.Models.Common; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Purview; + +/// +/// Orchestrates the processing of scoped content by combining protection scope, process content, and content activities operations. +/// +internal interface IScopedContentProcessor +{ + /// + /// Process a list of messages. + /// The list of messages should be a prompt or response. + /// + /// A list of objects sent to the agent or received from the agent.. + /// The thread where the messages were sent. + /// An activity to indicate prompt or response. + /// Purview settings containing tenant id, app name, etc. + /// The user who sent the prompt or is receiving the response. + /// Cancellation token. + /// A bool indicating if the request should be blocked and the user id of the user who made the request. + Task<(bool shouldBlock, string? userId)> ProcessMessagesAsync(IEnumerable messages, string? threadId, Activity activity, PurviewSettings purviewSettings, string? userId, CancellationToken cancellationToken); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Microsoft.Agents.AI.Purview.csproj b/dotnet/src/Microsoft.Agents.AI.Purview/Microsoft.Agents.AI.Purview.csproj new file mode 100644 index 0000000000..20eca86359 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Microsoft.Agents.AI.Purview.csproj @@ -0,0 +1,43 @@ + + + + $(ProjectsTargetFrameworks) + $(ProjectsDebugTargetFrameworks) + alpha + + + + true + true + true + + + + + + + + + + + + + + + + + + Microsoft.Agents.AI.Purview + Tools to connect generative AI apps to Microsoft Purview. + + + + + + + + + $(NoWarn);CA1812 + + + \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/AIAgentInfo.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/AIAgentInfo.cs new file mode 100644 index 0000000000..15c1fbab00 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/AIAgentInfo.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Info about an AI agent associated with the content. +/// +internal sealed class AIAgentInfo +{ + /// + /// Gets or sets agent id. + /// + [JsonPropertyName("identifier")] + public string? Identifier { get; set; } + + /// + /// Gets or sets agent name. + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// Gets or sets agent version. + /// + [JsonPropertyName("version")] + public string? Version { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/AIInteractionPlugin.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/AIInteractionPlugin.cs new file mode 100644 index 0000000000..d9b56f3911 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/AIInteractionPlugin.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Represents a plugin used in an AI interaction within the Purview SDK. +/// +internal sealed class AIInteractionPlugin +{ + /// + /// Gets or sets Plugin id. + /// + [JsonPropertyName("identifier")] + public string? Identifier { get; set; } + + /// + /// Gets or sets Plugin Name. + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// Gets or sets Plugin Version. + /// + [JsonPropertyName("version")] + public string? Version { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/AccessedResourceDetails.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/AccessedResourceDetails.cs new file mode 100644 index 0000000000..e9a18543c6 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/AccessedResourceDetails.cs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Information about a resource accessed during a conversation. +/// +internal sealed class AccessedResourceDetails +{ + /// + /// Resource ID. + /// + [JsonPropertyName("identifier")] + public string? Identifier { get; set; } + + /// + /// Resource name. + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// Resource URL. + /// + [JsonPropertyName("url")] + public string? Url { get; set; } + + /// + /// Sensitivity label id detected on the resource. + /// + [JsonPropertyName("labelId")] + public string? LabelId { get; set; } + + /// + /// Access type performed on the resource. + /// + [JsonPropertyName("accessType")] + public ResourceAccessType AccessType { get; set; } + + /// + /// Status of the access operation. + /// + [JsonPropertyName("status")] + public ResourceAccessStatus Status { get; set; } + + /// + /// Indicates if cross prompt injection was detected. + /// + [JsonPropertyName("isCrossPromptInjectionDetected")] + public bool? IsCrossPromptInjectionDetected { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/Activity.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/Activity.cs new file mode 100644 index 0000000000..5f9fdeb9d7 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/Activity.cs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Runtime.Serialization; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Activity definitions +/// +[DataContract] +[JsonConverter(typeof(JsonStringEnumConverter))] +internal enum Activity : int +{ + /// + /// Unknown activity + /// + [EnumMember(Value = "unknown")] + Unknown = 0, + + /// + /// Upload text + /// + [EnumMember(Value = "uploadText")] + UploadText = 1, + + /// + /// Upload file + /// + [EnumMember(Value = "uploadFile")] + UploadFile = 2, + + /// + /// Download text + /// + [EnumMember(Value = "downloadText")] + DownloadText = 3, + + /// + /// Download file + /// + [EnumMember(Value = "downloadFile")] + DownloadFile = 4, +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ActivityMetadata.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ActivityMetadata.cs new file mode 100644 index 0000000000..deefc24560 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ActivityMetadata.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Runtime.Serialization; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Request for metadata information +/// +[DataContract] +internal sealed class ActivityMetadata +{ + /// + /// Initializes a new instance of the class. + /// + /// The activity performed with the content. + public ActivityMetadata(Activity activity) + { + this.Activity = activity; + } + + /// + /// The activity performed with the content. + /// + [DataMember] + [JsonConverter(typeof(JsonStringEnumConverter))] + [JsonPropertyName("activity")] + public Activity Activity { get; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ClassificationErrorBase.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ClassificationErrorBase.cs new file mode 100644 index 0000000000..e52bf9ebb4 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ClassificationErrorBase.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Base error contract returned when some exception occurs. +/// +[JsonDerivedType(typeof(ProcessingError))] +internal class ClassificationErrorBase +{ + /// + /// Gets or sets the error code. + /// + [JsonPropertyName("code")] + public string? ErrorCode { get; set; } + + /// + /// Gets or sets the message. + /// + [JsonPropertyName("message")] + public string? Message { get; set; } + + /// + /// Gets or sets target of error. + /// + [JsonPropertyName("target")] + public string? Target { get; set; } + + /// + /// Gets or sets an object containing more specific information than the current object about the error. + /// It can't be a Dictionary because OData will make ClassificationErrorBase open type. It's not expected behavior. + /// + [JsonPropertyName("innerError")] + public ClassificationInnerError? InnerError { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ClassificationInnerError.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ClassificationInnerError.cs new file mode 100644 index 0000000000..1133529188 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ClassificationInnerError.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Inner classification error. +/// +internal sealed class ClassificationInnerError +{ + /// + /// Gets or sets date of error. + /// + [JsonPropertyName("date")] + public DateTime? Date { get; set; } + + /// + /// Gets or sets error code. + /// + [JsonPropertyName("code")] + public string? ErrorCode { get; set; } + + /// + /// Gets or sets client request ID. + /// + [JsonPropertyName("clientRequestId")] + public string? ClientRequestId { get; set; } + + /// + /// Gets or sets Activity ID. + /// + [JsonPropertyName("activityId")] + public string? ActivityId { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ContentBase.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ContentBase.cs new file mode 100644 index 0000000000..9619d27fc8 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ContentBase.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Base class for content items to be processed by the Purview SDK. +/// +[JsonDerivedType(typeof(PurviewTextContent))] +[JsonDerivedType(typeof(PurviewBinaryContent))] +internal abstract class ContentBase : GraphDataTypeBase +{ + /// + /// Creates a new instance of the class. + /// + /// The graph data type of the content. + public ContentBase(string dataType) : base(dataType) + { + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ContentProcessingErrorType.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ContentProcessingErrorType.cs new file mode 100644 index 0000000000..3d57a02aee --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ContentProcessingErrorType.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Type of error that occurred during content processing. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +internal enum ContentProcessingErrorType +{ + /// + /// Error is transient. + /// + Transient, + + /// + /// Error is permanent. + /// + Permanent, + + /// + /// Unknown future value placeholder. + /// + UnknownFutureValue +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ContentToProcess.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ContentToProcess.cs new file mode 100644 index 0000000000..9e2e5824f3 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ContentToProcess.cs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Runtime.Serialization; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Content to be processed by process content. +/// +internal sealed class ContentToProcess +{ + /// + /// Creates a new instance of ContentToProcess. + /// + /// The content to send and its associated ids. + /// Metadata about the activity performed with the content. + /// Metadata about the device that produced the content. + /// Metadata about the application integrating with Purview. + /// Metadata about the application being protected by Purview. + public ContentToProcess( + List contentEntries, + ActivityMetadata activityMetadata, + DeviceMetadata deviceMetadata, + IntegratedAppMetadata integratedAppMetadata, + ProtectedAppMetadata protectedAppMetadata) + { + this.ContentEntries = contentEntries; + this.ActivityMetadata = activityMetadata; + this.DeviceMetadata = deviceMetadata; + this.IntegratedAppMetadata = integratedAppMetadata; + this.ProtectedAppMetadata = protectedAppMetadata; + } + + /// + /// Gets or sets the content entries. + /// List of activities supported by caller. It is used to trim response to activities interesting to the caller. + /// + [JsonPropertyName("contentEntries")] + public List ContentEntries { get; set; } + + /// + /// Activity metadata + /// + [DataMember] + [JsonPropertyName("activityMetadata")] + public ActivityMetadata ActivityMetadata { get; set; } + + /// + /// Device metadata + /// + [DataMember] + [JsonPropertyName("deviceMetadata")] + public DeviceMetadata DeviceMetadata { get; set; } + + /// + /// Integrated app metadata + /// + [DataMember] + [JsonPropertyName("integratedAppMetadata")] + public IntegratedAppMetadata IntegratedAppMetadata { get; set; } + + /// + /// Protected app metadata + /// + [DataMember] + [JsonPropertyName("protectedAppMetadata")] + public ProtectedAppMetadata ProtectedAppMetadata { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/DeviceMetadata.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/DeviceMetadata.cs new file mode 100644 index 0000000000..3a60686be3 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/DeviceMetadata.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Endpoint device Metdata +/// +internal sealed class DeviceMetadata +{ + /// + /// Device type + /// + [JsonPropertyName("deviceType")] + public string? DeviceType { get; set; } + + /// + /// The ip address of the device. + /// + [JsonPropertyName("ipAddress")] + public string? IpAddress { get; set; } + + /// + /// OS specifications + /// + [JsonPropertyName("operatingSystemSpecifications")] + public OperatingSystemSpecifications? OperatingSystemSpecifications { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/DlpAction.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/DlpAction.cs new file mode 100644 index 0000000000..8eda013588 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/DlpAction.cs @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Defines all the actions for DLP. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +internal enum DlpAction +{ + /// + /// The DLP action to notify user. + /// + NotifyUser, + + /// + /// The DLP action is block. + /// + BlockAccess, + + /// + /// The DLP action to apply restrictions on device. + /// + DeviceRestriction, + + /// + /// The DLP action to apply restrictions on browsers. + /// + BrowserRestriction, + + /// + /// The DLP action to generate an alert + /// + GenerateAlert, + + /// + /// The DLP action to generate an incident report + /// + GenerateIncidentReportAction, + + /// + /// The DLP action to block anonymous link access in SPO + /// + SPBlockAnonymousAccess, + + /// + /// DLP Action to disallow guest access in SPO + /// + SPRuntimeAccessControl, + + /// + /// DLP No Op action for NotifyUser. Used in Block Access V2 rule + /// + SPSharingNotifyUser, + + /// + /// DLP No Op action for GIR. Used in Block Access V2 rule + /// + SPSharingGenerateIncidentReport, + + /// + /// Restrict access action for data in motion scenarios. + /// Advanced version of BlockAccess which can take both enforced restriction mode (Audit, Block, etc.) + /// and action triggers (Print, SaveToLocal, etc.) as parameters. + /// + RestrictAccess, +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/DlpActionInfo.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/DlpActionInfo.cs new file mode 100644 index 0000000000..a5846acadc --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/DlpActionInfo.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Base class to define DLP Actions. +/// +internal sealed class DlpActionInfo +{ + /// + /// Gets or sets the type of the DLP action. + /// + [JsonPropertyName("action")] + public DlpAction Action { get; set; } + + /// + /// The type of restriction action to take. + /// + [JsonPropertyName("restrictionAction")] + public RestrictionAction? RestrictionAction { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ErrorDetails.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ErrorDetails.cs new file mode 100644 index 0000000000..dd79ee13ce --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ErrorDetails.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Represents the details of an error. +/// +internal sealed class ErrorDetails +{ + /// + /// Gets or sets the error code. + /// + [JsonPropertyName("code")] + public string? Code { get; set; } + + /// + /// Gets or sets the error message. + /// + [JsonPropertyName("message")] + public string? Message { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ExecutionMode.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ExecutionMode.cs new file mode 100644 index 0000000000..3fecfbb3f4 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ExecutionMode.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Request execution mode +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +internal enum ExecutionMode : int +{ + /// + /// Evaluate inline. + /// + EvaluateInline = 1, + + /// + /// Evaluate offline. + /// + EvaluateOffline = 2, + + /// + /// Unknown future value. + /// + UnknownFutureValue = 3 +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/GraphDataTypeBase.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/GraphDataTypeBase.cs new file mode 100644 index 0000000000..b4334fdb43 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/GraphDataTypeBase.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Base class for all graph data types used in the Purview SDK. +/// +internal abstract class GraphDataTypeBase +{ + /// + /// Create a new instance of the class. + /// + /// The data type of the graph object. + public GraphDataTypeBase(string dataType) + { + this.DataType = dataType; + } + + /// + /// The @odata.type property name used in the JSON representation of the object. + /// + [JsonPropertyName(Constants.ODataTypePropertyName)] + public string DataType { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/IntegratedAppMetadata.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/IntegratedAppMetadata.cs new file mode 100644 index 0000000000..1a5e8b5e13 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/IntegratedAppMetadata.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Runtime.Serialization; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Request for metadata information +/// +[JsonDerivedType(typeof(ProtectedAppMetadata))] +internal class IntegratedAppMetadata +{ + /// + /// Application name + /// + [DataMember] + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// Application version + /// + [DataMember] + [JsonPropertyName("version")] + public string? Version { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/OperatingSystemSpecifications.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/OperatingSystemSpecifications.cs new file mode 100644 index 0000000000..3ea8837177 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/OperatingSystemSpecifications.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Operating System Specifications +/// +internal sealed class OperatingSystemSpecifications +{ + /// + /// OS platform + /// + [JsonPropertyName("operatingSystemPlatform")] + public string? OperatingSystemPlatform { get; set; } + + /// + /// OS version + /// + [JsonPropertyName("operatingSystemVersion")] + public string? OperatingSystemVersion { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PolicyBinding.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PolicyBinding.cs new file mode 100644 index 0000000000..9898f62e01 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PolicyBinding.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Represents user scoping information, i.e. which users are affected by the policy. +/// +internal sealed class PolicyBinding +{ + /// + /// Gets or sets the users to be included. + /// + [JsonPropertyName("inclusions")] + public ICollection? Inclusions { get; set; } + + /// + /// Gets or sets the users to be excluded. + /// Exclusions may not be present in the response, thus this property is nullable. + /// + [JsonPropertyName("exclusions")] + public ICollection? Exclusions { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PolicyLocation.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PolicyLocation.cs new file mode 100644 index 0000000000..c0a40974e5 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PolicyLocation.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Represents a location to which policy is applicable. +/// +internal sealed class PolicyLocation : GraphDataTypeBase +{ + /// + /// Creates a new instance of the class. + /// + /// The graph data type of the PolicyLocation object. + /// THe value of the policy location: app id, domain, etc. + public PolicyLocation(string dataType, string value) : base(dataType) + { + this.Value = value; + } + + /// + /// Gets or sets the applicable value for location. + /// + [JsonPropertyName("value")] + public string Value { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PolicyPivotProperty.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PolicyPivotProperty.cs new file mode 100644 index 0000000000..d56a374842 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PolicyPivotProperty.cs @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Runtime.Serialization; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Property for policy scoping response to aggregate on +/// +[DataContract] +[JsonConverter(typeof(JsonStringEnumConverter))] +internal enum PolicyPivotProperty : int +{ + /// + /// Unknown activity + /// + [EnumMember] + [JsonPropertyName("none")] + None = 0, + + /// + /// Pivot on Activity + /// + [EnumMember] + [JsonPropertyName("activity")] + Activity = 1, + + /// + /// Pivot on location + /// + [EnumMember] + [JsonPropertyName("location")] + Location = 2, + + /// + /// Pivot on location + /// + [EnumMember] + [JsonPropertyName("unknownFutureValue")] + UnknownFutureValue = 3, +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PolicyScope.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PolicyScope.cs new file mode 100644 index 0000000000..f00e941d35 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PolicyScope.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Represents a scope for policy protection. +/// +internal sealed class PolicyScopeBase +{ + /// + /// Gets or sets the locations to be protected, e.g. domains or URLs. + /// + [JsonPropertyName("locations")] + public ICollection? Locations { get; set; } + + /// + /// Gets or sets the activities to be protected, e.g. uploadText, downloadText. + /// + [JsonPropertyName("activities")] + public ProtectionScopeActivities Activities { get; set; } + + /// + /// Gets or sets how policy should be executed - fire-and-forget or wait for completion. + /// + [JsonPropertyName("executionMode")] + public ExecutionMode ExecutionMode { get; set; } + + /// + /// Gets or sets the enforcement actions to be taken on activities and locations from this scope. + /// There may be no actions in the response. + /// + [JsonPropertyName("policyActions")] + public ICollection? PolicyActions { get; set; } + + /// + /// Gets or sets information about policy applicability to a specific user. + /// + [JsonPropertyName("policyScope")] + public PolicyBinding? PolicyScope { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessContentMetadataBase.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessContentMetadataBase.cs new file mode 100644 index 0000000000..51f4936e82 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessContentMetadataBase.cs @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Base class for process content metadata. +/// +[JsonDerivedType(typeof(ProcessConversationMetadata))] +[JsonDerivedType(typeof(ProcessFileMetadata))] +internal abstract class ProcessContentMetadataBase : GraphDataTypeBase +{ + private const string ProcessConversationMetadataDataType = Constants.ODataGraphNamespace + ".processConversationMetadata"; + + /// + /// Creates a new instance of ProcessContentMetadataBase. + /// + /// The content that will be processed. + /// The unique identifier for the content. + /// Indicates if the content is truncated. + /// The name of the content. + public ProcessContentMetadataBase(ContentBase content, string identifier, bool isTruncated, string name) : base(ProcessConversationMetadataDataType) + { + this.Identifier = identifier; + this.IsTruncated = isTruncated; + this.Content = content; + this.Name = name; + } + + /// + /// Gets or sets the identifier. + /// Unique id for the content. It is specific to the enforcement plane. Path is used as item unique identifier, e.g., guid of a message in the conversation, file URL, storage file path, message ID, etc. + /// + [JsonPropertyName("identifier")] + public string Identifier { get; set; } + + /// + /// Gets or sets the content. + /// The content to be processed. + /// + [JsonPropertyName("content")] + public ContentBase Content { get; set; } + + /// + /// Gets or sets the name. + /// Name of the content, e.g., file name or web page title. + /// + [JsonPropertyName("name")] + public string Name { get; set; } + + /// + /// Gets or sets the correlationId. + /// Identifier to group multiple contents. + /// + [JsonPropertyName("correlationId")] + public string? CorrelationId { get; set; } + + /// + /// Gets or sets the sequenceNumber. + /// Sequence in which the content was originally generated. + /// + [JsonPropertyName("sequenceNumber")] + public long? SequenceNumber { get; set; } + + /// + /// Gets or sets the length. + /// Content length in bytes. + /// + [JsonPropertyName("length")] + public long? Length { get; set; } + + /// + /// Gets or sets the isTruncated. + /// Indicates if the original content has been truncated, e.g., to meet text or file size limits. + /// + [JsonPropertyName("isTruncated")] + public bool IsTruncated { get; set; } + + /// + /// Gets or sets the createdDateTime. + /// When the content was created. E.g., file created time or the time when a message was sent. + /// + [JsonPropertyName("createdDateTime")] + public DateTimeOffset CreatedDateTime { get; set; } = DateTime.UtcNow; + + /// + /// Gets or sets the modifiedDateTime. + /// When the content was last modified. E.g., file last modified time. For content created on the fly, such as messaging, whenModified and whenCreated are expected to be the same. + /// + [JsonPropertyName("modifiedDateTime")] + public DateTimeOffset? ModifiedDateTime { get; set; } = DateTime.UtcNow; +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessConversationMetadata.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessConversationMetadata.cs new file mode 100644 index 0000000000..86bedb9248 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessConversationMetadata.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Represents metadata for conversation content to be processed by the Purview SDK. +/// +internal sealed class ProcessConversationMetadata : ProcessContentMetadataBase +{ + private const string ProcessConversationMetadataDataType = Constants.ODataGraphNamespace + ".processConversationMetadata"; + + /// + /// Initializes a new instance of the class. + /// + public ProcessConversationMetadata(ContentBase contentBase, string identifier, bool isTruncated, string name) : base(contentBase, identifier, isTruncated, name) + { + this.DataType = ProcessConversationMetadataDataType; + } + + /// + /// Gets or sets the parent message ID for nested conversations. + /// + [JsonPropertyName("parentMessageId")] + public string? ParentMessageId { get; set; } + + /// + /// Gets or sets the accessed resources during message generation for bot messages. + /// + [JsonPropertyName("accessedResources_v2")] + public List? AccessedResources { get; set; } + + /// + /// Gets or sets the plugins used during message generation for bot messages. + /// + [JsonPropertyName("plugins")] + public List? Plugins { get; set; } + + /// + /// Gets or sets the collection of AI agent information. + /// + [JsonPropertyName("agents")] + public List? Agents { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessFileMetadata.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessFileMetadata.cs new file mode 100644 index 0000000000..a9f1749bed --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessFileMetadata.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Represents metadata for a file content to be processed by the Purview SDK. +/// +internal sealed class ProcessFileMetadata : ProcessContentMetadataBase +{ + private const string ProcessFileMetadataDataType = Constants.ODataGraphNamespace + ".processFileMetadata"; + + /// + /// Initializes a new instance of the class. + /// + public ProcessFileMetadata(ContentBase contentBase, string identifier, bool isTruncated, string name) : base(contentBase, identifier, isTruncated, name) + { + this.DataType = ProcessFileMetadataDataType; + } + + /// + /// Gets or sets the owner ID. + /// + [JsonPropertyName("ownerId")] + public string? OwnerId { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessingError.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessingError.cs new file mode 100644 index 0000000000..4852d5ca8a --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessingError.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Contains information about a processing error. +/// +internal sealed class ProcessingError : ClassificationErrorBase +{ + /// + /// Details about the error. + /// + [JsonPropertyName("details")] + public List? Details { get; set; } + + /// + /// Gets or sets the error type. + /// + [JsonPropertyName("type")] + public ContentProcessingErrorType? Type { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProtectedAppMetadata.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProtectedAppMetadata.cs new file mode 100644 index 0000000000..984a4168e7 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProtectedAppMetadata.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Represents metadata for a protected application that is integrated with Purview. +/// +internal sealed class ProtectedAppMetadata : IntegratedAppMetadata +{ + /// + /// Creates a new instance of the class. + /// + /// The location information of the protected app's data. + public ProtectedAppMetadata(PolicyLocation applicationLocation) + { + this.ApplicationLocation = applicationLocation; + } + + /// + /// The location of the application. + /// + [JsonPropertyName("applicationLocation")] + public PolicyLocation ApplicationLocation { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProtectionScopeActivities.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProtectionScopeActivities.cs new file mode 100644 index 0000000000..6c93a76124 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProtectionScopeActivities.cs @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Runtime.Serialization; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Activities that can be protected by the Purview Protection Scopes API. +/// +[Flags] +[DataContract] +[JsonConverter(typeof(JsonStringEnumConverter))] +internal enum ProtectionScopeActivities +{ + /// + /// None. + /// + [EnumMember(Value = "none")] + None = 0, + + /// + /// Upload text activity. + /// + [EnumMember(Value = "uploadText")] + UploadText = 1, + + /// + /// Upload file activity. + /// + [EnumMember(Value = "uploadFile")] + UploadFile = 2, + + /// + /// Download text activity. + /// + [EnumMember(Value = "downloadText")] + DownloadText = 4, + + /// + /// Download file activity. + /// + [EnumMember(Value = "downloadFile")] + DownloadFile = 8, + + /// + /// Unknown future value. + /// + [EnumMember(Value = "unknownFutureValue")] + UnknownFutureValue = 16 +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProtectionScopeState.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProtectionScopeState.cs new file mode 100644 index 0000000000..8fc7a534ad --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProtectionScopeState.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Indicates status of protection scope changes. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +internal enum ProtectionScopeState +{ + /// + /// Scope state hasn't changed. + /// + NotModified = 0, + + /// + /// Scope state has changed. + /// + Modified = 1, + + /// + /// Unknown value placeholder for future use. + /// + UnknownFutureValue = 2 +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProtectionScopesCacheKey.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProtectionScopesCacheKey.cs new file mode 100644 index 0000000000..2c772cbcb0 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProtectionScopesCacheKey.cs @@ -0,0 +1,91 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Linq; +using Microsoft.Agents.AI.Purview.Models.Requests; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// A cache key for storing protection scope responses. +/// +internal sealed class ProtectionScopesCacheKey +{ + /// + /// Creates a new instance of . + /// + /// The entra id of the user who made the interaction. + /// The tenant id of the user who made the interaction. + /// The activity performed with the data. + /// The location where the data came from. + /// The property to pivot on. + /// Metadata about the device that made the interaction. + /// Metadata about the app that is integrating with Purview. + public ProtectionScopesCacheKey( + string userId, + string tenantId, + ProtectionScopeActivities activities, + PolicyLocation? location, + PolicyPivotProperty? pivotOn, + DeviceMetadata? deviceMetadata, + IntegratedAppMetadata? integratedAppMetadata) + { + this.UserId = userId; + this.TenantId = tenantId; + this.Activities = activities; + this.Location = location; + this.PivotOn = pivotOn; + this.DeviceMetadata = deviceMetadata; + this.IntegratedAppMetadata = integratedAppMetadata; + } + + /// + /// Creates a mew instance of . + /// + /// A protection scopes request. + public ProtectionScopesCacheKey( + ProtectionScopesRequest request) : this( + request.UserId, + request.TenantId, + request.Activities, + request.Locations.FirstOrDefault(), + request.PivotOn, + request.DeviceMetadata, + request.IntegratedAppMetadata) + { + } + + /// + /// The id of the user making the request. + /// + public string UserId { get; set; } + + /// + /// The id of the tenant containing the user making the request. + /// + public string TenantId { get; set; } + + /// + /// The activity performed with the content. + /// + public ProtectionScopeActivities Activities { get; set; } + + /// + /// The location of the application. + /// + public PolicyLocation? Location { get; set; } + + /// + /// The property used to pivot the policy evaluation. + /// + public PolicyPivotProperty? PivotOn { get; set; } + + /// + /// Metadata about the device used to access the content. + /// + public DeviceMetadata? DeviceMetadata { get; set; } + + /// + /// Metadata about the integrated app used to access the content. + /// + public IntegratedAppMetadata? IntegratedAppMetadata { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PurviewBinaryContent.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PurviewBinaryContent.cs new file mode 100644 index 0000000000..0d65ac341d --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PurviewBinaryContent.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Represents a binary content item to be processed. +/// +internal sealed class PurviewBinaryContent : ContentBase +{ + private const string BinaryContentDataType = Constants.ODataGraphNamespace + ".binaryContent"; + + /// + /// Initializes a new instance of the class. + /// + /// The binary content in byte array format. + public PurviewBinaryContent(byte[] data) : base(BinaryContentDataType) + { + this.Data = data; + } + + /// + /// Gets or sets the binary data. + /// + [JsonPropertyName("data")] + public byte[] Data { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PurviewTextContent.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PurviewTextContent.cs new file mode 100644 index 0000000000..cfd03ae6ce --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PurviewTextContent.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Represents a text content item to be processed. +/// +internal sealed class PurviewTextContent : ContentBase +{ + private const string TextContentDataType = Constants.ODataGraphNamespace + ".textContent"; + + /// + /// Initializes a new instance of the class. + /// + /// The text content in string format. + public PurviewTextContent(string data) : base(TextContentDataType) + { + this.Data = data; + } + + /// + /// Gets or sets the text data. + /// + [JsonPropertyName("data")] + public string Data { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ResourceAccessStatus.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ResourceAccessStatus.cs new file mode 100644 index 0000000000..623f138e8b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ResourceAccessStatus.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Runtime.Serialization; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Status of the access operation. +/// +[DataContract] +[JsonConverter(typeof(JsonStringEnumConverter))] +internal enum ResourceAccessStatus +{ + /// + /// Represents failed access to the resource. + /// + [EnumMember(Value = "failure")] + Failure = 0, + + /// + /// Represents successful access to the resource. + /// + [EnumMember(Value = "success")] + Success = 1, + + /// + /// Unknown future value. + /// + [EnumMember(Value = "unknownFutureValue")] + UnknownFutureValue = 2 +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ResourceAccessType.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ResourceAccessType.cs new file mode 100644 index 0000000000..cb4e3b0cab --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ResourceAccessType.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Runtime.Serialization; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Access type performed on the resource. +/// +[Flags] +[DataContract] +[JsonConverter(typeof(JsonStringEnumConverter))] +internal enum ResourceAccessType : long +{ + /// + /// No access type. + /// + [EnumMember(Value = "none")] + None = 0, + + /// + /// Read access. + /// + [EnumMember(Value = "read")] + Read = 1 << 0, + + /// + /// Write access. + /// + [EnumMember(Value = "write")] + Write = 1 << 1, + + /// + /// Create access. + /// + [EnumMember(Value = "create")] + Create = 1 << 2, + + /// + /// Unknown future value. + /// + [EnumMember(Value = "unknownFutureValue")] + UnknownFutureValue = 1 << 3 +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/RestrictionAction.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/RestrictionAction.cs new file mode 100644 index 0000000000..ea13ec36a6 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/RestrictionAction.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Restriction actions for devices. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +internal enum RestrictionAction +{ + /// + /// Warn Action. + /// + Warn, + + /// + /// Audit action. + /// + Audit, + + /// + /// Block action. + /// + Block, + + /// + /// Allow action + /// + Allow +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/Scope.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/Scope.cs new file mode 100644 index 0000000000..9fc4de38fe --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/Scope.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Represents tenant/user/group scopes. +/// +internal sealed class Scope +{ + /// + /// The odata type of the scope used to identify what type of scope was returned. + /// + [JsonPropertyName("@odata.type")] + public string? ODataType { get; set; } + + /// + /// Gets or sets the scope identifier. + /// + [JsonPropertyName("identity")] + public string? Identity { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/TokenInfo.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/TokenInfo.cs new file mode 100644 index 0000000000..bd1338dd64 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/TokenInfo.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Info pulled from an auth token. +/// +internal sealed class TokenInfo +{ + /// + /// The entra id of the authenticated user. This is null if the auth token is not a user token. + /// + public string? UserId { get; set; } + + /// + /// The tenant id of the auth token. + /// + public string? TenantId { get; set; } + + /// + /// The client id of the auth token. + /// + public string? ClientId { get; set; } + + /// + /// Gets a value indicating whether the token is associated with a user. + /// + public bool IsUserToken => !string.IsNullOrEmpty(this.UserId); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Jobs/BackgroundJobBase.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Jobs/BackgroundJobBase.cs new file mode 100644 index 0000000000..ab8cc8a588 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Jobs/BackgroundJobBase.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Purview.Models.Jobs; + +/// +/// Abstract base class for background jobs. +/// +internal abstract class BackgroundJobBase +{ +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Jobs/ContentActivityJob.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Jobs/ContentActivityJob.cs new file mode 100644 index 0000000000..513af7f331 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Jobs/ContentActivityJob.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.Purview.Models.Requests; + +namespace Microsoft.Agents.AI.Purview.Models.Jobs; + +/// +/// Class representing a job to send content activities to the Purview service. +/// +internal sealed class ContentActivityJob : BackgroundJobBase +{ + /// + /// Create a new instance of the class. + /// + /// The content activities request to be sent in the background. + public ContentActivityJob(ContentActivitiesRequest request) + { + this.Request = request; + } + + /// + /// The request to send to the Purview service. + /// + public ContentActivitiesRequest Request { get; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Jobs/ProcessContentJob.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Jobs/ProcessContentJob.cs new file mode 100644 index 0000000000..768588f9d7 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Jobs/ProcessContentJob.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.Purview.Models.Requests; + +namespace Microsoft.Agents.AI.Purview.Models.Jobs; + +/// +/// Class representing a job to process content. +/// +internal sealed class ProcessContentJob : BackgroundJobBase +{ + /// + /// Initializes a new instance of the class. + /// + /// The process content request to be sent in the background. + public ProcessContentJob(ProcessContentRequest request) + { + this.Request = request; + } + + /// + /// The request to process content. + /// + public ProcessContentRequest Request { get; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Requests/ContentActivitiesRequest.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Requests/ContentActivitiesRequest.cs new file mode 100644 index 0000000000..a754a5a56f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Requests/ContentActivitiesRequest.cs @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Text.Json.Serialization; +using Microsoft.Agents.AI.Purview.Models.Common; + +namespace Microsoft.Agents.AI.Purview.Models.Requests; + +/// +/// A request class used for contentActivity requests. +/// +internal sealed class ContentActivitiesRequest +{ + /// + /// Initializes a new instance of the class. + /// + /// The entra id of the user who performed the activity. + /// The tenant id of the user who performed the activity. + /// The metadata about the content that was sent. + /// The correlation id of the request. + /// The scope identifier of the protection scopes associated with this request. + public ContentActivitiesRequest(string userId, string tenantId, ContentToProcess contentMetadata, Guid correlationId = default, string? scopeIdentifier = null) + { + this.UserId = userId ?? throw new ArgumentNullException(nameof(userId)); + this.TenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId)); + this.ContentMetadata = contentMetadata ?? throw new ArgumentNullException(nameof(contentMetadata)); + this.CorrelationId = correlationId == default ? Guid.NewGuid() : correlationId; + this.ScopeIdentifier = scopeIdentifier; + } + + /// + /// Gets or sets the ID of the signal. + /// + [JsonPropertyName("id")] + public string Id { get; set; } = Guid.NewGuid().ToString(); + + /// + /// Gets or sets the user ID of the content that is generating the signal. + /// + [JsonPropertyName("userId")] + public string UserId { get; set; } + + /// + /// Gets or sets the scope identifier for the signal. + /// + [JsonPropertyName("scopeIdentifier")] + public string? ScopeIdentifier { get; set; } + + /// + /// Gets or sets the content and associated content metadata for the content used to generate the signal. + /// + [JsonPropertyName("contentMetadata")] + public ContentToProcess ContentMetadata { get; set; } + + /// + /// Gets or sets the correlation ID for the signal. + /// + [JsonIgnore] + public Guid CorrelationId { get; set; } + + /// + /// Gets or sets the tenant id for the signal. + /// + [JsonIgnore] + public string TenantId { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Requests/ProcessContentRequest.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Requests/ProcessContentRequest.cs new file mode 100644 index 0000000000..f8e9602cef --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Requests/ProcessContentRequest.cs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Text.Json.Serialization; +using Microsoft.Agents.AI.Purview.Models.Common; + +namespace Microsoft.Agents.AI.Purview.Models.Requests; + +/// +/// Request for ProcessContent API +/// +internal sealed class ProcessContentRequest +{ + /// + /// Creates a new instance of ProcessContentRequest. + /// + /// The content and its metadata that will be processed. + /// The entra user id of the user making the request. + /// The tenant id of the user making the request. + public ProcessContentRequest(ContentToProcess contentToProcess, string userId, string tenantId) + { + this.ContentToProcess = contentToProcess; + this.UserId = userId; + this.TenantId = tenantId; + } + + /// + /// The content to process. + /// + [JsonPropertyName("contentToProcess")] + public ContentToProcess ContentToProcess { get; set; } + + /// + /// The user id of the user making the request. + /// + [JsonIgnore] + public string UserId { get; set; } + + /// + /// The correlation id of the request. + /// + [JsonIgnore] + public Guid CorrelationId { get; set; } = Guid.NewGuid(); + + /// + /// The tenant id of the user making the request. + /// + [JsonIgnore] + public string TenantId { get; set; } + + /// + /// The identifier of the cached protection scopes. + /// + [JsonIgnore] + internal string? ScopeIdentifier { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Requests/ProtectionScopesRequest.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Requests/ProtectionScopesRequest.cs new file mode 100644 index 0000000000..04aba59aff --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Requests/ProtectionScopesRequest.cs @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Runtime.Serialization; +using System.Text.Json.Serialization; +using Microsoft.Agents.AI.Purview.Models.Common; + +namespace Microsoft.Agents.AI.Purview.Models.Requests; + +/// +/// Request model for user protection scopes requests. +/// +[DataContract] +internal sealed class ProtectionScopesRequest +{ + /// + /// Creates a new instance of ProtectionScopesRequest. + /// + /// The entra id of the user who made the interaction. + /// The tenant id of the user who made the interaction. + public ProtectionScopesRequest(string userId, string tenantId) + { + this.UserId = userId; + this.TenantId = tenantId; + } + + /// + /// Activities to include in the scope + /// + [DataMember] + [JsonPropertyName("activities")] + public ProtectionScopeActivities Activities { get; set; } + + /// + /// Gets or sets the locations to compute protection scopes for. + /// + [JsonPropertyName("locations")] + public ICollection Locations { get; set; } = Array.Empty(); + + /// + /// Response aggregation pivot + /// + [DataMember] + [JsonPropertyName("pivotOn")] + public PolicyPivotProperty? PivotOn { get; set; } + + /// + /// Device metadata + /// + [DataMember] + [JsonPropertyName("deviceMetadata")] + public DeviceMetadata? DeviceMetadata { get; set; } + + /// + /// Integrated app metadata + /// + [DataMember] + [JsonPropertyName("integratedAppMetadata")] + public IntegratedAppMetadata? IntegratedAppMetadata { get; set; } + + /// + /// The correlation id of the request. + /// + [JsonIgnore] + public Guid CorrelationId { get; set; } = Guid.NewGuid(); + + /// + /// Scope ID, used to detect stale client scoping information + /// + [DataMember] + [JsonIgnore] + public string ScopeIdentifier { get; set; } = string.Empty; + + /// + /// The id of the user making the request. + /// + [JsonIgnore] + public string UserId { get; set; } + + /// + /// The tenant id of the user making the request. + /// + [JsonIgnore] + public string TenantId { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Responses/ContentActivitiesResponse.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Responses/ContentActivitiesResponse.cs new file mode 100644 index 0000000000..afdc21618e --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Responses/ContentActivitiesResponse.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Net; +using System.Text.Json.Serialization; +using Microsoft.Agents.AI.Purview.Models.Common; + +namespace Microsoft.Agents.AI.Purview.Models.Responses; + +/// +/// Represents the response for content activities requests. +/// +internal sealed class ContentActivitiesResponse +{ + /// + /// Gets or sets the HTTP status code associated with the response. + /// + [JsonIgnore] + public HttpStatusCode StatusCode { get; set; } + + /// + /// Details about any errors returned by the request. + /// + [JsonPropertyName("error")] + public ErrorDetails? Error { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Responses/ProcessContentResponse.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Responses/ProcessContentResponse.cs new file mode 100644 index 0000000000..c685c7786f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Responses/ProcessContentResponse.cs @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Runtime.Serialization; +using System.Text.Json.Serialization; +using Microsoft.Agents.AI.Purview.Models.Common; + +namespace Microsoft.Agents.AI.Purview.Models.Responses; + +/// +/// The response of a process content evaluation. +/// +internal sealed class ProcessContentResponse +{ + /// + /// Gets or sets the evaluation id. + /// + [Key] + public string? Id { get; set; } + + /// + /// Gets or sets the status of protection scope changes. + /// + [DataMember] + [JsonPropertyName("protectionScopeState")] + public ProtectionScopeState? ProtectionScopeState { get; set; } + + /// + /// Gets or sets the policy actions to take. + /// + [DataMember] + [JsonPropertyName("policyActions")] + public IReadOnlyList? PolicyActions { get; set; } + + /// + /// Gets or sets error information about the evaluation. + /// + [DataMember] + [JsonPropertyName("processingErrors")] + public IReadOnlyList? ProcessingErrors { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Responses/ProtectionScopesResponse.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Responses/ProtectionScopesResponse.cs new file mode 100644 index 0000000000..fb9b0603d8 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Responses/ProtectionScopesResponse.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json.Serialization; +using Microsoft.Agents.AI.Purview.Models.Common; + +namespace Microsoft.Agents.AI.Purview.Models.Responses; + +/// +/// A response object containing protection scopes for a tenant. +/// +internal sealed class ProtectionScopesResponse +{ + /// + /// The identifier used for caching the user protection scopes. + /// + public string? ScopeIdentifier { get; set; } + + /// + /// The user protection scopes. + /// + [JsonPropertyName("value")] + public IReadOnlyCollection? Scopes { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/PurviewAgent.cs b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewAgent.cs new file mode 100644 index 0000000000..fd2a1950e9 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewAgent.cs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Purview; + +/// +/// A middleware agent that connects to Microsoft Purview. +/// +internal class PurviewAgent : AIAgent, IDisposable +{ + private readonly AIAgent _innerAgent; + private readonly PurviewWrapper _purviewWrapper; + + /// + /// Initializes a new instance of the class. + /// + /// The agent-framework agent that the middleware wraps. + /// The purview wrapper used to interact with the Purview service. + public PurviewAgent(AIAgent innerAgent, PurviewWrapper purviewWrapper) + { + this._innerAgent = innerAgent; + this._purviewWrapper = purviewWrapper; + } + + /// + public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null) + { + return this._innerAgent.DeserializeThread(serializedThread, jsonSerializerOptions); + } + + /// + public override AgentThread GetNewThread() + { + return this._innerAgent.GetNewThread(); + } + + /// + public override Task RunAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + { + return this._purviewWrapper.ProcessAgentContentAsync(messages, thread, options, this._innerAgent, cancellationToken); + } + + /// + public override async IAsyncEnumerable RunStreamingAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var response = await this._purviewWrapper.ProcessAgentContentAsync(messages, thread, options, this._innerAgent, cancellationToken).ConfigureAwait(false); + foreach (var update in response.ToAgentRunResponseUpdates()) + { + yield return update; + } + } + + /// + public void Dispose() + { + if (this._innerAgent is IDisposable disposableAgent) + { + disposableAgent.Dispose(); + } + + this._purviewWrapper.Dispose(); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/PurviewAppLocation.cs b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewAppLocation.cs new file mode 100644 index 0000000000..5e5d7af96f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewAppLocation.cs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.Agents.AI.Purview.Models.Common; + +namespace Microsoft.Agents.AI.Purview; + +/// +/// An identifier representing the app's location for Purview policy evaluation. +/// +public class PurviewAppLocation +{ + /// + /// Creates a new instance of . + /// + /// The type of location. + /// The value of the location. + public PurviewAppLocation(PurviewLocationType locationType, string locationValue) + { + this.LocationType = locationType; + this.LocationValue = locationValue; + } + + /// + /// The type of location. + /// + public PurviewLocationType LocationType { get; set; } + + /// + /// The location value. + /// + public string LocationValue { get; set; } + + /// + /// Returns the model for this . + /// + /// PolicyLocation request model. + /// Thrown when an invalid location type is provided. + internal PolicyLocation GetPolicyLocation() + { + switch (this.LocationType) + { + case PurviewLocationType.Application: + return new PolicyLocation($"{Constants.ODataGraphNamespace}.policyLocationApplication", this.LocationValue); + case PurviewLocationType.Uri: + return new PolicyLocation($"{Constants.ODataGraphNamespace}.policyLocationUrl", this.LocationValue); + case PurviewLocationType.Domain: + return new PolicyLocation($"{Constants.ODataGraphNamespace}.policyLocationDomain", this.LocationValue); + default: + throw new InvalidOperationException("Invalid location type."); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/PurviewChatClient.cs b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewChatClient.cs new file mode 100644 index 0000000000..fded26c0ae --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewChatClient.cs @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Purview; + +/// +/// A middleware chat client that connects to Microsoft Purview. +/// +internal class PurviewChatClient : IChatClient +{ + private readonly IChatClient _innerChatClient; + private readonly PurviewWrapper _purviewWrapper; + + /// + /// Initializes a new instance of the class. + /// + /// The inner chat client to wrap. + /// The purview wrapper used to interact with the Purview service. + public PurviewChatClient(IChatClient innerChatClient, PurviewWrapper purviewWrapper) + { + this._innerChatClient = innerChatClient; + this._purviewWrapper = purviewWrapper; + } + + /// + public void Dispose() + { + this._purviewWrapper.Dispose(); + this._innerChatClient.Dispose(); + } + + /// + public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + return this._purviewWrapper.ProcessChatContentAsync(messages, options, this._innerChatClient, cancellationToken); + } + + /// + public object? GetService(Type serviceType, object? serviceKey = null) + { + return this._innerChatClient.GetService(serviceType, serviceKey); + } + + /// + public async IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + Task responseTask = this._purviewWrapper.ProcessChatContentAsync(messages, options, this._innerChatClient, cancellationToken); + + foreach (var update in (await responseTask.ConfigureAwait(false)).ToChatResponseUpdates()) + { + yield return update; + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/PurviewClient.cs b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewClient.cs new file mode 100644 index 0000000000..7fade4eabb --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewClient.cs @@ -0,0 +1,311 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using System.Threading; +using System.Threading.Tasks; +using Azure.Core; +using Microsoft.Agents.AI.Purview.Models.Common; +using Microsoft.Agents.AI.Purview.Models.Requests; +using Microsoft.Agents.AI.Purview.Models.Responses; +using Microsoft.Agents.AI.Purview.Serialization; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Microsoft.Agents.AI.Purview; + +/// +/// Client for calling Purview APIs. +/// +internal sealed class PurviewClient : IPurviewClient +{ + private readonly TokenCredential _tokenCredential; + private readonly HttpClient _httpClient; + private readonly string[] _scopes; + private readonly string _graphUri; + private readonly ILogger _logger; + + private static PurviewException CreateExceptionForStatusCode(HttpStatusCode statusCode, string endpointName) + { + // .net framework does not support TooManyRequests, so we have to convert to an int. + switch ((int)statusCode) + { + case 429: + return new PurviewRateLimitException($"Rate limit exceeded for {endpointName}."); + case 401: + case 403: + return new PurviewAuthenticationException($"Unauthorized access to {endpointName}. Status code: {statusCode}"); + case 402: + return new PurviewPaymentRequiredException($"Payment required for {endpointName}. Status code: {statusCode}"); + default: + return new PurviewRequestException(statusCode, endpointName); + } + } + + /// + /// Creates a new instance. + /// + /// The token credential used to authenticate with Purview. + /// The settings used for purview requests. + /// The HttpClient used to make network requests to Purview. + /// The logger used to log information from the middleware. + public PurviewClient(TokenCredential tokenCredential, PurviewSettings purviewSettings, HttpClient httpClient, ILogger logger) + { + this._tokenCredential = tokenCredential; + this._httpClient = httpClient; + + this._scopes = new string[] { $"https://{purviewSettings.GraphBaseUri.Host}/.default" }; + this._graphUri = purviewSettings.GraphBaseUri.ToString().TrimEnd('/'); + this._logger = logger ?? NullLogger.Instance; + } + + private static TokenInfo ExtractTokenInfo(string tokenString) + { + // Split JWT and decode payload + string[] parts = tokenString.Split('.'); + if (parts.Length < 2) + { + throw new PurviewRequestException("Invalid JWT access token format."); + } + + string payload = parts[1]; + // Pad base64 string if needed + int mod4 = payload.Length % 4; + if (mod4 > 0) + { + payload += new string('=', 4 - mod4); + } + + byte[] bytes = Convert.FromBase64String(payload.Replace('-', '+').Replace('_', '/')); + string json = Encoding.UTF8.GetString(bytes); + + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + string? objectId = root.TryGetProperty("oid", out var oidProp) ? oidProp.GetString() : null; + string? idType = root.TryGetProperty("idtyp", out var idtypProp) ? idtypProp.GetString() : null; + string? tenant = root.TryGetProperty("tid", out var tidProp) ? tidProp.GetString() : null; + string? clientId = root.TryGetProperty("appid", out var appidProp) ? appidProp.GetString() : null; + + string? userId = idType == "user" ? objectId : null; + + return new TokenInfo + { + UserId = userId, + TenantId = tenant, + ClientId = clientId + }; + } + + /// + public async Task GetUserInfoFromTokenAsync(CancellationToken cancellationToken, string? tenantId = default) + { + TokenRequestContext tokenRequestContext = tenantId == null ? new(this._scopes) : new(this._scopes, tenantId: tenantId); + AccessToken token = await this._tokenCredential.GetTokenAsync(tokenRequestContext, cancellationToken).ConfigureAwait(false); + + string tokenString = token.Token; + + return ExtractTokenInfo(tokenString); + } + + /// + public async Task ProcessContentAsync(ProcessContentRequest request, CancellationToken cancellationToken) + { + var token = await this._tokenCredential.GetTokenAsync(new TokenRequestContext(this._scopes, tenantId: request.TenantId), cancellationToken).ConfigureAwait(false); + string userId = request.UserId; + + string uri = $"{this._graphUri}/users/{userId}/dataSecurityAndGovernance/processContent"; + + using (HttpRequestMessage message = new(HttpMethod.Post, new Uri(uri))) + { + message.Headers.Add("Authorization", $"Bearer {token.Token}"); + message.Headers.Add("User-Agent", "agent-framework-dotnet"); + + if (request.ScopeIdentifier != null) + { + message.Headers.Add("If-None-Match", request.ScopeIdentifier); + } + + string content = JsonSerializer.Serialize(request, PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ProcessContentRequest))); + message.Content = new StringContent(content, Encoding.UTF8, "application/json"); + + HttpResponseMessage response; + try + { + response = await this._httpClient.SendAsync(message, cancellationToken).ConfigureAwait(false); + } + catch (HttpRequestException e) + { + this._logger.LogError(e, "Http error while processing content."); + throw new PurviewRequestException("Http error occurred while processing content.", e); + } + +#if NET5_0_OR_GREATER + // Pass the cancellation token if that method is available. + string responseContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); +#else + string responseContent = await response.Content.ReadAsStringAsync().ConfigureAwait(false); +#endif + + if (response.StatusCode == HttpStatusCode.OK || response.StatusCode == HttpStatusCode.Accepted) + { + ProcessContentResponse? deserializedResponse; + try + { + JsonTypeInfo typeInfo = (JsonTypeInfo)PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ProcessContentResponse)); + deserializedResponse = JsonSerializer.Deserialize(responseContent, typeInfo); + } + catch (JsonException jsonException) + { + const string DeserializeExceptionError = "Failed to deserialize ProcessContent response."; + this._logger.LogError(jsonException, DeserializeExceptionError); + throw new PurviewRequestException(DeserializeExceptionError, jsonException); + } + + if (deserializedResponse != null) + { + return deserializedResponse; + } + + const string DeserializeError = "Failed to deserialize ProcessContent response. Response was null."; + this._logger.LogError(DeserializeError); + throw new PurviewRequestException(DeserializeError); + } + + this._logger.LogError("Failed to process content. Status code: {StatusCode}", response.StatusCode); + throw CreateExceptionForStatusCode(response.StatusCode, "processContent"); + } + } + + /// + public async Task GetProtectionScopesAsync(ProtectionScopesRequest request, CancellationToken cancellationToken) + { + var token = await this._tokenCredential.GetTokenAsync(new TokenRequestContext(this._scopes), cancellationToken).ConfigureAwait(false); + string userId = request.UserId; + + string uri = $"{this._graphUri}/users/{userId}/dataSecurityAndGovernance/protectionScopes/compute"; + + using (HttpRequestMessage message = new(HttpMethod.Post, new Uri(uri))) + { + message.Headers.Add("Authorization", $"Bearer {token.Token}"); + message.Headers.Add("User-Agent", "agent-framework-dotnet"); + + var typeinfo = PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ProtectionScopesRequest)); + string content = JsonSerializer.Serialize(request, typeinfo); + message.Content = new StringContent(content, Encoding.UTF8, "application/json"); + + HttpResponseMessage response; + try + { + response = await this._httpClient.SendAsync(message, cancellationToken).ConfigureAwait(false); + } + catch (HttpRequestException e) + { + this._logger.LogError(e, "Http error while retrieving protection scopes."); + throw new PurviewRequestException("Http error occurred while retrieving protection scopes.", e); + } + + if (response.StatusCode == HttpStatusCode.OK) + { +#if NET5_0_OR_GREATER + // Pass the cancellation token if that method is available. + string responseContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); +#else + string responseContent = await response.Content.ReadAsStringAsync().ConfigureAwait(false); +#endif + ProtectionScopesResponse? deserializedResponse; + try + { + JsonTypeInfo typeInfo = (JsonTypeInfo)PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ProtectionScopesResponse)); + deserializedResponse = JsonSerializer.Deserialize(responseContent, typeInfo); + } + catch (JsonException jsonException) + { + const string DeserializeExceptionError = "Failed to deserialize ProtectionScopes response."; + this._logger.LogError(jsonException, DeserializeExceptionError); + throw new PurviewRequestException(DeserializeExceptionError, jsonException); + } + + if (deserializedResponse != null) + { + deserializedResponse.ScopeIdentifier = response.Headers.ETag?.Tag; + return deserializedResponse; + } + + const string DeserializeError = "Failed to deserialize ProtectionScopes response."; + this._logger.LogError(DeserializeError); + throw new PurviewRequestException(DeserializeError); + } + + this._logger.LogError("Failed to retrieve protection scopes. Status code: {StatusCode}", response.StatusCode); + throw CreateExceptionForStatusCode(response.StatusCode, "protectionScopes/compute"); + } + } + + /// + public async Task SendContentActivitiesAsync(ContentActivitiesRequest request, CancellationToken cancellationToken) + { + var token = await this._tokenCredential.GetTokenAsync(new TokenRequestContext(this._scopes), cancellationToken).ConfigureAwait(false); + string userId = request.UserId; + + string uri = $"{this._graphUri}/{userId}/dataSecurityAndGovernance/activities/contentActivities"; + + using (HttpRequestMessage message = new(HttpMethod.Post, new Uri(uri))) + { + message.Headers.Add("Authorization", $"Bearer {token.Token}"); + message.Headers.Add("User-Agent", "agent-framework-dotnet"); + string content = JsonSerializer.Serialize(request, PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ContentActivitiesRequest))); + message.Content = new StringContent(content, Encoding.UTF8, "application/json"); + HttpResponseMessage response; + + try + { + response = await this._httpClient.SendAsync(message, cancellationToken).ConfigureAwait(false); + } + catch (HttpRequestException e) + { + this._logger.LogError(e, "Http error while creating content activities."); + throw new PurviewRequestException("Http error occurred while creating content activities.", e); + } + + if (response.StatusCode == HttpStatusCode.Created) + { +#if NET5_0_OR_GREATER + // Pass the cancellation token if that method is available. + string responseContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); +#else + string responseContent = await response.Content.ReadAsStringAsync().ConfigureAwait(false); +#endif + ContentActivitiesResponse? deserializedResponse; + + try + { + JsonTypeInfo typeInfo = (JsonTypeInfo)PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ContentActivitiesResponse)); + deserializedResponse = JsonSerializer.Deserialize(responseContent, typeInfo); + } + catch (JsonException jsonException) + { + const string DeserializeExceptionError = "Failed to deserialize ContentActivities response."; + this._logger.LogError(jsonException, DeserializeExceptionError); + throw new PurviewRequestException(DeserializeExceptionError, jsonException); + } + + if (deserializedResponse != null) + { + return deserializedResponse; + } + + const string DeserializeError = "Failed to deserialize ContentActivities response."; + this._logger.LogError(DeserializeError); + throw new PurviewRequestException(DeserializeError); + } + + this._logger.LogError("Failed to create content activities. Status code: {StatusCode}", response.StatusCode); + throw CreateExceptionForStatusCode(response.StatusCode, "contentActivities"); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/PurviewExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewExtensions.cs new file mode 100644 index 0000000000..cdeb395d67 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewExtensions.cs @@ -0,0 +1,122 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Net.Http; +using System.Threading.Channels; +using Azure.Core; +using Microsoft.Agents.AI.Purview.Models.Jobs; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; + +namespace Microsoft.Agents.AI.Purview; + +/// +/// Extension methods to add Purview capabilities to an . +/// +public static class PurviewExtensions +{ + private static PurviewWrapper CreateWrapper(TokenCredential tokenCredential, PurviewSettings purviewSettings, ILogger? logger = null, IDistributedCache? cache = null) + { + MemoryDistributedCacheOptions options = new() + { + SizeLimit = purviewSettings.InMemoryCacheSizeLimit, + }; + + IDistributedCache distributedCache = cache ?? new MemoryDistributedCache(Options.Create(options)); + + ServiceCollection services = new(); + services.AddSingleton(tokenCredential); + services.AddSingleton(purviewSettings); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(distributedCache); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(logger ?? NullLogger.Instance); + services.AddSingleton(); + services.AddSingleton(Channel.CreateBounded(purviewSettings.PendingBackgroundJobLimit)); + services.AddSingleton(); + services.AddSingleton(); + ServiceProvider serviceProvider = services.BuildServiceProvider(); + + return serviceProvider.GetRequiredService(); + } + + /// + /// Adds Purview capabilities to an . + /// + /// The AI Agent builder for the . + /// The token credential used to authenticate with Purview. + /// The settings for communication with Purview. + /// The logger to use for logging. + /// The distributed cache to use for caching Purview responses. An in memory cache will be used if this is null. + /// The updated + public static AIAgentBuilder WithPurview(this AIAgentBuilder builder, TokenCredential tokenCredential, PurviewSettings purviewSettings, ILogger? logger = null, IDistributedCache? cache = null) + { + PurviewWrapper purviewWrapper = CreateWrapper(tokenCredential, purviewSettings, logger, cache); + return builder.Use((innerAgent) => new PurviewAgent(innerAgent, purviewWrapper)); + } + + /// + /// Adds Purview capabilities to a . + /// + /// The chat client builder for the . + /// The token credential used to authenticate with Purview. + /// The settings for communication with Purview. + /// The logger to use for logging. + /// The distributed cache to use for caching Purview responses. An in memory cache will be used if this is null. + /// The updated + public static ChatClientBuilder WithPurview(this ChatClientBuilder builder, TokenCredential tokenCredential, PurviewSettings purviewSettings, ILogger? logger = null, IDistributedCache? cache = null) + { + PurviewWrapper purviewWrapper = CreateWrapper(tokenCredential, purviewSettings, logger, cache); + return builder.Use((innerChatClient) => new PurviewChatClient(innerChatClient, purviewWrapper)); + } + + /// + /// Creates a Purview middleware function for use with a . + /// + /// The token credential used to authenticate with Purview. + /// The settings for communication with Purview. + /// The logger to use for logging. + /// The distributed cache to use for caching Purview responses. An in memory cache will be used if this is null. + /// A chat middleware delegate. + public static Func PurviewChatMiddleware(TokenCredential tokenCredential, PurviewSettings purviewSettings, ILogger? logger = null, IDistributedCache? cache = null) + { + PurviewWrapper purviewWrapper = CreateWrapper(tokenCredential, purviewSettings, logger, cache); + return (innerChatClient) => new PurviewChatClient(innerChatClient, purviewWrapper); + } + + /// + /// Creates a Purview middleware function for use with an . + /// + /// The token credential used to authenticate with Purview. + /// The settings for communication with Purview. + /// The logger to use for logging. + /// The distributed cache to use for caching Purview responses. An in memory cache will be used if this is null. + /// An agent middleware delegate. + public static Func PurviewAgentMiddleware(TokenCredential tokenCredential, PurviewSettings purviewSettings, ILogger? logger = null, IDistributedCache? cache = null) + { + PurviewWrapper purviewWrapper = CreateWrapper(tokenCredential, purviewSettings, logger, cache); + return (innerAgent) => new PurviewAgent(innerAgent, purviewWrapper); + } + + /// + /// Sets the user id for a message. + /// + /// The message. + /// The id of the owner of the message. + public static void SetUserId(this ChatMessage message, Guid userId) + { + if (message.AdditionalProperties == null) + { + message.AdditionalProperties = new AdditionalPropertiesDictionary(); + } + + message.AdditionalProperties[Constants.UserId] = userId.ToString(); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/PurviewLocationType.cs b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewLocationType.cs new file mode 100644 index 0000000000..4fcc145f0b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewLocationType.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Purview; + +/// +/// The type of location for Purview policy evaluation. +/// +public enum PurviewLocationType +{ + /// + /// An application location. + /// + Application, + + /// + /// A URI location. + /// + Uri, + + /// + /// A domain name location. + /// + Domain +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/PurviewSettings.cs b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewSettings.cs new file mode 100644 index 0000000000..cb400805c6 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewSettings.cs @@ -0,0 +1,88 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.AI.Purview; + +/// +/// Represents the configuration settings for a Purview application, including tenant information, application name, and +/// optional default user settings. +/// +/// This class is used to encapsulate the necessary configuration details for interacting with Purview +/// services. It includes the tenant ID and application name, which are required, and an optional default user ID that +/// can be used for requests where a specific user ID is not provided. +public class PurviewSettings +{ + /// + /// Initializes a new instance of the class. + /// + /// The publicly visible name of the application. + public PurviewSettings(string appName) + { + this.AppName = appName; + } + + /// + /// The publicly visible app name of the application. + /// + public string AppName { get; set; } + + /// + /// The version string of the application. + /// + public string? AppVersion { get; set; } + + /// + /// The tenant id of the user making the request. + /// If this is not provided, the tenant id will be inferred from the token. + /// + public string? TenantId { get; set; } + + /// + /// Gets or sets the location of the Purview resource. + /// If this is not provided, a location containing the client id will be used instead. + /// + public PurviewAppLocation? PurviewAppLocation { get; set; } + + /// + /// Gets or sets a flag indicating whether to ignore exceptions when processing Purview requests. False by default. + /// If set to true, exceptions calling Purview will be logged but not thrown. + /// + public bool IgnoreExceptions { get; set; } + + /// + /// Gets or sets the base URI for the Microsoft Graph API. + /// Set to graph v1.0 by default. + /// + public Uri GraphBaseUri { get; set; } = new Uri("https://graph.microsoft.com/v1.0/"); + + /// + /// Gets or sets the message to display when a prompt is blocked by Purview policies. + /// + public string BlockedPromptMessage { get; set; } = "Prompt blocked by policies"; + + /// + /// Gets or sets the message to display when a response is blocked by Purview policies. + /// + public string BlockedResponseMessage { get; set; } = "Response blocked by policies"; + + /// + /// The size limit of the default in memory cache in bytes. This only applies if no cache is provided when creating Purview resources. + /// + public long? InMemoryCacheSizeLimit { get; set; } = 100_000_000; + + /// + /// The TTL of each cache entry. + /// + public TimeSpan CacheTTL { get; set; } = TimeSpan.FromMinutes(30); + + /// + /// The maximum number of background jobs that can be queued up. + /// + public int PendingBackgroundJobLimit { get; set; } = 100; + + /// + /// The maximum number of concurrent job consumers. + /// + public int MaxConcurrentJobConsumers { get; set; } = 10; +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/PurviewWrapper.cs b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewWrapper.cs new file mode 100644 index 0000000000..c8316a4e21 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewWrapper.cs @@ -0,0 +1,181 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Purview.Models.Common; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Agents.AI.Purview; + +/// +/// A delegating agent that connects to Microsoft Purview. +/// +internal sealed class PurviewWrapper : IDisposable +{ + private readonly ILogger _logger; + private readonly IScopedContentProcessor _scopedProcessor; + private readonly PurviewSettings _purviewSettings; + private readonly IChannelHandler _channelHandler; + + /// + /// Creates a new instance. + /// + /// The scoped processor used to orchestrate the calls to Purview. + /// The settings for Purview integration. + /// The logger used for logging. + /// The channel handler used to queue background jobs and add job runners. + public PurviewWrapper(IScopedContentProcessor scopedProcessor, PurviewSettings purviewSettings, ILogger logger, IChannelHandler channelHandler) + { + this._scopedProcessor = scopedProcessor; + this._purviewSettings = purviewSettings; + this._logger = logger; + this._channelHandler = channelHandler; + } + + private static string GetThreadIdFromAgentThread(AgentThread? thread, IEnumerable messages) + { + if (thread is ChatClientAgentThread chatClientAgentThread && + chatClientAgentThread.ConversationId != null) + { + return chatClientAgentThread.ConversationId; + } + + foreach (ChatMessage message in messages) + { + if (message.AdditionalProperties != null && + message.AdditionalProperties.TryGetValue(Constants.ConversationId, out object? conversationId) && + conversationId != null) + { + return conversationId.ToString() ?? Guid.NewGuid().ToString(); + } + } + + return Guid.NewGuid().ToString(); + } + + /// + /// Processes a prompt and response exchange at a chat client level. + /// + /// The messages sent to the chat client. + /// The chat options used with the chat client. + /// The wrapped chat client. + /// The cancellation token used to interrupt async operations. + /// The chat client's response. This could be the response from the chat client or a message indicating that Purview has blocked the prompt or response. + public async Task ProcessChatContentAsync(IEnumerable messages, ChatOptions? options, IChatClient innerChatClient, CancellationToken cancellationToken) + { + string? resolvedUserId = null; + + try + { + (bool shouldBlockPrompt, resolvedUserId) = await this._scopedProcessor.ProcessMessagesAsync(messages, options?.ConversationId, Activity.UploadText, this._purviewSettings, null, cancellationToken).ConfigureAwait(false); + if (shouldBlockPrompt) + { + this._logger.LogInformation("Prompt blocked by policy. Sending message: {Message}", this._purviewSettings.BlockedPromptMessage); + return new ChatResponse(new ChatMessage(ChatRole.System, this._purviewSettings.BlockedPromptMessage)); + } + } + catch (Exception ex) + { + this._logger.LogError(ex, "Error processing prompt: {ExceptionMessage}", ex.Message); + + if (!this._purviewSettings.IgnoreExceptions) + { + throw; + } + } + + ChatResponse response = await innerChatClient.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false); + + try + { + (bool shouldBlockResponse, _) = await this._scopedProcessor.ProcessMessagesAsync(response.Messages, options?.ConversationId, Activity.UploadText, this._purviewSettings, resolvedUserId, cancellationToken).ConfigureAwait(false); + if (shouldBlockResponse) + { + this._logger.LogInformation("Response blocked by policy. Sending message: {Message}", this._purviewSettings.BlockedResponseMessage); + return new ChatResponse(new ChatMessage(ChatRole.System, this._purviewSettings.BlockedResponseMessage)); + } + } + catch (Exception ex) + { + this._logger.LogError(ex, "Error processing response: {ExceptionMessage}", ex.Message); + + if (!this._purviewSettings.IgnoreExceptions) + { + throw; + } + } + + return response; + } + + /// + /// Processes a prompt and response exchange at an agent level. + /// + /// The messages sent to the agent. + /// The thread used for this agent conversation. + /// The options used with this agent. + /// The wrapped agent. + /// The cancellation token used to interrupt async operations. + /// The agent's response. This could be the response from the agent or a message indicating that Purview has blocked the prompt or response. + public async Task ProcessAgentContentAsync(IEnumerable messages, AgentThread? thread, AgentRunOptions? options, AIAgent innerAgent, CancellationToken cancellationToken) + { + string threadId = GetThreadIdFromAgentThread(thread, messages); + + string? resolvedUserId = null; + + try + { + (bool shouldBlockPrompt, resolvedUserId) = await this._scopedProcessor.ProcessMessagesAsync(messages, threadId, Activity.UploadText, this._purviewSettings, null, cancellationToken).ConfigureAwait(false); + + if (shouldBlockPrompt) + { + this._logger.LogInformation("Prompt blocked by policy. Sending message: {Message}", this._purviewSettings.BlockedPromptMessage); + return new AgentRunResponse(new ChatMessage(ChatRole.System, this._purviewSettings.BlockedPromptMessage)); + } + } + catch (Exception ex) + { + this._logger.LogError(ex, "Error processing prompt: {ExceptionMessage}", ex.Message); + + if (!this._purviewSettings.IgnoreExceptions) + { + throw; + } + } + + AgentRunResponse response = await innerAgent.RunAsync(messages, thread, options, cancellationToken).ConfigureAwait(false); + + try + { + (bool shouldBlockResponse, _) = await this._scopedProcessor.ProcessMessagesAsync(response.Messages, threadId, Activity.UploadText, this._purviewSettings, resolvedUserId, cancellationToken).ConfigureAwait(false); + + if (shouldBlockResponse) + { + this._logger.LogInformation("Response blocked by policy. Sending message: {Message}", this._purviewSettings.BlockedResponseMessage); + return new AgentRunResponse(new ChatMessage(ChatRole.System, this._purviewSettings.BlockedResponseMessage)); + } + } + catch (Exception ex) + { + this._logger.LogError(ex, "Error processing response: {ExceptionMessage}", ex.Message); + + if (!this._purviewSettings.IgnoreExceptions) + { + throw; + } + } + + return response; + } + + /// + public void Dispose() + { +#pragma warning disable VSTHRD002 // Need to wait for pending jobs to complete. + this._channelHandler.StopAndWaitForCompletionAsync().GetAwaiter().GetResult(); +#pragma warning restore VSTHRD002 // Need to wait for pending jobs to complete. + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/README.md b/dotnet/src/Microsoft.Agents.AI.Purview/README.md new file mode 100644 index 0000000000..3e46ceff65 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/README.md @@ -0,0 +1,263 @@ +# Microsoft Agent Framework - Purview Integration (Dotnet) + +The Purview plugin for the Microsoft Agent Framework adds Purview policy evaluation to the Microsoft Agent Framework. +It lets you enforce data security and governance policies on both the *prompt* (user input + conversation history) and the *model response* before they proceed further in your workflow. + +> Status: **Preview** + +### Key Features + +- Middleware-based policy enforcement (agent-level and chat-client level) +- Blocks or allows content at both ingress (prompt) and egress (response) +- Works with any `IChatClient` or `AIAgent` using the standard Agent Framework middleware pipeline. +- Authenticates to Purview using `TokenCredential`s +- Simple configuration using `PurviewSettings` +- Configurable caching using `IDistributedCache` +- `WithPurview` Extension methods to easily apply middleware to a `ChatClientBuilder` or `AIAgentBuilder` + +### When to Use +Add Purview when you need to: + +- Prevent sensitive or disallowed content from being sent to an LLM +- Prevent model output containing disallowed data from leaving the system +- Apply centrally managed policies without rewriting agent logic + +--- + + +## Quick Start + +``` csharp +using Azure.AI.OpenAI; +using Azure.Core; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Purview; +using Microsoft.Extensions.AI; + +Uri endpoint = new Uri("..."); // The endpoint of Azure OpenAI instance. +string deploymentName = "..."; // The deployment name of your Azure OpenAI instance ex: gpt-4o-mini +string purviewClientAppId = "..."; // The client id of your entra app registration. + +// This will get a user token for an entra app configured to call the Purview API. +// Any TokenCredential with permissions to call the Purview API can be used here. +TokenCredential browserCredential = new InteractiveBrowserCredential( + new InteractiveBrowserCredentialOptions + { + ClientId = purviewClientAppId + }); + +IChatClient client = new AzureOpenAIClient( + new Uri(endpoint), + new AzureCliCredential()) + .GetOpenAIResponseClient(deploymentName) + .AsIChatClient() + .AsBuilder() + .WithPurview(browserCredential, new PurviewSettings("My Sample App")) + .Build(); + +using (client) +{ + Console.WriteLine("Enter a prompt to send to the client:"); + string? promptText = Console.ReadLine(); + + if (!string.IsNullOrEmpty(promptText)) + { + // Invoke the agent and output the text result. + Console.WriteLine(await client.GetResponseAsync(promptText)); + } +} +``` + +If a policy violation is detected on the prompt, the middleware interrupts the run and outputs the message: `"Prompt blocked by policies"`. If on the response, the result becomes `"Response blocked by policies"`. + +--- + +## Authentication + +The Purview middleware uses Azure.Core TokenCredential objects for authentication. + +The plugin requires the following Graph permissions: +- ProtectionScopes.Compute.All : [userProtectionScopeContainer](https://learn.microsoft.com/en-us/graph/api/userprotectionscopecontainer-compute) +- Content.Process.All : [processContent](https://learn.microsoft.com/en-us/graph/api/userdatasecurityandgovernance-processcontent) +- ContentActivity.Write : [contentActivity](https://learn.microsoft.com/en-us/graph/api/activitiescontainer-post-contentactivities) + +Authentication with user tokens is preferred. If authenticating with app tokens, the agent-framework caller will need to provide an entra user id for each `ChatMessage` send to the agent/client. This user id can be set using the `SetUserId` extension method, or by setting the `"userId"` field of the `AdditionalProperties` dictionary. + +``` csharp +// Manually +var message = new ChatMessage(ChatRole.User, promptText); +if (message.AdditionalProperties == null) +{ + message.AdditionalProperties = new AdditionalPropertiesDictionary(); +} +message.AdditionalProperties["userId"] = ""; + +// Or with the extension method +var message = new ChatMessage(ChatRole.User, promptText); +message.SetUserId(new Guid("")); +``` + +### Tenant Enablement for Purview +- The tenant requires an e5 license and consumptive billing setup. +- [Data Loss Prevention](https://learn.microsoft.com/en-us/purview/dlp-create-deploy-policy) or [Data Collection Policies](https://learn.microsoft.com/en-us/purview/collection-policies-policy-reference) policies that apply to the user are required to enable classification and message ingestion (Process Content API). Otherwise, messages will only be logged in Purview's Audit log (Content Activities API). + +## Configuration + +### Settings + +The Purview middleware can be customized and configured using the `PurviewSettings` class. + +#### `PurviewSettings` + +| Field | Type | Purpose | +| ----- | ---- | ------- | +| AppName | string | The publicly visible app name of the application. | +| AppVersion | string? | (Optional) The version string of the application. | +| TenantId | string? | (Optional) The tenant id of the user making the request. If not provided, this will be inferred from the token. | +| PurviewAppLocation | PurviewAppLocation? | (Optional) The location of the Purview resource used during policy evaluation. If not provided, a location containing the application client id will be used instead. | +| IgnoreExceptions | bool | (Optional, `false` by default) Determines if the exceptions thrown in the Purview middleware should be ignored. If set to true, exceptions will be logged but not thrown. | +| GraphBaseUri | Uri | (Optional, https://graph.microsoft.com/v1.0/ by default) The base URI used for calls to Purview's Microsoft Graph APIs. | +| BlockedPromptMessage | string | (Optional, `"Prompt blocked by policies"` by default) The message returned when a prompt is blocked by Purview. | +| BlockedResponseMessage | string | (Optional, `"Response blocked by policies"` by default) The message returned when a response is blocked by Purview. | +| InMemoryCacheSizeLimit | long? | (Optional, `100_000_000` by default) The size limit of the default in-memory cache in bytes. This only applies if no cache is provided when creating the Purview middleware. | +| CacheTTL | TimeSpan | (Optional, 30 minutes by default) The time to live of each cache entry. | +| PendingBackgroundJobLimit | int | (Optional, 100 by default) The maximum number of pending background jobs that can be queued in the middleware. | +| MaxConcurrentJobConsumers | int | (Optional, 10 by default) The maximum number of concurrent consumers that can run background jobs in the middleware. | + +#### `PurviewAppLocation` + +| Field | Type | Purpose | +| ----- | ---- | ------- | +| LocationType | PurviewLocationType | The type of the location: Application, Uri, Domain. | +| LocationValue | string | The value of the location. | + +#### Location + +The `PurviewAppLocation` field of the `PurviewSettings` object contains the location of the app which is used by Purview for policy evaluation (see [policyLocation](https://learn.microsoft.com/en-us/graph/api/resources/policylocation?view=graph-rest-1.0) for more information). +This location can be set to the URL of the agent app, the domain of the agent app, or the application id of the agent app. + +#### Example + +```csharp +var location = new PurviewAppLocation(PurviewLocationType.Uri, "https://contoso.com/chatagent"); +var settings = new PurviewSettings("My Sample App") +{ + AppVersion = "1.0", + TenantId = "your-tenant-id", + PurviewAppLocation = location, + IgnoreExceptions = false, + GraphBaseUri = new Uri("https://graph.microsoft.com/v1.0/"), + BlockedPromptMessage = "Prompt blocked by policies.", + BlockedResponseMessage = "Response blocked by policies.", + InMemoryCacheSizeLimit = 100_000_000, + CacheTTL = TimeSpan.FromMinutes(30), + PendingBackgroundJobLimit = 100, + MaxConcurrentJobConsumers = 10, +}; + +// ... Set up credential and client builder ... + +var client = builder.WithPurview(credential, settings).Build(); +``` + +#### Customizing Blocked Messages + +This is useful for: +- Providing more user-friendly error messages +- Including support contact information +- Localizing messages for different languages +- Adding branding or specific guidance for your application + +``` csharp +var settings = new PurviewSettings("My Sample App") +{ + BlockedPromptMessage = "Your request contains content that violates our policies. Please rephrase and try again.", + BlockedResponseMessage = "The response was blocked due to policy restrictions. Please contact support if you need assistance.", +}; +``` + +### Selecting Agent vs Chat Middleware + +Use the agent middleware when you already have / want the full agent pipeline: + +``` csharp +AIAgent agent = new AzureOpenAIClient( + new Uri(endpoint), + new AzureCliCredential()) + .GetChatClient(deploymentName) + .CreateAIAgent("You are a helpful assistant.") + .AsBuilder() + .WithPurview(browserCredential, new PurviewSettings("Agent Framework Test App")) + .Build(); +``` + +Use the chat middleware when you attach directly to a chat client (e.g. minimal agent shell or custom orchestration): + +``` csharp +IChatClient client = new AzureOpenAIClient( + new Uri(endpoint), + new AzureCliCredential()) + .GetOpenAIResponseClient(deploymentName) + .AsIChatClient() + .AsBuilder() + .WithPurview(browserCredential, new PurviewSettings("Agent Framework Test App")) + .Build(); +``` + +The policy logic is identical; the only difference is the hook point in the pipeline. + +--- + +## Middleware Lifecycle +1. Before sending the prompt to the agent, the middleware checks the app and user metadata against Purview's protection scopes and evaluates all the `ChatMessage`s in the prompt. +2. If the content was blocked, the middleware returns a `ChatResponse` or `AgentRunResponse` containing the `BlockedPromptMessage` text. The blocked content does not get passed to the agent. +3. If the evaluation did not block the content, the middleware passes the prompt data to the agent and waits for a response. +4. After receiving a response from the agent, the middleware calls Purview again to evaluate the response content. +5. If the content was blocked, the middleware returns a response containing the `BlockedResponseMessage`. + +The user id from the prompt message(s) is reused for the response evaluation so both evaluations map consistently to the same user. + +There are several optimizations to speed up Purview calls. Protection scope lookups (the first step in evaluation) are cached to minimize network calls. +If the policies allow content to be processed offline, the middleware will add the process content request to a channel and run it in a background worker. Similarly, the middleware will run a background request if no scopes apply and the interaction only has to be logged in Audit. + +## Exceptions +| Exception | Scenario | +| --------- | -------- | +| PurviewAuthenticationException | Token acquisition / validation issues | +| PurviewJobException | Errors thrown by a background job | +| PurviewJobLimitExceededException | Errors caused by exceeding the background job limit | +| PurviewPaymentRequiredException | 402 responses from the service | +| PurviewRateLimitException | 429 responses from the service | +| PurviewRequestException | Other errors related to Purview requests | +| PurviewException | Base class for all Purview plugin exceptions | + +Callers' exception handling can be fine-grained + +``` csharp +try +{ + // Code that uses Purview middleware +} +catch (PurviewPaymentRequiredException) +{ + this._logger.LogError("Payment required for Purview."); +} +catch (PurviewAuthenticationException) +{ + this._logger.LogError("Error authenticating to Purview."); +} +``` + +Or broad + +``` csharp +try +{ + // Code that uses Purview middleware +} +catch (PurviewException e) +{ + this._logger.LogError(e, "Purview middleware threw an exception.") +} +``` diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs b/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs new file mode 100644 index 0000000000..da9e61a22e --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs @@ -0,0 +1,358 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Purview.Models.Common; +using Microsoft.Agents.AI.Purview.Models.Jobs; +using Microsoft.Agents.AI.Purview.Models.Requests; +using Microsoft.Agents.AI.Purview.Models.Responses; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Purview; + +/// +/// Processor class that combines protectionScopes, processContent, and contentActivities calls. +/// +internal sealed class ScopedContentProcessor : IScopedContentProcessor +{ + private readonly IPurviewClient _purviewClient; + private readonly ICacheProvider _cacheProvider; + private readonly IChannelHandler _channelHandler; + + /// + /// Create a new instance of . + /// + /// The purview client to use for purview requests. + /// The cache used to store Purview data. + /// The channel handler used to manage background jobs. + public ScopedContentProcessor(IPurviewClient purviewClient, ICacheProvider cacheProvider, IChannelHandler channelHandler) + { + this._purviewClient = purviewClient; + this._cacheProvider = cacheProvider; + this._channelHandler = channelHandler; + } + + /// + public async Task<(bool shouldBlock, string? userId)> ProcessMessagesAsync(IEnumerable messages, string? threadId, Activity activity, PurviewSettings purviewSettings, string? userId, CancellationToken cancellationToken) + { + List pcRequests = await this.MapMessageToPCRequestsAsync(messages, threadId, activity, purviewSettings, userId, cancellationToken).ConfigureAwait(false); + + bool shouldBlock = false; + string? resolvedUserId = null; + + foreach (ProcessContentRequest pcRequest in pcRequests) + { + resolvedUserId = pcRequest.UserId; + ProcessContentResponse processContentResponse = await this.ProcessContentWithProtectionScopesAsync(pcRequest, cancellationToken).ConfigureAwait(false); + if (processContentResponse.PolicyActions?.Count > 0) + { + foreach (DlpActionInfo policyAction in processContentResponse.PolicyActions) + { + // We need to process all data before blocking, so set the flag and return it outside of this loop. + if (policyAction.Action == DlpAction.BlockAccess) + { + shouldBlock = true; + } + + if (policyAction.RestrictionAction == RestrictionAction.Block) + { + shouldBlock = true; + } + } + } + } + + return (shouldBlock, resolvedUserId); + } + + private static bool TryGetUserIdFromPayload(IEnumerable messages, out string? userId) + { + userId = null; + + foreach (ChatMessage message in messages) + { + if (message.AdditionalProperties != null && + message.AdditionalProperties.TryGetValue(Constants.UserId, out userId) && + !string.IsNullOrEmpty(userId)) + { + return true; + } + else if (Guid.TryParse(message.AuthorName, out Guid _)) + { + userId = message.AuthorName; + return true; + } + } + + return false; + } + + /// + /// Transform a list of ChatMessages into a list of ProcessContentRequests. + /// + /// The messages to transform. + /// The id of the message thread. + /// The activity performed on the content. + /// The settings used for purview integration. + /// The entra id of the user who made the interaction. + /// The cancellation token used to cancel async operations. + /// A list of process content requests. + private async Task> MapMessageToPCRequestsAsync(IEnumerable messages, string? threadId, Activity activity, PurviewSettings settings, string? userId, CancellationToken cancellationToken) + { + List pcRequests = new(); + TokenInfo? tokenInfo = null; + + bool needUserId = userId == null && TryGetUserIdFromPayload(messages, out userId); + + // Only get user info if the tenant id is null or if there's no location. + // If location is missing, we will create a new location using the client id. + if (settings.TenantId == null || + settings.PurviewAppLocation == null || + needUserId) + { + tokenInfo = await this._purviewClient.GetUserInfoFromTokenAsync(cancellationToken, settings.TenantId).ConfigureAwait(false); + } + + string tenantId = settings.TenantId ?? tokenInfo?.TenantId ?? throw new PurviewRequestException("No tenant id provided or inferred for Purview request. Please provide a tenant id in PurviewSettings or configure the TokenCredential to authenticate to a tenant."); + + foreach (ChatMessage message in messages) + { + string messageId = message.MessageId ?? Guid.NewGuid().ToString(); + ContentBase content = new PurviewTextContent(message.Text); + ProcessConversationMetadata conversationmetadata = new(content, messageId, false, $"Agent Framework Message {messageId}") + { + CorrelationId = threadId ?? Guid.NewGuid().ToString() + }; + ActivityMetadata activityMetadata = new(activity); + PolicyLocation policyLocation; + + if (settings.PurviewAppLocation != null) + { + policyLocation = settings.PurviewAppLocation.GetPolicyLocation(); + } + else if (tokenInfo?.ClientId != null) + { + policyLocation = new($"{Constants.ODataGraphNamespace}.policyLocationApplication", tokenInfo.ClientId); + } + else + { + throw new PurviewRequestException("No app location provided or inferred for Purview request. Please provide an app location in PurviewSettings or configure the TokenCredential to authenticate to an entra app."); + } + + string appVersion = !string.IsNullOrEmpty(settings.AppVersion) ? settings.AppVersion : "Unknown"; + + ProtectedAppMetadata protectedAppMetadata = new(policyLocation) + { + Name = settings.AppName, + Version = appVersion + }; + IntegratedAppMetadata integratedAppMetadata = new() + { + Name = settings.AppName, + Version = appVersion + }; + + DeviceMetadata deviceMetadata = new() + { + OperatingSystemSpecifications = new() + { + OperatingSystemPlatform = "Unknown", + OperatingSystemVersion = "Unknown" + } + }; + ContentToProcess contentToProcess = new(new List { conversationmetadata }, activityMetadata, deviceMetadata, integratedAppMetadata, protectedAppMetadata); + + if (userId == null && + tokenInfo?.UserId != null) + { + userId = tokenInfo.UserId; + } + + if (string.IsNullOrEmpty(userId)) + { + throw new PurviewRequestException("No user id provided or inferred for Purview request. Please provide an Entra user id in each message's AuthorName, set a default Entra user id in PurviewSettings, or configure the TokenCredential to authenticate to an Entra user."); + } + + ProcessContentRequest pcRequest = new(contentToProcess, userId, tenantId); + pcRequests.Add(pcRequest); + } + + return pcRequests; + } + + /// + /// Orchestrates process content and protection scopes calls. + /// + /// The process content request. + /// The cancellation token used to cancel async operations. + /// A process content response. This could be a response from the process content API or a response generated from a content activities call. + private async Task ProcessContentWithProtectionScopesAsync(ProcessContentRequest pcRequest, CancellationToken cancellationToken) + { + ProtectionScopesRequest psRequest = CreateProtectionScopesRequest(pcRequest, pcRequest.UserId, pcRequest.TenantId, pcRequest.CorrelationId); + + ProtectionScopesCacheKey cacheKey = new(psRequest); + + ProtectionScopesResponse? cacheResponse = await this._cacheProvider.GetAsync(cacheKey, cancellationToken).ConfigureAwait(false); + + ProtectionScopesResponse psResponse; + + if (cacheResponse != null) + { + psResponse = cacheResponse; + } + else + { + psResponse = await this._purviewClient.GetProtectionScopesAsync(psRequest, cancellationToken).ConfigureAwait(false); + await this._cacheProvider.SetAsync(cacheKey, psResponse, cancellationToken).ConfigureAwait(false); + } + + pcRequest.ScopeIdentifier = psResponse.ScopeIdentifier; + + (bool shouldProcess, List dlpActions, ExecutionMode executionMode) = CheckApplicableScopes(pcRequest, psResponse); + + if (shouldProcess) + { + if (executionMode == ExecutionMode.EvaluateOffline) + { + this._channelHandler.QueueJob(new ProcessContentJob(pcRequest)); + return new ProcessContentResponse(); + } + + ProcessContentResponse pcResponse = await this._purviewClient.ProcessContentAsync(pcRequest, cancellationToken).ConfigureAwait(false); + + if (pcResponse.ProtectionScopeState == ProtectionScopeState.Modified) + { + await this._cacheProvider.RemoveAsync(cacheKey, cancellationToken).ConfigureAwait(false); + } + + pcResponse = CombinePolicyActions(pcResponse, dlpActions); + return pcResponse; + } + + ContentActivitiesRequest caRequest = new(pcRequest.UserId, pcRequest.TenantId, pcRequest.ContentToProcess, pcRequest.CorrelationId); + this._channelHandler.QueueJob(new ContentActivityJob(caRequest)); + + return new ProcessContentResponse(); + } + + /// + /// Dedupe policy actions received from the service. + /// + /// The process content response which may contain DLP actions. + /// DLP actions returned from protection scopes. + /// The process content response with the protection scopes DLP actions added. Actions are deduplicated. + private static ProcessContentResponse CombinePolicyActions(ProcessContentResponse pcResponse, List? actionInfos) + { + if (actionInfos == null || actionInfos.Count == 0) + { + return pcResponse; + } + + if (pcResponse.PolicyActions == null) + { + pcResponse.PolicyActions = actionInfos; + return pcResponse; + } + + List pcActionInfos = new(pcResponse.PolicyActions); + pcActionInfos.AddRange(actionInfos); + pcResponse.PolicyActions = pcActionInfos; + return pcResponse; + } + + /// + /// Check if any scopes are applicable to the request. + /// + /// The process content request. + /// The protection scopes response that was returned for the process content request. + /// A bool indicating if the content needs to be processed. A list of applicable actions from the scopes response, and the execution mode for the process content request. + private static (bool shouldProcess, List dlpActions, ExecutionMode executionMode) CheckApplicableScopes(ProcessContentRequest pcRequest, ProtectionScopesResponse psResponse) + { + ProtectionScopeActivities requestActivity = TranslateActivity(pcRequest.ContentToProcess.ActivityMetadata.Activity); + + // The location data type is formatted as microsoft.graph.{locationType} + // Sometimes a '#' gets appended by graph during responses, so for the sake of simplicity, + // Split it by '.' and take the last segment. We'll do a case-insensitive endsWith later. + string[] locationSegments = pcRequest.ContentToProcess.ProtectedAppMetadata.ApplicationLocation.DataType.Split('.'); + string locationType = locationSegments.Length > 0 ? locationSegments[locationSegments.Length - 1] : pcRequest.ContentToProcess.ProtectedAppMetadata.ApplicationLocation.Value; + + string locationValue = pcRequest.ContentToProcess.ProtectedAppMetadata.ApplicationLocation.Value; + List dlpActions = new(); + bool shouldProcess = false; + ExecutionMode executionMode = ExecutionMode.EvaluateOffline; + + foreach (var scope in psResponse.Scopes ?? Array.Empty()) + { + bool activityMatch = scope.Activities.HasFlag(requestActivity); + bool locationMatch = false; + + foreach (var location in scope.Locations ?? Array.Empty()) + { + locationMatch = location.DataType.EndsWith(locationType, StringComparison.OrdinalIgnoreCase) && location.Value.Equals(locationValue, StringComparison.OrdinalIgnoreCase); + } + + if (activityMatch && locationMatch) + { + shouldProcess = true; + + if (scope.ExecutionMode == ExecutionMode.EvaluateInline) + { + executionMode = ExecutionMode.EvaluateInline; + } + + if (scope.PolicyActions != null) + { + dlpActions.AddRange(scope.PolicyActions); + } + } + } + + return (shouldProcess, dlpActions, executionMode); + } + + /// + /// Create a ProtectionScopesRequest for the given content ProcessContentRequest. + /// + /// The process content request. + /// The entra user id of the user who sent the data. + /// The tenant id of the user who sent the data. + /// The correlation id of the request. + /// The protection scopes request generated from the process content request. + private static ProtectionScopesRequest CreateProtectionScopesRequest(ProcessContentRequest pcRequest, string userId, string tenantId, Guid correlationId) + { + return new ProtectionScopesRequest(userId, tenantId) + { + Activities = TranslateActivity(pcRequest.ContentToProcess.ActivityMetadata.Activity), + Locations = new List { pcRequest.ContentToProcess.ProtectedAppMetadata.ApplicationLocation }, + DeviceMetadata = pcRequest.ContentToProcess.DeviceMetadata, + IntegratedAppMetadata = pcRequest.ContentToProcess.IntegratedAppMetadata, + CorrelationId = correlationId + }; + } + + /// + /// Map process content activity to protection scope activity. + /// + /// The process content activity. + /// The protection scopes activity. + private static ProtectionScopeActivities TranslateActivity(Activity activity) + { + switch (activity) + { + case Activity.Unknown: + return ProtectionScopeActivities.None; + case Activity.UploadText: + return ProtectionScopeActivities.UploadText; + case Activity.UploadFile: + return ProtectionScopeActivities.UploadFile; + case Activity.DownloadText: + return ProtectionScopeActivities.DownloadText; + case Activity.DownloadFile: + return ProtectionScopeActivities.DownloadFile; + default: + return ProtectionScopeActivities.UnknownFutureValue; + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Serialization/PurviewSerializationUtils.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Serialization/PurviewSerializationUtils.cs new file mode 100644 index 0000000000..320fbcd3b6 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Serialization/PurviewSerializationUtils.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Agents.AI.Purview.Models.Common; +using Microsoft.Agents.AI.Purview.Models.Requests; +using Microsoft.Agents.AI.Purview.Models.Responses; + +namespace Microsoft.Agents.AI.Purview.Serialization; + +/// +/// Source generation context for Purview serialization. +/// +[JsonSerializable(typeof(ProtectionScopesRequest))] +[JsonSerializable(typeof(ProtectionScopesResponse))] +[JsonSerializable(typeof(ProcessContentRequest))] +[JsonSerializable(typeof(ProcessContentResponse))] +[JsonSerializable(typeof(ContentActivitiesRequest))] +[JsonSerializable(typeof(ContentActivitiesResponse))] +[JsonSerializable(typeof(ProtectionScopesCacheKey))] +internal sealed partial class SourceGenerationContext : JsonSerializerContext; + +/// +/// Utility class for Purview serialization settings. +/// +internal static class PurviewSerializationUtils +{ + /// + /// Serialization settings for Purview. + /// + public static JsonSerializerOptions SerializationSettings { get; } = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + WriteIndented = false, + AllowTrailingCommas = false, + DictionaryKeyPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = SourceGenerationContext.Default, + }; +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/Microsoft.Agents.AI.Purview.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/Microsoft.Agents.AI.Purview.UnitTests.csproj new file mode 100644 index 0000000000..bd07eca8ab --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/Microsoft.Agents.AI.Purview.UnitTests.csproj @@ -0,0 +1,12 @@ + + + + $(ProjectsTargetFrameworks) + + + + + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/PurviewClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/PurviewClientTests.cs new file mode 100644 index 0000000000..b2b0ac45e6 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/PurviewClientTests.cs @@ -0,0 +1,585 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Azure.Core; +using Microsoft.Agents.AI.Purview.Models.Common; +using Microsoft.Agents.AI.Purview.Models.Requests; +using Microsoft.Agents.AI.Purview.Models.Responses; +using Microsoft.Agents.AI.Purview.Serialization; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Microsoft.Agents.AI.Purview.UnitTests; + +/// +/// Unit tests for the class. +/// +public sealed class PurviewClientTests : IDisposable +{ + private readonly HttpClient _httpClient; + private readonly PurviewClientHttpMessageHandlerStub _handler; + private readonly PurviewClient _client; + private readonly PurviewSettings _settings; + + public PurviewClientTests() + { + this._handler = new PurviewClientHttpMessageHandlerStub(); + this._httpClient = new HttpClient(this._handler, false); + this._settings = new PurviewSettings("TestApp") + { + GraphBaseUri = new Uri("https://graph.microsoft.com/v1.0/") + }; + var tokenCredential = new MockTokenCredential(); + this._client = new PurviewClient(tokenCredential, this._settings, this._httpClient, NullLogger.Instance); + } + + #region ProcessContentAsync Tests + + [Fact] + public async Task ProcessContentAsync_WithValidRequest_ReturnsSuccessResponseAsync() + { + // Arrange + var request = CreateValidProcessContentRequest(); + var expectedResponse = new ProcessContentResponse + { + Id = "test-id-123", + ProtectionScopeState = ProtectionScopeState.NotModified, + PolicyActions = new List + { + new() { Action = DlpAction.NotifyUser } + } + }; + + this._handler.StatusCodeToReturn = HttpStatusCode.OK; + this._handler.ResponseToReturn = JsonSerializer.Serialize(expectedResponse, PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ProcessContentResponse))); + + // Act + var result = await this._client.ProcessContentAsync(request, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.Equal(expectedResponse.Id, result.Id); + Assert.Equal(ProtectionScopeState.NotModified, result.ProtectionScopeState); + Assert.Single(result.PolicyActions!); + Assert.Equal(DlpAction.NotifyUser, result.PolicyActions![0].Action); + + // Verify request + Assert.Equal("https://graph.microsoft.com/v1.0/users/test-user-id/dataSecurityAndGovernance/processContent", this._handler.RequestUri?.ToString()); + Assert.Equal(HttpMethod.Post, this._handler.RequestMethod); + Assert.Contains("Bearer ", this._handler.AuthorizationHeader); + } + + [Fact] + public async Task ProcessContentAsync_WithAcceptedStatus_ReturnsSuccessResponseAsync() + { + // Arrange + var request = CreateValidProcessContentRequest(); + var expectedResponse = new ProcessContentResponse + { + Id = "test-id-456", + ProtectionScopeState = ProtectionScopeState.Modified + }; + + this._handler.StatusCodeToReturn = HttpStatusCode.Accepted; + this._handler.ResponseToReturn = JsonSerializer.Serialize(expectedResponse, PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ProcessContentResponse))); + + // Act + var result = await this._client.ProcessContentAsync(request, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.Equal(expectedResponse.Id, result.Id); + Assert.Equal(ProtectionScopeState.Modified, result.ProtectionScopeState); + } + + [Fact] + public async Task ProcessContentAsync_WithScopeIdentifier_IncludesIfNoneMatchHeaderAsync() + { + // Arrange + var request = CreateValidProcessContentRequest(); + request.ScopeIdentifier = "\"test-scope-123\""; // ETags must be quoted + var expectedResponse = new ProcessContentResponse { Id = "test-id" }; + + this._handler.StatusCodeToReturn = HttpStatusCode.OK; + this._handler.ResponseToReturn = JsonSerializer.Serialize(expectedResponse, PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ProcessContentResponse))); + + // Act + await this._client.ProcessContentAsync(request, CancellationToken.None); + + // Assert + Assert.Equal("\"test-scope-123\"", this._handler.IfNoneMatchHeader); + } + + [Fact] + public async Task ProcessContentAsync_WithRateLimitError_ThrowsPurviewRateLimitExceptionAsync() + { + // Arrange + var request = CreateValidProcessContentRequest(); + this._handler.StatusCodeToReturn = (HttpStatusCode)429; + + // Act & Assert + await Assert.ThrowsAsync(() => + this._client.ProcessContentAsync(request, CancellationToken.None)); + } + + [Fact] + public async Task ProcessContentAsync_WithUnauthorizedError_ThrowsPurviewAuthenticationExceptionAsync() + { + // Arrange + var request = CreateValidProcessContentRequest(); + this._handler.StatusCodeToReturn = HttpStatusCode.Unauthorized; + + // Act & Assert + await Assert.ThrowsAsync(() => + this._client.ProcessContentAsync(request, CancellationToken.None)); + } + + [Fact] + public async Task ProcessContentAsync_WithForbiddenError_ThrowsPurviewAuthenticationExceptionAsync() + { + // Arrange + var request = CreateValidProcessContentRequest(); + this._handler.StatusCodeToReturn = HttpStatusCode.Forbidden; + + // Act & Assert + await Assert.ThrowsAsync(() => + this._client.ProcessContentAsync(request, CancellationToken.None)); + } + + [Fact] + public async Task ProcessContentAsync_WithPaymentRequiredError_ThrowsPurviewPaymentRequiredExceptionAsync() + { + // Arrange + var request = CreateValidProcessContentRequest(); + this._handler.StatusCodeToReturn = HttpStatusCode.PaymentRequired; + + // Act & Assert + await Assert.ThrowsAsync(() => + this._client.ProcessContentAsync(request, CancellationToken.None)); + } + + [Fact] + public async Task ProcessContentAsync_WithBadRequestError_ThrowsPurviewRequestExceptionAsync() + { + // Arrange + var request = CreateValidProcessContentRequest(); + this._handler.StatusCodeToReturn = HttpStatusCode.BadRequest; + + // Act & Assert + await Assert.ThrowsAsync(() => + this._client.ProcessContentAsync(request, CancellationToken.None)); + } + + [Fact] + public async Task ProcessContentAsync_WithInvalidJsonResponse_ThrowsPurviewExceptionAsync() + { + // Arrange + var request = CreateValidProcessContentRequest(); + this._handler.StatusCodeToReturn = HttpStatusCode.OK; + this._handler.ResponseToReturn = "invalid json"; + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + this._client.ProcessContentAsync(request, CancellationToken.None)); + + Assert.Contains("Failed to deserialize ProcessContent response", exception.Message); + Assert.NotNull(exception.InnerException); + Assert.IsType(exception.InnerException); + } + + [Fact] + public async Task ProcessContentAsync_WithHttpRequestException_ThrowsPurviewRequestExceptionAsync() + { + // Arrange + var request = CreateValidProcessContentRequest(); + this._handler.ShouldThrowHttpRequestException = true; + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + this._client.ProcessContentAsync(request, CancellationToken.None)); + + Assert.Equal("Http error occurred while processing content.", exception.Message); + Assert.NotNull(exception.InnerException); + Assert.IsType(exception.InnerException); + } + + #endregion + + #region GetProtectionScopesAsync Tests + + [Fact] + public async Task GetProtectionScopesAsync_WithValidRequest_ReturnsSuccessResponseAsync() + { + // Arrange + var request = new ProtectionScopesRequest("test-user-id", "test-tenant-id") + { + Activities = ProtectionScopeActivities.UploadText, + Locations = new List + { + new("microsoft.graph.policyLocationApplication", "app-123") + } + }; + + var expectedResponse = new ProtectionScopesResponse + { + Scopes = new List + { + new() + { + Activities = ProtectionScopeActivities.UploadText, + Locations = new List + { + new ("microsoft.graph.policyLocationApplication", "app-123") + } + } + } + }; + + this._handler.StatusCodeToReturn = HttpStatusCode.OK; + this._handler.ResponseToReturn = JsonSerializer.Serialize(expectedResponse, PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ProtectionScopesResponse))); + this._handler.ETagToReturn = "\"scope-etag-123\""; + + // Act + var result = await this._client.GetProtectionScopesAsync(request, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Scopes); + Assert.Single(result.Scopes); + Assert.Equal("\"scope-etag-123\"", result.ScopeIdentifier); // ETags are stored with quotes + + // Verify request + Assert.Equal("https://graph.microsoft.com/v1.0/users/test-user-id/dataSecurityAndGovernance/protectionScopes/compute", this._handler.RequestUri?.ToString()); + Assert.Equal(HttpMethod.Post, this._handler.RequestMethod); + } + + [Fact] + public async Task GetProtectionScopesAsync_SetsETagFromResponse_Async() + { + // Arrange + var request = new ProtectionScopesRequest("test-user-id", "test-tenant-id"); + var expectedResponse = new ProtectionScopesResponse { Scopes = new List() }; + + this._handler.StatusCodeToReturn = HttpStatusCode.OK; + this._handler.ResponseToReturn = JsonSerializer.Serialize(expectedResponse, PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ProtectionScopesResponse))); + this._handler.ETagToReturn = "\"custom-etag-456\""; + + // Act + var result = await this._client.GetProtectionScopesAsync(request, CancellationToken.None); + + // Assert + Assert.Equal("\"custom-etag-456\"", result.ScopeIdentifier); + } + + [Fact] + public async Task GetProtectionScopesAsync_WithRateLimitError_ThrowsPurviewRateLimitExceptionAsync() + { + // Arrange + var request = new ProtectionScopesRequest("test-user-id", "test-tenant-id"); + this._handler.StatusCodeToReturn = (HttpStatusCode)429; + + // Act & Assert + await Assert.ThrowsAsync(() => + this._client.GetProtectionScopesAsync(request, CancellationToken.None)); + } + + [Fact] + public async Task GetProtectionScopesAsync_WithUnauthorizedError_ThrowsPurviewAuthenticationExceptionAsync() + { + // Arrange + var request = new ProtectionScopesRequest("test-user-id", "test-tenant-id"); + this._handler.StatusCodeToReturn = HttpStatusCode.Unauthorized; + + // Act & Assert + await Assert.ThrowsAsync(() => + this._client.GetProtectionScopesAsync(request, CancellationToken.None)); + } + + [Fact] + public async Task GetProtectionScopesAsync_WithInvalidJsonResponse_ThrowsPurviewExceptionAsync() + { + // Arrange + var request = new ProtectionScopesRequest("test-user-id", "test-tenant-id"); + this._handler.StatusCodeToReturn = HttpStatusCode.OK; + this._handler.ResponseToReturn = "invalid json"; + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + this._client.GetProtectionScopesAsync(request, CancellationToken.None)); + + Assert.Contains("Failed to deserialize ProtectionScopes response", exception.Message); + Assert.NotNull(exception.InnerException); + Assert.IsType(exception.InnerException); + } + + [Fact] + public async Task GetProtectionScopesAsync_WithHttpRequestException_ThrowsPurviewRequestExceptionAsync() + { + // Arrange + var request = new ProtectionScopesRequest("test-user-id", "test-tenant-id"); + this._handler.ShouldThrowHttpRequestException = true; + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + this._client.GetProtectionScopesAsync(request, CancellationToken.None)); + + Assert.Equal("Http error occurred while retrieving protection scopes.", exception.Message); + Assert.NotNull(exception.InnerException); + Assert.IsType(exception.InnerException); + } + + #endregion + + #region SendContentActivitiesAsync Tests + + [Fact] + public async Task SendContentActivitiesAsync_WithValidRequest_ReturnsSuccessResponseAsync() + { + // Arrange + var contentToProcess = CreateValidContentToProcess(); + var request = new ContentActivitiesRequest("test-user-id", "test-tenant-id", contentToProcess); + var expectedResponse = new ContentActivitiesResponse + { + StatusCode = HttpStatusCode.Created + }; + + this._handler.StatusCodeToReturn = HttpStatusCode.Created; + this._handler.ResponseToReturn = JsonSerializer.Serialize(expectedResponse, PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ContentActivitiesResponse))); + + // Act + var result = await this._client.SendContentActivitiesAsync(request, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.Null(result.Error); + + // Verify request - note the endpoint is different from ProcessContent + Assert.Equal("https://graph.microsoft.com/v1.0/test-user-id/dataSecurityAndGovernance/activities/contentActivities", this._handler.RequestUri?.ToString()); + Assert.Equal(HttpMethod.Post, this._handler.RequestMethod); + } + + [Fact] + public async Task SendContentActivitiesAsync_WithError_ReturnsResponseWithErrorAsync() + { + // Arrange + var contentToProcess = CreateValidContentToProcess(); + var request = new ContentActivitiesRequest("test-user-id", "test-tenant-id", contentToProcess); + var expectedResponse = new ContentActivitiesResponse + { + Error = new ErrorDetails + { + Code = "InvalidRequest", + Message = "The request is invalid" + } + }; + + this._handler.StatusCodeToReturn = HttpStatusCode.Created; + this._handler.ResponseToReturn = JsonSerializer.Serialize(expectedResponse, PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ContentActivitiesResponse))); + + // Act + var result = await this._client.SendContentActivitiesAsync(request, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Error); + Assert.Equal("InvalidRequest", result.Error.Code); + Assert.Equal("The request is invalid", result.Error.Message); + } + + [Fact] + public async Task SendContentActivitiesAsync_WithRateLimitError_ThrowsPurviewRateLimitExceptionAsync() + { + // Arrange + var contentToProcess = CreateValidContentToProcess(); + var request = new ContentActivitiesRequest("test-user-id", "test-tenant-id", contentToProcess); + this._handler.StatusCodeToReturn = (HttpStatusCode)429; + + // Act & Assert + await Assert.ThrowsAsync(() => + this._client.SendContentActivitiesAsync(request, CancellationToken.None)); + } + + [Fact] + public async Task SendContentActivitiesAsync_WithUnauthorizedError_ThrowsPurviewAuthenticationExceptionAsync() + { + // Arrange + var contentToProcess = CreateValidContentToProcess(); + var request = new ContentActivitiesRequest("test-user-id", "test-tenant-id", contentToProcess); + this._handler.StatusCodeToReturn = HttpStatusCode.Unauthorized; + + // Act & Assert + await Assert.ThrowsAsync(() => + this._client.SendContentActivitiesAsync(request, CancellationToken.None)); + } + + [Fact] + public async Task SendContentActivitiesAsync_WithBadRequestError_ThrowsPurviewRequestExceptionAsync() + { + // Arrange + var contentToProcess = CreateValidContentToProcess(); + var request = new ContentActivitiesRequest("test-user-id", "test-tenant-id", contentToProcess); + this._handler.StatusCodeToReturn = HttpStatusCode.BadRequest; + + // Act & Assert + await Assert.ThrowsAsync(() => + this._client.SendContentActivitiesAsync(request, CancellationToken.None)); + } + + [Fact] + public async Task SendContentActivitiesAsync_WithInvalidJsonResponse_ThrowsPurviewExceptionAsync() + { + // Arrange + var contentToProcess = CreateValidContentToProcess(); + var request = new ContentActivitiesRequest("test-user-id", "test-tenant-id", contentToProcess); + this._handler.StatusCodeToReturn = HttpStatusCode.Created; + this._handler.ResponseToReturn = "invalid json"; + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + this._client.SendContentActivitiesAsync(request, CancellationToken.None)); + + Assert.Contains("Failed to deserialize ContentActivities response", exception.Message); + Assert.NotNull(exception.InnerException); + Assert.IsType(exception.InnerException); + } + + [Fact] + public async Task SendContentActivitiesAsync_WithHttpRequestException_ThrowsPurviewRequestExceptionAsync() + { + // Arrange + var contentToProcess = CreateValidContentToProcess(); + var request = new ContentActivitiesRequest("test-user-id", "test-tenant-id", contentToProcess); + this._handler.ShouldThrowHttpRequestException = true; + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + this._client.SendContentActivitiesAsync(request, CancellationToken.None)); + + Assert.Equal("Http error occurred while creating content activities.", exception.Message); + Assert.NotNull(exception.InnerException); + Assert.IsType(exception.InnerException); + } + + #endregion + + #region Helper Methods + + private static ProcessContentRequest CreateValidProcessContentRequest() + { + var contentToProcess = CreateValidContentToProcess(); + return new ProcessContentRequest(contentToProcess, "test-user-id", "test-tenant-id"); + } + + private static ContentToProcess CreateValidContentToProcess() + { + var content = new PurviewTextContent("Test content"); + var metadata = new ProcessConversationMetadata(content, "msg-123", false, "Test message"); + var activityMetadata = new ActivityMetadata(Activity.UploadText); + var deviceMetadata = new DeviceMetadata + { + OperatingSystemSpecifications = new OperatingSystemSpecifications + { + OperatingSystemPlatform = "Windows", + OperatingSystemVersion = "10" + } + }; + var integratedAppMetadata = new IntegratedAppMetadata + { + Name = "TestApp", + Version = "1.0" + }; + var policyLocation = new PolicyLocation("microsoft.graph.policyLocationApplication", "app-123"); + var protectedAppMetadata = new ProtectedAppMetadata(policyLocation) + { + Name = "TestApp", + Version = "1.0" + }; + + return new ContentToProcess( + new List { metadata }, + activityMetadata, + deviceMetadata, + integratedAppMetadata, + protectedAppMetadata + ); + } + + #endregion + + public void Dispose() + { + this._handler.Dispose(); + this._httpClient.Dispose(); + } + + /// + /// Mock HTTP message handler for testing + /// + internal sealed class PurviewClientHttpMessageHandlerStub : HttpMessageHandler + { + public HttpStatusCode StatusCodeToReturn { get; set; } = HttpStatusCode.OK; + public string? ResponseToReturn { get; set; } + public string? ETagToReturn { get; set; } + public bool ShouldThrowHttpRequestException { get; set; } + public Uri? RequestUri { get; private set; } + public HttpMethod? RequestMethod { get; private set; } + public string? AuthorizationHeader { get; private set; } + public string? IfNoneMatchHeader { get; private set; } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + // Capture request details + this.RequestUri = request.RequestUri; + this.RequestMethod = request.Method; + + if (request.Headers.Authorization != null) + { + this.AuthorizationHeader = request.Headers.Authorization.ToString(); + } + + if (request.Headers.TryGetValues("If-None-Match", out var ifNoneMatchValues)) + { + this.IfNoneMatchHeader = string.Join(", ", ifNoneMatchValues); + } + + // Throw HttpRequestException if configured + if (this.ShouldThrowHttpRequestException) + { + throw new HttpRequestException("Simulated network error"); + } + + var response = new HttpResponseMessage(this.StatusCodeToReturn); + + response.Content = new StringContent(this.ResponseToReturn ?? string.Empty, Encoding.UTF8, "application/json"); + + if (!string.IsNullOrEmpty(this.ETagToReturn)) + { + response.Headers.ETag = new System.Net.Http.Headers.EntityTagHeaderValue(this.ETagToReturn); + } + + return await Task.FromResult(response); + } + } + + /// + /// Mock token credential for testing + /// + internal sealed class MockTokenCredential : TokenCredential + { + public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) + { + return new AccessToken("mock-token", DateTimeOffset.UtcNow.AddHours(1)); + } + + public override ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) + { + return new ValueTask(new AccessToken("mock-token", DateTimeOffset.UtcNow.AddHours(1))); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/PurviewWrapperTests.cs b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/PurviewWrapperTests.cs new file mode 100644 index 0000000000..22b729dda4 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/PurviewWrapperTests.cs @@ -0,0 +1,571 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Purview.Models.Common; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace Microsoft.Agents.AI.Purview.UnitTests; + +/// +/// Unit tests for the class. +/// +public sealed class PurviewWrapperTests : IDisposable +{ + private readonly Mock _mockProcessor; + private readonly IChannelHandler _channelHandler; + private readonly PurviewSettings _settings; + private readonly PurviewWrapper _wrapper; + + public PurviewWrapperTests() + { + this._mockProcessor = new Mock(); + this._channelHandler = Mock.Of(); + this._settings = new PurviewSettings("TestApp") + { + TenantId = "tenant-123", + PurviewAppLocation = new PurviewAppLocation(PurviewLocationType.Application, "app-123"), + BlockedPromptMessage = "Prompt blocked by policy", + BlockedResponseMessage = "Response blocked by policy" + }; + this._wrapper = new PurviewWrapper(this._mockProcessor.Object, this._settings, NullLogger.Instance, this._channelHandler); + } + + #region ProcessChatContentAsync Tests + + [Fact] + public async Task ProcessChatContentAsync_WithBlockedPrompt_ReturnsBlockedMessageAsync() + { + // Arrange + var messages = new List + { + new(ChatRole.User, "Sensitive content that should be blocked") + }; + var mockChatClient = new Mock(); + + this._mockProcessor.Setup(x => x.ProcessMessagesAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync((true, "user-123")); + + // Act + var result = await this._wrapper.ProcessChatContentAsync(messages, null, mockChatClient.Object, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.Single(result.Messages); + Assert.Equal(ChatRole.System, result.Messages[0].Role); + Assert.Equal("Prompt blocked by policy", result.Messages[0].Text); + mockChatClient.Verify(x => x.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny()), Times.Never); + } + + [Fact] + public async Task ProcessChatContentAsync_WithAllowedPromptAndBlockedResponse_ReturnsBlockedMessageAsync() + { + // Arrange + var messages = new List + { + new(ChatRole.User, "Test message") + }; + var mockChatClient = new Mock(); + var innerResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Sensitive response")); + + mockChatClient.Setup(x => x.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(innerResponse); + + this._mockProcessor.SetupSequence(x => x.ProcessMessagesAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync((false, "user-123")) // Prompt allowed + .ReturnsAsync((true, "user-123")); // Response blocked + + // Act + var result = await this._wrapper.ProcessChatContentAsync(messages, null, mockChatClient.Object, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.Single(result.Messages); + Assert.Equal(ChatRole.System, result.Messages[0].Role); + Assert.Equal("Response blocked by policy", result.Messages[0].Text); + } + + [Fact] + public async Task ProcessChatContentAsync_WithAllowedPromptAndResponse_ReturnsInnerResponseAsync() + { + // Arrange + var messages = new List + { + new(ChatRole.User, "Test message") + }; + var mockChatClient = new Mock(); + var innerResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Safe response")); + + mockChatClient.Setup(x => x.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(innerResponse); + + this._mockProcessor.Setup(x => x.ProcessMessagesAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync((false, "user-123")); + + // Act + var result = await this._wrapper.ProcessChatContentAsync(messages, null, mockChatClient.Object, CancellationToken.None); + + // Assert + Assert.Same(innerResponse, result); + } + + [Fact] + public async Task ProcessChatContentAsync_WithIgnoreExceptions_ContinuesOnPromptErrorAsync() + { + // Arrange + var settingsWithIgnore = new PurviewSettings("TestApp") + { + TenantId = "tenant-123", + IgnoreExceptions = true, + PurviewAppLocation = new PurviewAppLocation(PurviewLocationType.Application, "app-123") + }; + var wrapper = new PurviewWrapper(this._mockProcessor.Object, settingsWithIgnore, NullLogger.Instance, this._channelHandler); + + var messages = new List + { + new(ChatRole.User, "Test message") + }; + + var expectedResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Response from inner client")); + var mockChatClient = new Mock(); + mockChatClient.Setup(x => x.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(expectedResponse); + + this._mockProcessor.SetupSequence(x => x.ProcessMessagesAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync(new PurviewRequestException("Prompt processing error")); // Response processing succeeds + + // Act + var result = await wrapper.ProcessChatContentAsync(messages, null, mockChatClient.Object, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.Same(expectedResponse, result); + } + + [Fact] + public async Task ProcessChatContentAsync_WithoutIgnoreExceptions_ThrowsOnPromptErrorAsync() + { + // Arrange + var messages = new List + { + new(ChatRole.User, "Test message") + }; + var mockChatClient = new Mock(); + + this._mockProcessor.Setup(x => x.ProcessMessagesAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync(new PurviewRequestException("Prompt processing error")); + + // Act & Assert + await Assert.ThrowsAsync(() => + this._wrapper.ProcessChatContentAsync(messages, null, mockChatClient.Object, CancellationToken.None)); + } + + [Fact] + public async Task ProcessChatContentAsync_UsesConversationIdFromOptions_Async() + { + // Arrange + var messages = new List + { + new(ChatRole.User, "Test message") + }; + var options = new ChatOptions { ConversationId = "conversation-123" }; + var mockChatClient = new Mock(); + var innerResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Response")); + + mockChatClient.Setup(x => x.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(innerResponse); + + this._mockProcessor.Setup(x => x.ProcessMessagesAsync( + It.IsAny>(), + "conversation-123", + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync((false, "user-123")); + + // Act + await this._wrapper.ProcessChatContentAsync(messages, options, mockChatClient.Object, CancellationToken.None); + + // Assert + this._mockProcessor.Verify(x => x.ProcessMessagesAsync( + It.IsAny>(), + "conversation-123", + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Exactly(2)); + } + + #endregion + + #region ProcessAgentContentAsync Tests + + [Fact] + public async Task ProcessAgentContentAsync_WithBlockedPrompt_ReturnsBlockedMessageAsync() + { + // Arrange + var messages = new List + { + new(ChatRole.User, "Sensitive content") + }; + var mockAgent = new Mock(); + + this._mockProcessor.Setup(x => x.ProcessMessagesAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync((true, "user-123")); + + // Act + var result = await this._wrapper.ProcessAgentContentAsync(messages, null, null, mockAgent.Object, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.Single(result.Messages); + Assert.Equal(ChatRole.System, result.Messages[0].Role); + Assert.Equal("Prompt blocked by policy", result.Messages[0].Text); + mockAgent.Verify(x => x.RunAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Never); + } + + [Fact] + public async Task ProcessAgentContentAsync_WithAllowedPromptAndBlockedResponse_ReturnsBlockedMessageAsync() + { + // Arrange + var messages = new List + { + new(ChatRole.User, "Test message") + }; + var mockAgent = new Mock(); + var innerResponse = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Sensitive response")); + + mockAgent.Setup(x => x.RunAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(innerResponse); + + this._mockProcessor.SetupSequence(x => x.ProcessMessagesAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync((false, "user-123")) // Prompt allowed + .ReturnsAsync((true, "user-123")); // Response blocked + + // Act + var result = await this._wrapper.ProcessAgentContentAsync(messages, null, null, mockAgent.Object, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.Single(result.Messages); + Assert.Equal(ChatRole.System, result.Messages[0].Role); + Assert.Equal("Response blocked by policy", result.Messages[0].Text); + } + + [Fact] + public async Task ProcessAgentContentAsync_WithAllowedPromptAndResponse_ReturnsInnerResponseAsync() + { + // Arrange + var messages = new List + { + new(ChatRole.User, "Test message") + }; + var mockAgent = new Mock(); + var innerResponse = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Safe response")); + + mockAgent.Setup(x => x.RunAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(innerResponse); + + this._mockProcessor.Setup(x => x.ProcessMessagesAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync((false, "user-123")); + + // Act + var result = await this._wrapper.ProcessAgentContentAsync(messages, null, null, mockAgent.Object, CancellationToken.None); + + // Assert + Assert.Same(innerResponse, result); + } + + [Fact] + public async Task ProcessAgentContentAsync_WithIgnoreExceptions_ContinuesOnErrorAsync() + { + // Arrange + var settingsWithIgnore = new PurviewSettings("TestApp") + { + TenantId = "tenant-123", + IgnoreExceptions = true, + PurviewAppLocation = new PurviewAppLocation(PurviewLocationType.Application, "app-123") + }; + var wrapper = new PurviewWrapper(this._mockProcessor.Object, settingsWithIgnore, NullLogger.Instance, this._channelHandler); + + var messages = new List + { + new(ChatRole.User, "Test message") + }; + + var expectedResponse = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Response from inner agent")); + var mockAgent = new Mock(); + mockAgent.Setup(x => x.RunAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(expectedResponse); + + this._mockProcessor.SetupSequence(x => x.ProcessMessagesAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync(new PurviewRequestException("Prompt processing error")) + .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 + { + new(ChatRole.User, "Test message") + }; + var mockAgent = new Mock(); + + this._mockProcessor.Setup(x => x.ProcessMessagesAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync(new PurviewRequestException("Processing error")); + + // Act & Assert + await Assert.ThrowsAsync(() => + this._wrapper.ProcessAgentContentAsync(messages, null, null, mockAgent.Object, CancellationToken.None)); + } + + [Fact] + public async Task ProcessAgentContentAsync_ExtractsThreadIdFromMessageAdditionalProperties_Async() + { + // Arrange + var messages = new List + { + 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(); + mockAgent.Setup(x => x.RunAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(expectedResponse); + + this._mockProcessor.Setup(x => x.ProcessMessagesAsync( + It.IsAny>(), + "conversation-from-props", + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync((false, "user-123")); + + // Act + var result = await this._wrapper.ProcessAgentContentAsync(messages, null, null, mockAgent.Object, CancellationToken.None); + + // Assert + Assert.NotNull(result); + this._mockProcessor.Verify(x => x.ProcessMessagesAsync( + It.IsAny>(), + "conversation-from-props", + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Exactly(2)); + } + + [Fact] + public async Task ProcessAgentContentAsync_GeneratesThreadId_WhenNotProvidedAsync() + { + // Arrange + var messages = new List + { + new(ChatRole.User, "Test message") + }; + + var expectedResponse = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Response")); + var mockAgent = new Mock(); + mockAgent.Setup(x => x.RunAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(expectedResponse); + + string? capturedThreadId = null; + this._mockProcessor.Setup(x => x.ProcessMessagesAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback, 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 + { + new(ChatRole.User, "Test message") + }; + var mockAgent = new Mock(); + var innerResponse = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Response")); + + mockAgent.Setup(x => x.RunAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(innerResponse); + + var callCount = 0; + string? firstCallUserId = null; + string? secondCallUserId = null; + + this._mockProcessor.Setup(x => x.ProcessMessagesAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback, 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(); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/ScopedContentProcessorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/ScopedContentProcessorTests.cs new file mode 100644 index 0000000000..f43f086de7 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/ScopedContentProcessorTests.cs @@ -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; + +/// +/// Unit tests for the class. +/// +public sealed class ScopedContentProcessorTests +{ + private readonly Mock _mockPurviewClient; + private readonly Mock _mockCacheProvider; + private readonly Mock _mockChannelHandler; + private readonly ScopedContentProcessor _processor; + + public ScopedContentProcessorTests() + { + this._mockPurviewClient = new Mock(); + this._mockCacheProvider = new Mock(); + this._mockChannelHandler = new Mock(); + 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 + { + 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(), null)) + .ReturnsAsync(tokenInfo); + + this._mockCacheProvider.Setup(x => x.GetAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync((ProtectionScopesResponse?)null); + + var psResponse = new ProtectionScopesResponse + { + Scopes = new List + { + new() + { + Activities = ProtectionScopeActivities.UploadText, + Locations = new List + { + new ("microsoft.graph.policyLocationApplication", "app-123") + }, + ExecutionMode = ExecutionMode.EvaluateInline + } + } + }; + + this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(psResponse); + + var pcResponse = new ProcessContentResponse + { + PolicyActions = new List + { + new() { Action = DlpAction.BlockAccess } + } + }; + + this._mockPurviewClient.Setup(x => x.ProcessContentAsync( + It.IsAny(), It.IsAny())) + .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 + { + 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(), null)) + .ReturnsAsync(tokenInfo); + + this._mockCacheProvider.Setup(x => x.GetAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync((ProtectionScopesResponse?)null); + + var psResponse = new ProtectionScopesResponse + { + Scopes = new List + { + new() + { + Activities = ProtectionScopeActivities.UploadText, + Locations = new List + { + new ("microsoft.graph.policyLocationApplication", "app-123") + }, + ExecutionMode = ExecutionMode.EvaluateInline + } + } + }; + + this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(psResponse); + + var pcResponse = new ProcessContentResponse + { + PolicyActions = new List + { + new() { RestrictionAction = RestrictionAction.Block } + } + }; + + this._mockPurviewClient.Setup(x => x.ProcessContentAsync( + It.IsAny(), It.IsAny())) + .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 + { + 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(), null)) + .ReturnsAsync(tokenInfo); + + this._mockCacheProvider.Setup(x => x.GetAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync((ProtectionScopesResponse?)null); + + var psResponse = new ProtectionScopesResponse + { + Scopes = new List + { + new() + { + Activities = ProtectionScopeActivities.UploadText, + Locations = new List + { + new("microsoft.graph.policyLocationApplication", "app-123") + }, + ExecutionMode = ExecutionMode.EvaluateInline + } + } + }; + + this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(psResponse); + + var pcResponse = new ProcessContentResponse + { + PolicyActions = new List + { + new() { Action = DlpAction.NotifyUser } + } + }; + + this._mockPurviewClient.Setup(x => x.ProcessContentAsync( + It.IsAny(), It.IsAny())) + .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 + { + 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(), null)) + .ReturnsAsync(tokenInfo); + + var cachedPsResponse = new ProtectionScopesResponse + { + Scopes = new List + { + new() + { + Activities = ProtectionScopeActivities.UploadText, + Locations = new List + { + new ("microsoft.graph.policyLocationApplication", "app-123") + }, + ExecutionMode = ExecutionMode.EvaluateInline + } + } + }; + + this._mockCacheProvider.Setup(x => x.GetAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(cachedPsResponse); + + var pcResponse = new ProcessContentResponse + { + PolicyActions = new List() + }; + + this._mockPurviewClient.Setup(x => x.ProcessContentAsync( + It.IsAny(), It.IsAny())) + .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(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task ProcessMessagesAsync_InvalidatesCache_WhenProtectionScopeModifiedAsync() + { + // Arrange + var messages = new List + { + 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(), null)) + .ReturnsAsync(tokenInfo); + + this._mockCacheProvider.Setup(x => x.GetAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync((ProtectionScopesResponse?)null); + + var psResponse = new ProtectionScopesResponse + { + Scopes = new List + { + new() + { + Activities = ProtectionScopeActivities.UploadText, + Locations = new List + { + new ("microsoft.graph.policyLocationApplication", "app-123") + }, + ExecutionMode = ExecutionMode.EvaluateInline + } + } + }; + + this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(psResponse); + + var pcResponse = new ProcessContentResponse + { + ProtectionScopeState = ProtectionScopeState.Modified, + PolicyActions = new List() + }; + + this._mockPurviewClient.Setup(x => x.ProcessContentAsync( + It.IsAny(), It.IsAny())) + .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(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task ProcessMessagesAsync_SendsContentActivities_WhenNoApplicableScopesAsync() + { + // Arrange + var messages = new List + { + 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(), null)) + .ReturnsAsync(tokenInfo); + + this._mockCacheProvider.Setup(x => x.GetAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync((ProtectionScopesResponse?)null); + + var psResponse = new ProtectionScopesResponse + { + Scopes = new List + { + new() + { + Activities = ProtectionScopeActivities.UploadText, + Locations = new List + { + new ("microsoft.graph.policyLocationApplication", "app-456") + } + } + } + }; + + this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync( + It.IsAny(), It.IsAny())) + .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()), Times.Once); + this._mockPurviewClient.Verify(x => x.ProcessContentAsync( + It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task ProcessMessagesAsync_WithNoTenantId_ThrowsPurviewExceptionAsync() + { + // Arrange + var messages = new List + { + 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(), null)) + .ReturnsAsync(tokenInfo); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + 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 + { + 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(), null)) + .ReturnsAsync(tokenInfo); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + 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 + { + 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(), null)) + .ReturnsAsync(tokenInfo); + + this._mockCacheProvider.Setup(x => x.GetAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync((ProtectionScopesResponse?)null); + + var psResponse = new ProtectionScopesResponse { Scopes = new List() }; + this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync( + It.IsAny(), It.IsAny())) + .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 + { + 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(), null)) + .ReturnsAsync(tokenInfo); + + this._mockCacheProvider.Setup(x => x.GetAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync((ProtectionScopesResponse?)null); + + var psResponse = new ProtectionScopesResponse { Scopes = new List() }; + this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync( + It.IsAny(), It.IsAny())) + .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 +} diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_client.py b/python/packages/azure-ai/agent_framework_azure_ai/_client.py index 774349a85d..2fa54ea565 100644 --- a/python/packages/azure-ai/agent_framework_azure_ai/_client.py +++ b/python/packages/azure-ai/agent_framework_azure_ai/_client.py @@ -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) diff --git a/python/packages/azure-ai/tests/test_azure_ai_client.py b/python/packages/azure-ai/tests/test_azure_ai_client.py index 576218f270..ead970785d 100644 --- a/python/packages/azure-ai/tests/test_azure_ai_client.py +++ b/python/packages/azure-ai/tests/test_azure_ai_client.py @@ -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], diff --git a/python/packages/core/agent_framework/_mcp.py b/python/packages/core/agent_framework/_mcp.py index 873b7f04cc..66c96425c8 100644 --- a/python/packages/core/agent_framework/_mcp.py +++ b/python/packages/core/agent_framework/_mcp.py @@ -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, diff --git a/python/packages/core/tests/core/test_mcp.py b/python/packages/core/tests/core/test_mcp.py index e6dd1fd8a7..865e2ef484 100644 --- a/python/packages/core/tests/core/test_mcp.py +++ b/python/packages/core/tests/core/test_mcp.py @@ -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 diff --git a/python/samples/demos/hosted_agents/agent_with_hosted_mcp/.env.example b/python/samples/demos/hosted_agents/agent_with_hosted_mcp/.env.example new file mode 100644 index 0000000000..4a1424ba25 --- /dev/null +++ b/python/samples/demos/hosted_agents/agent_with_hosted_mcp/.env.example @@ -0,0 +1,2 @@ +OPENAI_CHAT_MODEL_ID="gpt-4o-2024-08-06" +OPENAI_API_KEY="your-openai-api-key" diff --git a/python/samples/demos/hosted_agents/agent_with_hosted_mcp/Dockerfile b/python/samples/demos/hosted_agents/agent_with_hosted_mcp/Dockerfile new file mode 100644 index 0000000000..eaffb94f19 --- /dev/null +++ b/python/samples/demos/hosted_agents/agent_with_hosted_mcp/Dockerfile @@ -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"] \ No newline at end of file diff --git a/python/samples/demos/hosted_agents/agent_with_hosted_mcp/README.md b/python/samples/demos/hosted_agents/agent_with_hosted_mcp/README.md new file mode 100644 index 0000000000..6c7bff0e2b --- /dev/null +++ b/python/samples/demos/hosted_agents/agent_with_hosted_mcp/README.md @@ -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 don’t 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"} +``` diff --git a/python/samples/demos/hosted_agents/agent_with_hosted_mcp/main.py b/python/samples/demos/hosted_agents/agent_with_hosted_mcp/main.py new file mode 100644 index 0000000000..cb8c626d32 --- /dev/null +++ b/python/samples/demos/hosted_agents/agent_with_hosted_mcp/main.py @@ -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() diff --git a/python/samples/demos/hosted_agents/agent_with_hosted_mcp/requirements.txt b/python/samples/demos/hosted_agents/agent_with_hosted_mcp/requirements.txt new file mode 100644 index 0000000000..d05845588a --- /dev/null +++ b/python/samples/demos/hosted_agents/agent_with_hosted_mcp/requirements.txt @@ -0,0 +1,2 @@ +azure-ai-agentserver-agentframework==1.0.0b3 +agent-framework \ No newline at end of file diff --git a/python/samples/demos/hosted_agents/agent_with_text_search_rag/.env.example b/python/samples/demos/hosted_agents/agent_with_text_search_rag/.env.example new file mode 100644 index 0000000000..4a1424ba25 --- /dev/null +++ b/python/samples/demos/hosted_agents/agent_with_text_search_rag/.env.example @@ -0,0 +1,2 @@ +OPENAI_CHAT_MODEL_ID="gpt-4o-2024-08-06" +OPENAI_API_KEY="your-openai-api-key" diff --git a/python/samples/demos/hosted_agents/agent_with_text_search_rag/Dockerfile b/python/samples/demos/hosted_agents/agent_with_text_search_rag/Dockerfile new file mode 100644 index 0000000000..eaffb94f19 --- /dev/null +++ b/python/samples/demos/hosted_agents/agent_with_text_search_rag/Dockerfile @@ -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"] \ No newline at end of file diff --git a/python/samples/demos/hosted_agents/agent_with_text_search_rag/README.md b/python/samples/demos/hosted_agents/agent_with_text_search_rag/README.md new file mode 100644 index 0000000000..fde8d2c729 --- /dev/null +++ b/python/samples/demos/hosted_agents/agent_with_text_search_rag/README.md @@ -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"} +``` diff --git a/python/samples/demos/hosted_agents/agent_with_text_search_rag/main.py b/python/samples/demos/hosted_agents/agent_with_text_search_rag/main.py new file mode 100644 index 0000000000..14b418361e --- /dev/null +++ b/python/samples/demos/hosted_agents/agent_with_text_search_rag/main.py @@ -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() diff --git a/python/samples/demos/hosted_agents/agent_with_text_search_rag/requirements.txt b/python/samples/demos/hosted_agents/agent_with_text_search_rag/requirements.txt new file mode 100644 index 0000000000..d05845588a --- /dev/null +++ b/python/samples/demos/hosted_agents/agent_with_text_search_rag/requirements.txt @@ -0,0 +1,2 @@ +azure-ai-agentserver-agentframework==1.0.0b3 +agent-framework \ No newline at end of file diff --git a/python/samples/demos/hosted_agents/agents_in_workflow/.env.example b/python/samples/demos/hosted_agents/agents_in_workflow/.env.example new file mode 100644 index 0000000000..4a1424ba25 --- /dev/null +++ b/python/samples/demos/hosted_agents/agents_in_workflow/.env.example @@ -0,0 +1,2 @@ +OPENAI_CHAT_MODEL_ID="gpt-4o-2024-08-06" +OPENAI_API_KEY="your-openai-api-key" diff --git a/python/samples/demos/hosted_agents/agents_in_workflow/Dockerfile b/python/samples/demos/hosted_agents/agents_in_workflow/Dockerfile new file mode 100644 index 0000000000..eaffb94f19 --- /dev/null +++ b/python/samples/demos/hosted_agents/agents_in_workflow/Dockerfile @@ -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"] \ No newline at end of file diff --git a/python/samples/demos/hosted_agents/agents_in_workflow/README.md b/python/samples/demos/hosted_agents/agents_in_workflow/README.md new file mode 100644 index 0000000000..5f8cc8e993 --- /dev/null +++ b/python/samples/demos/hosted_agents/agents_in_workflow/README.md @@ -0,0 +1,49 @@ +# Hosted Workflow Agents Demo + +This demo showcases an agent that is backed by a workflow of multiple agents running concurrently, hosted as an agent endpoint in a Docker container. + +## What the Project Does + +This project demonstrates how to: + +- Build a workflow of agents using the Agent Framework +- Host the workflow agent as an agent endpoint running in a Docker container + +The agent responds to product launch strategy inquiries by concurrently leveraging insights from three specialized agents: + +- **Researcher Agent** - Provides market research insights +- **Marketer Agent** - Crafts marketing value propositions and messaging +- **Legal Agent** - Reviews for compliance and legal considerations + +## 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-workflow . + +# Run the container +docker run -p 8088:8088 hosted-agent-workflow +``` + +> 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": "We are launching a new budget-friendly electric bike for urban commuters.","stream":false}' +``` + +> Expected response is not shown here for brevity. The response will include insights from the researcher, marketer, and legal agents based on the input prompt. diff --git a/python/samples/demos/hosted_agents/agents_in_workflow/main.py b/python/samples/demos/hosted_agents/agents_in_workflow/main.py new file mode 100644 index 0000000000..57622a0eaf --- /dev/null +++ b/python/samples/demos/hosted_agents/agents_in_workflow/main.py @@ -0,0 +1,43 @@ +# Copyright (c) Microsoft. All rights reserved. + +from agent_framework import ConcurrentBuilder +from agent_framework.openai import OpenAIChatClient +from azure.ai.agentserver.agentframework import from_agent_framework # pyright: ignore[reportUnknownVariableType] + + +def main(): + # Create agents + researcher = OpenAIChatClient().create_agent( + instructions=( + "You're an expert market and product researcher. " + "Given a prompt, provide concise, factual insights, opportunities, and risks." + ), + name="researcher", + ) + marketer = OpenAIChatClient().create_agent( + instructions=( + "You're a creative marketing strategist. " + "Craft compelling value propositions and target messaging aligned to the prompt." + ), + name="marketer", + ) + legal = OpenAIChatClient().create_agent( + instructions=( + "You're a cautious legal/compliance reviewer. " + "Highlight constraints, disclaimers, and policy concerns based on the prompt." + ), + name="legal", + ) + + # Build a concurrent workflow + workflow = ConcurrentBuilder().participants([researcher, marketer, legal]).build() + + # Convert the workflow to an agent + workflow_agent = workflow.as_agent() + + # Run the agent as a hosted agent + from_agent_framework(workflow_agent).run() + + +if __name__ == "__main__": + main() diff --git a/python/samples/demos/hosted_agents/agents_in_workflow/requirements.txt b/python/samples/demos/hosted_agents/agents_in_workflow/requirements.txt new file mode 100644 index 0000000000..d05845588a --- /dev/null +++ b/python/samples/demos/hosted_agents/agents_in_workflow/requirements.txt @@ -0,0 +1,2 @@ +azure-ai-agentserver-agentframework==1.0.0b3 +agent-framework \ No newline at end of file diff --git a/python/samples/getting_started/agents/azure_ai/azure_ai_with_code_interpreter.py b/python/samples/getting_started/agents/azure_ai/azure_ai_with_code_interpreter.py index a2ea4aafe3..b81a2add85 100644 --- a/python/samples/getting_started/agents/azure_ai/azure_ai_with_code_interpreter.py +++ b/python/samples/getting_started/agents/azure_ai/azure_ai_with_code_interpreter.py @@ -22,6 +22,7 @@ async def main() -> None: async with ( AzureCliCredential() as credential, AzureAIClient(async_credential=credential).create_agent( + name="MyCodeInterpreterAgent", instructions="You are a helpful assistant that can write and execute Python code to solve problems.", tools=HostedCodeInterpreterTool(), ) as agent, diff --git a/python/samples/getting_started/azure_functions/04_single_agent_orchestration_chaining/function_app.py b/python/samples/getting_started/azure_functions/04_single_agent_orchestration_chaining/function_app.py index 15a95327b9..377af2763a 100644 --- a/python/samples/getting_started/azure_functions/04_single_agent_orchestration_chaining/function_app.py +++ b/python/samples/getting_started/azure_functions/04_single_agent_orchestration_chaining/function_app.py @@ -12,10 +12,9 @@ import json import logging from typing import Any -import azure.durable_functions as df import azure.functions as func from agent_framework.azure import AgentFunctionApp, AzureOpenAIChatClient -from azure.durable_functions import DurableOrchestrationContext +from azure.durable_functions import DurableOrchestrationClient, DurableOrchestrationContext from azure.identity import AzureCliCredential logger = logging.getLogger(__name__) @@ -74,7 +73,7 @@ def single_agent_orchestration(context: DurableOrchestrationContext): @app.durable_client_input(client_name="client") async def start_single_agent_orchestration( req: func.HttpRequest, - client: df.DurableOrchestrationClient, + client: DurableOrchestrationClient, ) -> func.HttpResponse: """Start the orchestration and return status metadata.""" @@ -104,7 +103,7 @@ async def start_single_agent_orchestration( @app.durable_client_input(client_name="client") async def get_orchestration_status( req: func.HttpRequest, - client: df.DurableOrchestrationClient, + client: DurableOrchestrationClient, ) -> func.HttpResponse: """Return orchestration runtime status.""" diff --git a/python/samples/getting_started/azure_functions/05_multi_agent_orchestration_concurrency/function_app.py b/python/samples/getting_started/azure_functions/05_multi_agent_orchestration_concurrency/function_app.py index 6d0fd17eca..59f59a4f40 100644 --- a/python/samples/getting_started/azure_functions/05_multi_agent_orchestration_concurrency/function_app.py +++ b/python/samples/getting_started/azure_functions/05_multi_agent_orchestration_concurrency/function_app.py @@ -12,10 +12,9 @@ import json import logging from typing import Any -import azure.durable_functions as df import azure.functions as func from agent_framework.azure import AgentFunctionApp, AzureOpenAIChatClient -from azure.durable_functions import DurableOrchestrationContext +from azure.durable_functions import DurableOrchestrationClient, DurableOrchestrationContext from azure.identity import AzureCliCredential logger = logging.getLogger(__name__) @@ -80,7 +79,7 @@ def multi_agent_concurrent_orchestration(context: DurableOrchestrationContext): @app.durable_client_input(client_name="client") async def start_multi_agent_concurrent_orchestration( req: func.HttpRequest, - client: df.DurableOrchestrationClient, + client: DurableOrchestrationClient, ) -> func.HttpResponse: """Kick off the orchestration with a plain text prompt.""" @@ -121,7 +120,7 @@ async def start_multi_agent_concurrent_orchestration( @app.durable_client_input(client_name="client") async def get_orchestration_status( req: func.HttpRequest, - client: df.DurableOrchestrationClient, + client: DurableOrchestrationClient, ) -> func.HttpResponse: instance_id = req.route_params.get("instanceId") if not instance_id: diff --git a/python/samples/getting_started/azure_functions/06_multi_agent_orchestration_conditionals/README.md b/python/samples/getting_started/azure_functions/06_multi_agent_orchestration_conditionals/README.md index 3271f88744..da38bf0dd6 100644 --- a/python/samples/getting_started/azure_functions/06_multi_agent_orchestration_conditionals/README.md +++ b/python/samples/getting_started/azure_functions/06_multi_agent_orchestration_conditionals/README.md @@ -16,9 +16,9 @@ Set up the shared prerequisites outlined in `../README.md`, including the virtua Submit an email payload: ```bash -curl -X POST http://localhost:7071/api/spamdetection/run \ +curl -X POST "http://localhost:7071/api/spamdetection/run" \ -H "Content-Type: application/json" \ - -d '{"subject": "Sale now on", "body": "Limited time offer"}' + -d '{"email_id": "email-001", "email_content": "URGENT! You'\''ve won $1,000,000! Click here now to claim your prize! Limited time offer! Don'\''t miss out!"}' ``` Poll the returned `statusQueryGetUri` or call the status route directly: diff --git a/python/samples/getting_started/azure_functions/06_multi_agent_orchestration_conditionals/function_app.py b/python/samples/getting_started/azure_functions/06_multi_agent_orchestration_conditionals/function_app.py index a5991e76f5..84d114b3b5 100644 --- a/python/samples/getting_started/azure_functions/06_multi_agent_orchestration_conditionals/function_app.py +++ b/python/samples/getting_started/azure_functions/06_multi_agent_orchestration_conditionals/function_app.py @@ -14,10 +14,9 @@ import logging from collections.abc import Mapping from typing import Any, cast -import azure.durable_functions as df import azure.functions as func from agent_framework.azure import AgentFunctionApp, AzureOpenAIChatClient -from azure.durable_functions import DurableOrchestrationContext +from azure.durable_functions import DurableOrchestrationClient, DurableOrchestrationContext from azure.identity import AzureCliCredential from pydantic import BaseModel, ValidationError @@ -134,7 +133,7 @@ def spam_detection_orchestration(context: DurableOrchestrationContext): @app.durable_client_input(client_name="client") async def start_spam_detection_orchestration( req: func.HttpRequest, - client: df.DurableOrchestrationClient, + client: DurableOrchestrationClient, ) -> func.HttpResponse: try: body = req.get_json() @@ -185,7 +184,7 @@ async def start_spam_detection_orchestration( @app.durable_client_input(client_name="client") async def get_orchestration_status( req: func.HttpRequest, - client: df.DurableOrchestrationClient, + client: DurableOrchestrationClient, ) -> func.HttpResponse: instance_id = req.route_params.get("instanceId") if not instance_id: diff --git a/python/samples/getting_started/azure_functions/07_single_agent_orchestration_hitl/function_app.py b/python/samples/getting_started/azure_functions/07_single_agent_orchestration_hitl/function_app.py index aac7d092f1..7257ff7831 100644 --- a/python/samples/getting_started/azure_functions/07_single_agent_orchestration_hitl/function_app.py +++ b/python/samples/getting_started/azure_functions/07_single_agent_orchestration_hitl/function_app.py @@ -14,10 +14,9 @@ from collections.abc import Mapping from datetime import timedelta from typing import Any -import azure.durable_functions as df import azure.functions as func from agent_framework.azure import AgentFunctionApp, AzureOpenAIChatClient -from azure.durable_functions import DurableOrchestrationContext +from azure.durable_functions import DurableOrchestrationClient, DurableOrchestrationContext from azure.identity import AzureCliCredential from pydantic import BaseModel, ValidationError @@ -160,7 +159,7 @@ def content_generation_hitl_orchestration(context: DurableOrchestrationContext): @app.durable_client_input(client_name="client") async def start_content_generation( req: func.HttpRequest, - client: df.DurableOrchestrationClient, + client: DurableOrchestrationClient, ) -> func.HttpResponse: try: body = req.get_json() @@ -209,7 +208,7 @@ async def start_content_generation( @app.durable_client_input(client_name="client") async def send_human_approval( req: func.HttpRequest, - client: df.DurableOrchestrationClient, + client: DurableOrchestrationClient, ) -> func.HttpResponse: instance_id = req.route_params.get("instanceId") if not instance_id: @@ -260,7 +259,7 @@ async def send_human_approval( @app.durable_client_input(client_name="client") async def get_orchestration_status( req: func.HttpRequest, - client: df.DurableOrchestrationClient, + client: DurableOrchestrationClient, ) -> func.HttpResponse: instance_id = req.route_params.get("instanceId") if not instance_id: diff --git a/python/shared_tasks.toml b/python/shared_tasks.toml index 5641bb5faa..775cb6ec0a 100644 --- a/python/shared_tasks.toml +++ b/python/shared_tasks.toml @@ -5,6 +5,6 @@ lint = "ruff check" pyright = "pyright" publish = "uv publish" clean-dist = "rm -rf dist" -build-package = "python -m flit build" +build-package = "uv build" move-dist = "sh -c 'mkdir -p ../../dist && mv dist/* ../../dist/ 2>/dev/null || true'" build = ["build-package", "move-dist"] diff --git a/python/uv.lock b/python/uv.lock index 9baa7aa402..826c75a5b4 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.13' and sys_platform == 'darwin'", @@ -1052,11 +1052,11 @@ wheels = [ [[package]] name = "cachetools" -version = "6.2.1" +version = "6.2.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cc/7e/b975b5814bd36faf009faebe22c1072a1fa1168db34d285ef0ba071ad78c/cachetools-6.2.1.tar.gz", hash = "sha256:3f391e4bd8f8bf0931169baf7456cc822705f4e2a31f840d218f445b9a854201", size = 31325, upload-time = "2025-10-12T14:55:30.139Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fb/44/ca1675be2a83aeee1886ab745b28cda92093066590233cc501890eb8417a/cachetools-6.2.2.tar.gz", hash = "sha256:8e6d266b25e539df852251cfd6f990b4bc3a141db73b939058d809ebd2590fc6", size = 31571, upload-time = "2025-11-13T17:42:51.465Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/96/c5/1e741d26306c42e2bf6ab740b2202872727e0f606033c9dd713f8b93f5a8/cachetools-6.2.1-py3-none-any.whl", hash = "sha256:09868944b6dde876dfd44e1d47e18484541eaf12f26f29b7af91b26cc892d701", size = 11280, upload-time = "2025-10-12T14:55:28.382Z" }, + { url = "https://files.pythonhosted.org/packages/e6/46/eb6eca305c77a4489affe1c5d8f4cae82f285d9addd8de4ec084a7184221/cachetools-6.2.2-py3-none-any.whl", hash = "sha256:6c09c98183bf58560c97b2abfcedcbaf6a896a490f534b031b661d3723b45ace", size = 11503, upload-time = "2025-11-13T17:42:50.232Z" }, ] [[package]] @@ -1729,7 +1729,7 @@ name = "exceptiongroup" version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "typing-extensions", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } wheels = [ @@ -1747,7 +1747,7 @@ wheels = [ [[package]] name = "fastapi" -version = "0.121.1" +version = "0.121.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -1755,9 +1755,9 @@ dependencies = [ { name = "starlette", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6b/a4/29e1b861fc9017488ed02ff1052feffa40940cb355ed632a8845df84ce84/fastapi-0.121.1.tar.gz", hash = "sha256:b6dba0538fd15dab6fe4d3e5493c3957d8a9e1e9257f56446b5859af66f32441", size = 342523, upload-time = "2025-11-08T21:48:14.068Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fb/48/f08f264da34cf160db82c62ffb335e838b1fc16cbcc905f474c7d4c815db/fastapi-0.121.2.tar.gz", hash = "sha256:ca8e932b2b823ec1721c641e3669472c855ad9564a2854c9899d904c2848b8b9", size = 342944, upload-time = "2025-11-13T17:05:54.692Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/94/fd/2e6f7d706899cc08690c5f6641e2ffbfffe019e8f16ce77104caa5730910/fastapi-0.121.1-py3-none-any.whl", hash = "sha256:2c5c7028bc3a58d8f5f09aecd3fd88a000ccc0c5ad627693264181a3c33aa1fc", size = 109192, upload-time = "2025-11-08T21:48:12.458Z" }, + { url = "https://files.pythonhosted.org/packages/eb/23/dfb161e91db7c92727db505dc72a384ee79681fe0603f706f9f9f52c2901/fastapi-0.121.2-py3-none-any.whl", hash = "sha256:f2d80b49a86a846b70cc3a03eb5ea6ad2939298bf6a7fe377aa9cd3dd079d358", size = 109201, upload-time = "2025-11-13T17:05:52.718Z" }, ] [[package]] @@ -2413,7 +2413,7 @@ wheels = [ [[package]] name = "huggingface-hub" -version = "1.1.2" +version = "1.1.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filelock", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -2427,9 +2427,9 @@ dependencies = [ { name = "typer-slim", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b8/63/eeea214a6b456d8e91ac2ea73ebb83da3af9aa64716dfb6e28dd9b2e6223/huggingface_hub-1.1.2.tar.gz", hash = "sha256:7bdafc432dc12fa1f15211bdfa689a02531d2a47a3cc0d74935f5726cdbcab8e", size = 606173, upload-time = "2025-11-06T10:04:38.398Z" } +sdist = { url = "https://files.pythonhosted.org/packages/44/8a/3cba668d9cd1b4e3eb6c1c3ff7bf0f74a7809bdbb5c327bcdbdbac802d23/huggingface_hub-1.1.4.tar.gz", hash = "sha256:a7424a766fffa1a11e4c1ac2040a1557e2101f86050fdf06627e7b74cc9d2ad6", size = 606842, upload-time = "2025-11-13T10:51:57.602Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/33/21/e15d90fd09b56938502a0348d566f1915f9789c5bb6c00c1402dc7259b6e/huggingface_hub-1.1.2-py3-none-any.whl", hash = "sha256:dfcfa84a043466fac60573c3e4af475490a7b0d7375b22e3817706d6659f61f7", size = 514955, upload-time = "2025-11-06T10:04:36.674Z" }, + { url = "https://files.pythonhosted.org/packages/33/3f/969137c9d9428ed8bf171d27604243dd950a47cac82414826e2aebbc0a4c/huggingface_hub-1.1.4-py3-none-any.whl", hash = "sha256:867799fbd2ef338b7f8b03d038d9c0e09415dfe45bb2893b48a510d1d746daa5", size = 515580, upload-time = "2025-11-13T10:51:55.742Z" }, ] [[package]] @@ -3067,7 +3067,7 @@ wheels = [ [[package]] name = "mcp" -version = "1.21.0" +version = "1.21.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -3081,11 +3081,13 @@ dependencies = [ { name = "pywin32", marker = "sys_platform == 'win32'" }, { name = "sse-starlette", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "starlette", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "typing-inspection", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "uvicorn", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/33/54/dd2330ef4611c27ae59124820863c34e1d3edb1133c58e6375e2d938c9c5/mcp-1.21.0.tar.gz", hash = "sha256:bab0a38e8f8c48080d787233343f8d301b0e1e95846ae7dead251b2421d99855", size = 452697, upload-time = "2025-11-06T23:19:58.432Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/25/4df633e7574254ada574822db2245bbee424725d1b01bccae10bf128794e/mcp-1.21.1.tar.gz", hash = "sha256:540e6ac4b12b085c43f14879fde04cbdb10148a09ea9492ff82d8c7ba651a302", size = 469071, upload-time = "2025-11-13T20:33:46.139Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/47/850b6edc96c03bd44b00de9a0ca3c1cc71e0ba1cd5822955bc9e4eb3fad3/mcp-1.21.0-py3-none-any.whl", hash = "sha256:598619e53eb0b7a6513db38c426b28a4bdf57496fed04332100d2c56acade98b", size = 173672, upload-time = "2025-11-06T23:19:56.508Z" }, + { url = "https://files.pythonhosted.org/packages/49/af/01fb42df59ad15925ffc1e2e609adafddd3ac4572f606faae0dc8b55ba0c/mcp-1.21.1-py3-none-any.whl", hash = "sha256:dd35abe36d68530a8a1291daa25d50276d8731e545c0434d6e250a3700dd2a6d", size = 174852, upload-time = "2025-11-13T20:33:44.502Z" }, ] [package.optional-dependencies] @@ -3104,7 +3106,7 @@ wheels = [ [[package]] name = "mem0ai" -version = "1.0.0" +version = "1.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -3115,9 +3117,9 @@ dependencies = [ { name = "qdrant-client", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "sqlalchemy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/99/02/b6c3bba83b4bb6450e6c8a07e4419b24644007588f5ef427b680addbd30f/mem0ai-1.0.0.tar.gz", hash = "sha256:8a891502e6547436adb526a59acf091cacaa689e182e186f4dd8baf185d75224", size = 177780, upload-time = "2025-10-16T10:36:23.871Z" } +sdist = { url = "https://files.pythonhosted.org/packages/39/cd/f9047cd45952af08da8084c2297f8aad780f9ac8558631fc64b3ed235b28/mem0ai-1.0.1.tar.gz", hash = "sha256:53be77f479387e6c07508096eb6c0688150b31152613bdcf6c281246b000b14d", size = 182296, upload-time = "2025-11-13T22:32:13.658Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/61/49/eed6e2a77bf90e37da25c9a336af6a6129b0baae76551409ee995f0a1f0c/mem0ai-1.0.0-py3-none-any.whl", hash = "sha256:107fd2990613eba34880ca6578e6cdd4a8158fd35f5b80be031b6e2b5a66a1f1", size = 268141, upload-time = "2025-10-16T10:36:21.63Z" }, + { url = "https://files.pythonhosted.org/packages/81/42/120d6db33e190ef09d69428ddd2eaaa87e10f4c8243af788f5fc524748c9/mem0ai-1.0.1-py3-none-any.whl", hash = "sha256:a8eeca9688e87f175af53d463b4a3b2d552984c81e29bc656c847dc04eaf6f75", size = 275351, upload-time = "2025-11-13T22:32:11.839Z" }, ] [[package]] @@ -3617,7 +3619,7 @@ wheels = [ [[package]] name = "openai" -version = "2.7.2" +version = "2.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -3629,14 +3631,14 @@ dependencies = [ { name = "tqdm", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/71/e3/cec27fa28ef36c4ccea71e9e8c20be9b8539618732989a82027575aab9d4/openai-2.7.2.tar.gz", hash = "sha256:082ef61163074d8efad0035dd08934cf5e3afd37254f70fc9165dd6a8c67dcbd", size = 595732, upload-time = "2025-11-10T16:42:31.108Z" } +sdist = { url = "https://files.pythonhosted.org/packages/04/0c/b9321e12f89e236f5e9a46346c30fb801818e22ba33b798a5aca84be895c/openai-2.8.0.tar.gz", hash = "sha256:4851908f6d6fcacbd47ba659c5ac084f7725b752b6bfa1e948b6fbfc111a6bad", size = 602412, upload-time = "2025-11-13T18:15:25.847Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/25/66/22cfe4b695b5fd042931b32c67d685e867bfd169ebf46036b95b57314c33/openai-2.7.2-py3-none-any.whl", hash = "sha256:116f522f4427f8a0a59b51655a356da85ce092f3ed6abeca65f03c8be6e073d9", size = 1008375, upload-time = "2025-11-10T16:42:28.574Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e1/0a6560bab7fb7b5a88d35a505b859c6d969cb2fa2681b568eb5d95019dec/openai-2.8.0-py3-none-any.whl", hash = "sha256:ba975e347f6add2fe13529ccb94d54a578280e960765e5224c34b08d7e029ddf", size = 1022692, upload-time = "2025-11-13T18:15:23.621Z" }, ] [[package]] name = "openai-agents" -version = "0.5.0" +version = "0.5.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "griffe", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -3647,14 +3649,14 @@ dependencies = [ { name = "types-requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/00/b3/c25c3b3084c113ffbd161c37ed7c02e0cc1296eb2f2d582d85c461f60c7a/openai_agents-0.5.0.tar.gz", hash = "sha256:776dde4025442164e3e860ff5b239b5c0ebc30f9445b0d75295c385a8ca1f696", size = 1958702, upload-time = "2025-11-05T05:28:37.456Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/1f/322595f9a7ffd48afe2449bb92090eb893ba6ae4475e3ee549f64566a3a1/openai_agents-0.5.1.tar.gz", hash = "sha256:e193cd3a1b0d4f9a3f3fa9c4011c0b1f8876fa5f38bde4ae41d6a834a5791124", size = 1990900, upload-time = "2025-11-13T17:59:36.173Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d5/f5/c43a84a64aa3328c628cc19365dc514ce02abf31e31c861ad489d6d3075b/openai_agents-0.5.0-py3-none-any.whl", hash = "sha256:5ef062273815de197315ec760f571625d0f2766ceb83ab189ba6cdd9b26a10e9", size = 223272, upload-time = "2025-11-05T05:28:35.64Z" }, + { url = "https://files.pythonhosted.org/packages/10/ca/352a7167ac040f9e7d6765081f4021811914724303d1417525d50942e15e/openai_agents-0.5.1-py3-none-any.whl", hash = "sha256:7077c47d8e4230d788a18922df7cd69f13c3328a57744156195da4921c08c835", size = 231786, upload-time = "2025-11-13T17:59:32.691Z" }, ] [[package]] name = "openai-chatkit" -version = "1.1.2" +version = "1.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -3662,9 +3664,9 @@ dependencies = [ { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "uvicorn", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/36/cc/d395cfb7db9ac2b677c70a95483cf28f5b0c1de79b66e079e1c634d31a69/openai_chatkit-1.1.2.tar.gz", hash = "sha256:53d783ced5460be7adbd65b9a966ace969576cc7361171e511130ff576f4a61c", size = 49635, upload-time = "2025-11-07T22:15:35.362Z" } +sdist = { url = "https://files.pythonhosted.org/packages/11/e1/c247260aa9b319109e88b9aef69d6497d881b1d8e1b464c502a3173f0482/openai_chatkit-1.2.0.tar.gz", hash = "sha256:4aa8b3ab5f525fd6e4b9e88d6bb6fab7d5a12a5e51e22d0753fa8835f79ce3c5", size = 50160, upload-time = "2025-11-13T20:46:05.775Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/38/15e5651407cf81548a0549498f028127d81e7e985e18a7228e575d48dece/openai_chatkit-1.1.2-py3-none-any.whl", hash = "sha256:402d304b880be8d6b26e291484de62420e5f25d2c9fc3070b6807e840eb9e158", size = 35352, upload-time = "2025-11-07T22:15:33.987Z" }, + { url = "https://files.pythonhosted.org/packages/f4/b8/7f8c54db201dc73867ab83cd5781035845cb474dfe8e9760be69c19c337d/openai_chatkit-1.2.0-py3-none-any.whl", hash = "sha256:4a8b40a160cf7c32b48dd813e3977567a124bb851ba27c01098a7cb249b453ec", size = 35577, upload-time = "2025-11-13T20:46:04.17Z" }, ] [[package]] @@ -5322,28 +5324,28 @@ wheels = [ [[package]] name = "ruff" -version = "0.14.4" +version = "0.14.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/df/55/cccfca45157a2031dcbb5a462a67f7cf27f8b37d4b3b1cd7438f0f5c1df6/ruff-0.14.4.tar.gz", hash = "sha256:f459a49fe1085a749f15414ca76f61595f1a2cc8778ed7c279b6ca2e1fd19df3", size = 5587844, upload-time = "2025-11-06T22:07:45.033Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/fa/fbb67a5780ae0f704876cb8ac92d6d76da41da4dc72b7ed3565ab18f2f52/ruff-0.14.5.tar.gz", hash = "sha256:8d3b48d7d8aad423d3137af7ab6c8b1e38e4de104800f0d596990f6ada1a9fc1", size = 5615944, upload-time = "2025-11-13T19:58:51.155Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/17/b9/67240254166ae1eaa38dec32265e9153ac53645a6c6670ed36ad00722af8/ruff-0.14.4-py3-none-linux_armv6l.whl", hash = "sha256:e6604613ffbcf2297cd5dcba0e0ac9bd0c11dc026442dfbb614504e87c349518", size = 12606781, upload-time = "2025-11-06T22:07:01.841Z" }, - { url = "https://files.pythonhosted.org/packages/46/c8/09b3ab245d8652eafe5256ab59718641429f68681ee713ff06c5c549f156/ruff-0.14.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d99c0b52b6f0598acede45ee78288e5e9b4409d1ce7f661f0fa36d4cbeadf9a4", size = 12946765, upload-time = "2025-11-06T22:07:05.858Z" }, - { url = "https://files.pythonhosted.org/packages/14/bb/1564b000219144bf5eed2359edc94c3590dd49d510751dad26202c18a17d/ruff-0.14.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:9358d490ec030f1b51d048a7fd6ead418ed0826daf6149e95e30aa67c168af33", size = 11928120, upload-time = "2025-11-06T22:07:08.023Z" }, - { url = "https://files.pythonhosted.org/packages/a3/92/d5f1770e9988cc0742fefaa351e840d9aef04ec24ae1be36f333f96d5704/ruff-0.14.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81b40d27924f1f02dfa827b9c0712a13c0e4b108421665322218fc38caf615c2", size = 12370877, upload-time = "2025-11-06T22:07:10.015Z" }, - { url = "https://files.pythonhosted.org/packages/e2/29/e9282efa55f1973d109faf839a63235575519c8ad278cc87a182a366810e/ruff-0.14.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f5e649052a294fe00818650712083cddc6cc02744afaf37202c65df9ea52efa5", size = 12408538, upload-time = "2025-11-06T22:07:13.085Z" }, - { url = "https://files.pythonhosted.org/packages/8e/01/930ed6ecfce130144b32d77d8d69f5c610e6d23e6857927150adf5d7379a/ruff-0.14.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa082a8f878deeba955531f975881828fd6afd90dfa757c2b0808aadb437136e", size = 13141942, upload-time = "2025-11-06T22:07:15.386Z" }, - { url = "https://files.pythonhosted.org/packages/6a/46/a9c89b42b231a9f487233f17a89cbef9d5acd538d9488687a02ad288fa6b/ruff-0.14.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1043c6811c2419e39011890f14d0a30470f19d47d197c4858b2787dfa698f6c8", size = 14544306, upload-time = "2025-11-06T22:07:17.631Z" }, - { url = "https://files.pythonhosted.org/packages/78/96/9c6cf86491f2a6d52758b830b89b78c2ae61e8ca66b86bf5a20af73d20e6/ruff-0.14.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a9f3a936ac27fb7c2a93e4f4b943a662775879ac579a433291a6f69428722649", size = 14210427, upload-time = "2025-11-06T22:07:19.832Z" }, - { url = "https://files.pythonhosted.org/packages/71/f4/0666fe7769a54f63e66404e8ff698de1dcde733e12e2fd1c9c6efb689cb5/ruff-0.14.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:95643ffd209ce78bc113266b88fba3d39e0461f0cbc8b55fb92505030fb4a850", size = 13658488, upload-time = "2025-11-06T22:07:22.32Z" }, - { url = "https://files.pythonhosted.org/packages/ee/79/6ad4dda2cfd55e41ac9ed6d73ef9ab9475b1eef69f3a85957210c74ba12c/ruff-0.14.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:456daa2fa1021bc86ca857f43fe29d5d8b3f0e55e9f90c58c317c1dcc2afc7b5", size = 13354908, upload-time = "2025-11-06T22:07:24.347Z" }, - { url = "https://files.pythonhosted.org/packages/b5/60/f0b6990f740bb15c1588601d19d21bcc1bd5de4330a07222041678a8e04f/ruff-0.14.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:f911bba769e4a9f51af6e70037bb72b70b45a16db5ce73e1f72aefe6f6d62132", size = 13587803, upload-time = "2025-11-06T22:07:26.327Z" }, - { url = "https://files.pythonhosted.org/packages/c9/da/eaaada586f80068728338e0ef7f29ab3e4a08a692f92eb901a4f06bbff24/ruff-0.14.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:76158a7369b3979fa878612c623a7e5430c18b2fd1c73b214945c2d06337db67", size = 12279654, upload-time = "2025-11-06T22:07:28.46Z" }, - { url = "https://files.pythonhosted.org/packages/66/d4/b1d0e82cf9bf8aed10a6d45be47b3f402730aa2c438164424783ac88c0ed/ruff-0.14.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f3b8f3b442d2b14c246e7aeca2e75915159e06a3540e2f4bed9f50d062d24469", size = 12357520, upload-time = "2025-11-06T22:07:31.468Z" }, - { url = "https://files.pythonhosted.org/packages/04/f4/53e2b42cc82804617e5c7950b7079d79996c27e99c4652131c6a1100657f/ruff-0.14.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c62da9a06779deecf4d17ed04939ae8b31b517643b26370c3be1d26f3ef7dbde", size = 12719431, upload-time = "2025-11-06T22:07:33.831Z" }, - { url = "https://files.pythonhosted.org/packages/a2/94/80e3d74ed9a72d64e94a7b7706b1c1ebaa315ef2076fd33581f6a1cd2f95/ruff-0.14.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5a443a83a1506c684e98acb8cb55abaf3ef725078be40237463dae4463366349", size = 13464394, upload-time = "2025-11-06T22:07:35.905Z" }, - { url = "https://files.pythonhosted.org/packages/54/1a/a49f071f04c42345c793d22f6cf5e0920095e286119ee53a64a3a3004825/ruff-0.14.4-py3-none-win32.whl", hash = "sha256:643b69cb63cd996f1fc7229da726d07ac307eae442dd8974dbc7cf22c1e18fff", size = 12493429, upload-time = "2025-11-06T22:07:38.43Z" }, - { url = "https://files.pythonhosted.org/packages/bc/22/e58c43e641145a2b670328fb98bc384e20679b5774258b1e540207580266/ruff-0.14.4-py3-none-win_amd64.whl", hash = "sha256:26673da283b96fe35fa0c939bf8411abec47111644aa9f7cfbd3c573fb125d2c", size = 13635380, upload-time = "2025-11-06T22:07:40.496Z" }, - { url = "https://files.pythonhosted.org/packages/30/bd/4168a751ddbbf43e86544b4de8b5c3b7be8d7167a2a5cb977d274e04f0a1/ruff-0.14.4-py3-none-win_arm64.whl", hash = "sha256:dd09c292479596b0e6fec8cd95c65c3a6dc68e9ad17b8f2382130f87ff6a75bb", size = 12663065, upload-time = "2025-11-06T22:07:42.603Z" }, + { url = "https://files.pythonhosted.org/packages/68/31/c07e9c535248d10836a94e4f4e8c5a31a1beed6f169b31405b227872d4f4/ruff-0.14.5-py3-none-linux_armv6l.whl", hash = "sha256:f3b8248123b586de44a8018bcc9fefe31d23dda57a34e6f0e1e53bd51fd63594", size = 13171630, upload-time = "2025-11-13T19:57:54.894Z" }, + { url = "https://files.pythonhosted.org/packages/8e/5c/283c62516dca697cd604c2796d1487396b7a436b2f0ecc3fd412aca470e0/ruff-0.14.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f7a75236570318c7a30edd7f5491945f0169de738d945ca8784500b517163a72", size = 13413925, upload-time = "2025-11-13T19:57:59.181Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f3/aa319f4afc22cb6fcba2b9cdfc0f03bbf747e59ab7a8c5e90173857a1361/ruff-0.14.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6d146132d1ee115f8802356a2dc9a634dbf58184c51bff21f313e8cd1c74899a", size = 12574040, upload-time = "2025-11-13T19:58:02.056Z" }, + { url = "https://files.pythonhosted.org/packages/f9/7f/cb5845fcc7c7e88ed57f58670189fc2ff517fe2134c3821e77e29fd3b0c8/ruff-0.14.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2380596653dcd20b057794d55681571a257a42327da8894b93bbd6111aa801f", size = 13009755, upload-time = "2025-11-13T19:58:05.172Z" }, + { url = "https://files.pythonhosted.org/packages/21/d2/bcbedbb6bcb9253085981730687ddc0cc7b2e18e8dc13cf4453de905d7a0/ruff-0.14.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2d1fa985a42b1f075a098fa1ab9d472b712bdb17ad87a8ec86e45e7fa6273e68", size = 12937641, upload-time = "2025-11-13T19:58:08.345Z" }, + { url = "https://files.pythonhosted.org/packages/a4/58/e25de28a572bdd60ffc6bb71fc7fd25a94ec6a076942e372437649cbb02a/ruff-0.14.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88f0770d42b7fa02bbefddde15d235ca3aa24e2f0137388cc15b2dcbb1f7c7a7", size = 13610854, upload-time = "2025-11-13T19:58:11.419Z" }, + { url = "https://files.pythonhosted.org/packages/7d/24/43bb3fd23ecee9861970978ea1a7a63e12a204d319248a7e8af539984280/ruff-0.14.5-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:3676cb02b9061fee7294661071c4709fa21419ea9176087cb77e64410926eb78", size = 15061088, upload-time = "2025-11-13T19:58:14.551Z" }, + { url = "https://files.pythonhosted.org/packages/23/44/a022f288d61c2f8c8645b24c364b719aee293ffc7d633a2ca4d116b9c716/ruff-0.14.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b595bedf6bc9cab647c4a173a61acf4f1ac5f2b545203ba82f30fcb10b0318fb", size = 14734717, upload-time = "2025-11-13T19:58:17.518Z" }, + { url = "https://files.pythonhosted.org/packages/58/81/5c6ba44de7e44c91f68073e0658109d8373b0590940efe5bd7753a2585a3/ruff-0.14.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f55382725ad0bdb2e8ee2babcbbfb16f124f5a59496a2f6a46f1d9d99d93e6e2", size = 14028812, upload-time = "2025-11-13T19:58:20.533Z" }, + { url = "https://files.pythonhosted.org/packages/ad/ef/41a8b60f8462cb320f68615b00299ebb12660097c952c600c762078420f8/ruff-0.14.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7497d19dce23976bdaca24345ae131a1d38dcfe1b0850ad8e9e6e4fa321a6e19", size = 13825656, upload-time = "2025-11-13T19:58:23.345Z" }, + { url = "https://files.pythonhosted.org/packages/7c/00/207e5de737fdb59b39eb1fac806904fe05681981b46d6a6db9468501062e/ruff-0.14.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:410e781f1122d6be4f446981dd479470af86537fb0b8857f27a6e872f65a38e4", size = 13959922, upload-time = "2025-11-13T19:58:26.537Z" }, + { url = "https://files.pythonhosted.org/packages/bc/7e/fa1f5c2776db4be405040293618846a2dece5c70b050874c2d1f10f24776/ruff-0.14.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c01be527ef4c91a6d55e53b337bfe2c0f82af024cc1a33c44792d6844e2331e1", size = 12932501, upload-time = "2025-11-13T19:58:29.822Z" }, + { url = "https://files.pythonhosted.org/packages/67/d8/d86bf784d693a764b59479a6bbdc9515ae42c340a5dc5ab1dabef847bfaa/ruff-0.14.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f66e9bb762e68d66e48550b59c74314168ebb46199886c5c5aa0b0fbcc81b151", size = 12927319, upload-time = "2025-11-13T19:58:32.923Z" }, + { url = "https://files.pythonhosted.org/packages/ac/de/ee0b304d450ae007ce0cb3e455fe24fbcaaedae4ebaad6c23831c6663651/ruff-0.14.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d93be8f1fa01022337f1f8f3bcaa7ffee2d0b03f00922c45c2207954f351f465", size = 13206209, upload-time = "2025-11-13T19:58:35.952Z" }, + { url = "https://files.pythonhosted.org/packages/33/aa/193ca7e3a92d74f17d9d5771a765965d2cf42c86e6f0fd95b13969115723/ruff-0.14.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c135d4b681f7401fe0e7312017e41aba9b3160861105726b76cfa14bc25aa367", size = 13953709, upload-time = "2025-11-13T19:58:39.002Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f1/7119e42aa1d3bf036ffc9478885c2e248812b7de9abea4eae89163d2929d/ruff-0.14.5-py3-none-win32.whl", hash = "sha256:c83642e6fccfb6dea8b785eb9f456800dcd6a63f362238af5fc0c83d027dd08b", size = 12925808, upload-time = "2025-11-13T19:58:42.779Z" }, + { url = "https://files.pythonhosted.org/packages/3b/9d/7c0a255d21e0912114784e4a96bf62af0618e2190cae468cd82b13625ad2/ruff-0.14.5-py3-none-win_amd64.whl", hash = "sha256:9d55d7af7166f143c94eae1db3312f9ea8f95a4defef1979ed516dbb38c27621", size = 14331546, upload-time = "2025-11-13T19:58:45.691Z" }, + { url = "https://files.pythonhosted.org/packages/e5/80/69756670caedcf3b9be597a6e12276a6cf6197076eb62aad0c608f8efce0/ruff-0.14.5-py3-none-win_arm64.whl", hash = "sha256:4b700459d4649e2594b31f20a9de33bc7c19976d4746d8d0798ad959621d64a4", size = 13433331, upload-time = "2025-11-13T19:58:48.434Z" }, ] [[package]]