Skip to content

Commit b19860b

Browse files
.NET: Implement Purview middleware in dotnet (#1949)
* Move Purview integration logic into middleware * Improve error handling and user id management * Rename purview package * Handle 402s more explicitly; add Middleware generation methods; don't ignore exceptions * Use DI container; pass scope id to PC * Add protection scope caching * Wrap more exceptions in PurviewClient * Remove block check dedup; add tests * Refactor PurviewWrapper intialization; Add unit tests * Use different .Use method and add IDisposable stub * Add background job processing for Purview * Misc comment cleanup * Apply copilot comments * Fix formatting * Formatting other files to fix pipeline * Small updates to settings and exceptions * Add README * Move Purview sample * Address review comments and update XML comments * Newline after namespace * Move public Purview classes to single namespace; Clean up csproj and slnx * Commit the renames * Remove unused openAI dependency --------- Co-authored-by: Dmytro Struk <[email protected]>
1 parent a75590e commit b19860b

File tree

80 files changed

+5727
-0
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

80 files changed

+5727
-0
lines changed

dotnet/agent-framework-dotnet.slnx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,9 @@
9191
<Project Path="samples/GettingStarted/AgentWithOpenAI/Agent_OpenAI_Step01_Running/Agent_OpenAI_Step01_Running.csproj" />
9292
<Project Path="samples/GettingStarted/AgentWithOpenAI/Agent_OpenAI_Step02_Reasoning/Agent_OpenAI_Step02_Reasoning.csproj" />
9393
</Folder>
94+
<Folder Name="/Samples/Purview/AgentWithPurview/">
95+
<Project Path="samples/Purview/AgentWithPurview/AgentWithPurview.csproj" />
96+
</Folder>
9497
<Folder Name="/Samples/GettingStarted/AgentWithRAG/">
9598
<File Path="samples/GettingStarted/AgentWithRAG/README.md" />
9699
<Project Path="samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/AgentWithRAG_Step01_BasicTextRAG.csproj" />
@@ -310,6 +313,7 @@
310313
<Project Path="src/Microsoft.Agents.AI.Hosting/Microsoft.Agents.AI.Hosting.csproj" />
311314
<Project Path="src/Microsoft.Agents.AI.Mem0/Microsoft.Agents.AI.Mem0.csproj" />
312315
<Project Path="src/Microsoft.Agents.AI.OpenAI/Microsoft.Agents.AI.OpenAI.csproj" />
316+
<Project Path="src/Microsoft.Agents.AI.Purview/Microsoft.Agents.AI.Purview.csproj" />
313317
<Project Path="src/Microsoft.Agents.AI.Workflows.Declarative/Microsoft.Agents.AI.Workflows.Declarative.csproj" />
314318
<Project Path="src/Microsoft.Agents.AI.Workflows/Microsoft.Agents.AI.Workflows.csproj" />
315319
<Project Path="src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj" />
@@ -341,6 +345,7 @@
341345
<Project Path="tests/Microsoft.Agents.AI.Hosting.UnitTests/Microsoft.Agents.AI.Hosting.UnitTests.csproj" />
342346
<Project Path="tests/Microsoft.Agents.AI.Mem0.UnitTests/Microsoft.Agents.AI.Mem0.UnitTests.csproj" />
343347
<Project Path="tests/Microsoft.Agents.AI.OpenAI.UnitTests/Microsoft.Agents.AI.OpenAI.UnitTests.csproj" />
348+
<Project Path="tests/Microsoft.Agents.AI.Purview.UnitTests/Microsoft.Agents.AI.Purview.UnitTests.csproj" />
344349
<Project Path="tests/Microsoft.Agents.AI.UnitTests/Microsoft.Agents.AI.UnitTests.csproj" />
345350
<Project Path="tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests.csproj" />
346351
<Project Path="tests/Microsoft.Agents.AI.Workflows.UnitTests/Microsoft.Agents.AI.Workflows.UnitTests.csproj" />
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<OutputType>Exe</OutputType>
5+
<TargetFramework>net9.0</TargetFramework>
6+
7+
<Nullable>enable</Nullable>
8+
<ImplicitUsings>enable</ImplicitUsings>
9+
</PropertyGroup>
10+
11+
<ItemGroup>
12+
<PackageReference Include="Azure.AI.OpenAI" />
13+
<PackageReference Include="Azure.Identity" />
14+
</ItemGroup>
15+
16+
<ItemGroup>
17+
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
18+
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Purview\Microsoft.Agents.AI.Purview.csproj" />
19+
</ItemGroup>
20+
21+
</Project>
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
// This sample shows how to create and use a simple AI agent with Purview integration.
4+
// It uses Azure OpenAI as the backend, but any IChatClient can be used.
5+
// Authentication to Purview is done using an InteractiveBrowserCredential.
6+
// Any TokenCredential with Purview API permissions can be used here.
7+
8+
using Azure.AI.OpenAI;
9+
using Azure.Core;
10+
using Azure.Identity;
11+
using Microsoft.Agents.AI;
12+
using Microsoft.Agents.AI.Purview;
13+
using Microsoft.Extensions.AI;
14+
15+
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
16+
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
17+
var purviewClientAppId = Environment.GetEnvironmentVariable("PURVIEW_CLIENT_APP_ID") ?? throw new InvalidOperationException("PURVIEW_CLIENT_APP_ID is not set.");
18+
19+
// This will get a user token for an entra app configured to call the Purview API.
20+
// Any TokenCredential with permissions to call the Purview API can be used here.
21+
TokenCredential browserCredential = new InteractiveBrowserCredential(
22+
new InteractiveBrowserCredentialOptions
23+
{
24+
ClientId = purviewClientAppId
25+
});
26+
27+
using IChatClient client = new AzureOpenAIClient(
28+
new Uri(endpoint),
29+
new AzureCliCredential())
30+
.GetOpenAIResponseClient(deploymentName)
31+
.AsIChatClient()
32+
.AsBuilder()
33+
.WithPurview(browserCredential, new PurviewSettings("Agent Framework Test App"))
34+
.Build();
35+
36+
Console.WriteLine("Enter a prompt to send to the client:");
37+
string? promptText = Console.ReadLine();
38+
39+
if (!string.IsNullOrEmpty(promptText))
40+
{
41+
// Invoke the agent and output the text result.
42+
Console.WriteLine(await client.GetResponseAsync(promptText));
43+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
using System;
4+
using System.Threading;
5+
using System.Threading.Channels;
6+
using System.Threading.Tasks;
7+
using Microsoft.Agents.AI.Purview.Models.Jobs;
8+
using Microsoft.Extensions.Logging;
9+
10+
namespace Microsoft.Agents.AI.Purview;
11+
12+
/// <summary>
13+
/// Service that runs jobs in background threads.
14+
/// </summary>
15+
internal sealed class BackgroundJobRunner
16+
{
17+
private readonly IChannelHandler _channelHandler;
18+
private readonly IPurviewClient _purviewClient;
19+
private readonly ILogger _logger;
20+
21+
/// <summary>
22+
/// Initializes a new instance of the <see cref="BackgroundJobRunner"/> class.
23+
/// </summary>
24+
/// <param name="channelHandler">The channel handler used to manage job channels.</param>
25+
/// <param name="purviewClient">The Purview client used to send requests to Purview.</param>
26+
/// <param name="logger">The logger used to log information about background jobs.</param>
27+
/// <param name="purviewSettings">The settings used to configure Purview client behavior.</param>
28+
public BackgroundJobRunner(IChannelHandler channelHandler, IPurviewClient purviewClient, ILogger logger, PurviewSettings purviewSettings)
29+
{
30+
this._channelHandler = channelHandler;
31+
this._purviewClient = purviewClient;
32+
this._logger = logger;
33+
34+
for (int i = 0; i < purviewSettings.MaxConcurrentJobConsumers; i++)
35+
{
36+
this._channelHandler.AddRunner(async (Channel<BackgroundJobBase> channel) =>
37+
{
38+
await foreach (BackgroundJobBase job in channel.Reader.ReadAllAsync().ConfigureAwait(false))
39+
{
40+
try
41+
{
42+
await this.RunJobAsync(job).ConfigureAwait(false);
43+
}
44+
catch (Exception e) when (
45+
!(e is OperationCanceledException) &&
46+
!(e is SystemException))
47+
{
48+
this._logger.LogError(e, "Error running background job {BackgroundJobError}.", e.Message);
49+
}
50+
}
51+
});
52+
}
53+
}
54+
55+
/// <summary>
56+
/// Runs a job.
57+
/// </summary>
58+
/// <param name="job">The job to run.</param>
59+
/// <returns>A task representing the job.</returns>
60+
private async Task RunJobAsync(BackgroundJobBase job)
61+
{
62+
switch (job)
63+
{
64+
case ProcessContentJob processContentJob:
65+
_ = await this._purviewClient.ProcessContentAsync(processContentJob.Request, CancellationToken.None).ConfigureAwait(false);
66+
break;
67+
case ContentActivityJob contentActivityJob:
68+
_ = await this._purviewClient.SendContentActivitiesAsync(contentActivityJob.Request, CancellationToken.None).ConfigureAwait(false);
69+
break;
70+
}
71+
}
72+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
using System.Text.Json;
4+
using System.Text.Json.Serialization.Metadata;
5+
using System.Threading;
6+
using System.Threading.Tasks;
7+
using Microsoft.Agents.AI.Purview.Serialization;
8+
using Microsoft.Extensions.Caching.Distributed;
9+
10+
namespace Microsoft.Agents.AI.Purview;
11+
12+
/// <summary>
13+
/// Manages caching of values.
14+
/// </summary>
15+
internal sealed class CacheProvider : ICacheProvider
16+
{
17+
private readonly IDistributedCache _cache;
18+
private readonly PurviewSettings _purviewSettings;
19+
20+
/// <summary>
21+
/// Create a new instance of the <see cref="CacheProvider"/> class.
22+
/// </summary>
23+
/// <param name="cache">The cache where the data is stored.</param>
24+
/// <param name="purviewSettings">The purview integration settings.</param>
25+
public CacheProvider(IDistributedCache cache, PurviewSettings purviewSettings)
26+
{
27+
this._cache = cache;
28+
this._purviewSettings = purviewSettings;
29+
}
30+
31+
/// <summary>
32+
/// Get a value from the cache.
33+
/// </summary>
34+
/// <typeparam name="TKey">The type of the key in the cache. Used for serialization.</typeparam>
35+
/// <typeparam name="TValue">The type of the value in the cache. Used for serialization.</typeparam>
36+
/// <param name="key">The key to look up in the cache.</param>
37+
/// <param name="cancellationToken">A cancellation token for the async operation.</param>
38+
/// <returns>The value in the cache. Null or default if no value is present.</returns>
39+
public async Task<TValue?> GetAsync<TKey, TValue>(TKey key, CancellationToken cancellationToken)
40+
{
41+
JsonTypeInfo<TKey> keyTypeInfo = (JsonTypeInfo<TKey>)PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(TKey));
42+
string serializedKey = JsonSerializer.Serialize(key, keyTypeInfo);
43+
byte[]? data = await this._cache.GetAsync(serializedKey, cancellationToken).ConfigureAwait(false);
44+
if (data == null)
45+
{
46+
return default;
47+
}
48+
49+
JsonTypeInfo<TValue> valueTypeInfo = (JsonTypeInfo<TValue>)PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(TValue));
50+
51+
return JsonSerializer.Deserialize(data, valueTypeInfo);
52+
}
53+
54+
/// <summary>
55+
/// Set a value in the cache.
56+
/// </summary>
57+
/// <typeparam name="TKey">The type of the key in the cache. Used for serialization.</typeparam>
58+
/// <typeparam name="TValue">The type of the value in the cache. Used for serialization.</typeparam>
59+
/// <param name="key">The key to identify the cache entry.</param>
60+
/// <param name="value">The value to cache.</param>
61+
/// <param name="cancellationToken">A cancellation token for the async operation.</param>
62+
/// <returns>A task for the async operation.</returns>
63+
public Task SetAsync<TKey, TValue>(TKey key, TValue value, CancellationToken cancellationToken)
64+
{
65+
JsonTypeInfo<TKey> keyTypeInfo = (JsonTypeInfo<TKey>)PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(TKey));
66+
string serializedKey = JsonSerializer.Serialize(key, keyTypeInfo);
67+
JsonTypeInfo<TValue> valueTypeInfo = (JsonTypeInfo<TValue>)PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(TValue));
68+
byte[] serializedValue = JsonSerializer.SerializeToUtf8Bytes(value, valueTypeInfo);
69+
70+
DistributedCacheEntryOptions cacheOptions = new() { AbsoluteExpirationRelativeToNow = this._purviewSettings.CacheTTL };
71+
72+
return this._cache.SetAsync(serializedKey, serializedValue, cacheOptions, cancellationToken);
73+
}
74+
75+
/// <summary>
76+
/// Removes a value from the cache.
77+
/// </summary>
78+
/// <typeparam name="TKey">The type of the key.</typeparam>
79+
/// <param name="key">The key to identify the cache entry.</param>
80+
/// <param name="cancellationToken">The cancellation token for the async operation.</param>
81+
/// <returns>A task for the async operation.</returns>
82+
public Task RemoveAsync<TKey>(TKey key, CancellationToken cancellationToken)
83+
{
84+
JsonTypeInfo<TKey> keyTypeInfo = (JsonTypeInfo<TKey>)PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(TKey));
85+
string serializedKey = JsonSerializer.Serialize(key, keyTypeInfo);
86+
87+
return this._cache.RemoveAsync(serializedKey, cancellationToken);
88+
}
89+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
using System;
4+
using System.Collections.Generic;
5+
using System.Threading.Channels;
6+
using System.Threading.Tasks;
7+
using Microsoft.Agents.AI.Purview.Models.Jobs;
8+
using Microsoft.Extensions.Logging;
9+
10+
namespace Microsoft.Agents.AI.Purview;
11+
12+
/// <summary>
13+
/// Handler class for background job management.
14+
/// </summary>
15+
internal class ChannelHandler : IChannelHandler
16+
{
17+
private readonly Channel<BackgroundJobBase> _jobChannel;
18+
private readonly List<Task> _channelListeners;
19+
private readonly ILogger _logger;
20+
private readonly PurviewSettings _purviewSettings;
21+
22+
/// <summary>
23+
/// Creates a new instance of JobHandler.
24+
/// </summary>
25+
/// <param name="purviewSettings">The purview integration settings.</param>
26+
/// <param name="logger">The logger used for logging job information.</param>
27+
/// <param name="jobChannel">The job channel used for queuing and reading background jobs.</param>
28+
public ChannelHandler(PurviewSettings purviewSettings, ILogger logger, Channel<BackgroundJobBase> jobChannel)
29+
{
30+
this._purviewSettings = purviewSettings;
31+
this._logger = logger;
32+
this._jobChannel = jobChannel;
33+
34+
this._channelListeners = new List<Task>(this._purviewSettings.MaxConcurrentJobConsumers);
35+
}
36+
37+
/// <inheritdoc/>
38+
public void QueueJob(BackgroundJobBase job)
39+
{
40+
try
41+
{
42+
if (job == null)
43+
{
44+
throw new PurviewJobException("Cannot queue null job.");
45+
}
46+
47+
if (this._channelListeners.Count == 0)
48+
{
49+
this._logger.LogWarning("No listeners are available to process the job.");
50+
throw new PurviewJobException("No listeners are available to process the job.");
51+
}
52+
53+
bool canQueue = this._jobChannel.Writer.TryWrite(job);
54+
55+
if (!canQueue)
56+
{
57+
int jobCount = this._jobChannel.Reader.Count;
58+
this._logger.LogError("Could not queue a job for background processing.");
59+
60+
if (this._jobChannel.Reader.Completion.IsCompleted)
61+
{
62+
throw new PurviewJobException("Job channel is closed or completed. Cannot queue job.");
63+
}
64+
else if (jobCount >= this._purviewSettings.PendingBackgroundJobLimit)
65+
{
66+
throw new PurviewJobLimitExceededException($"Job queue is full. Current pending jobs: {jobCount}. Maximum number of queued jobs: {this._purviewSettings.PendingBackgroundJobLimit}");
67+
}
68+
else
69+
{
70+
throw new PurviewJobException("Could not queue job for background processing.");
71+
}
72+
}
73+
}
74+
catch (Exception e)
75+
{
76+
if (this._purviewSettings.IgnoreExceptions)
77+
{
78+
this._logger.LogError(e, "Error queuing job: {ExceptionMessage}", e.Message);
79+
}
80+
else
81+
{
82+
throw;
83+
}
84+
}
85+
}
86+
87+
/// <inheritdoc/>
88+
public void AddRunner(Func<Channel<BackgroundJobBase>, Task> runnerTask)
89+
{
90+
this._channelListeners.Add(Task.Run(async () => await runnerTask(this._jobChannel).ConfigureAwait(false)));
91+
}
92+
93+
/// <inheritdoc/>
94+
public async Task StopAndWaitForCompletionAsync()
95+
{
96+
this._jobChannel.Writer.Complete();
97+
await this._jobChannel.Reader.Completion.ConfigureAwait(false);
98+
await Task.WhenAll(this._channelListeners).ConfigureAwait(false);
99+
}
100+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
namespace Microsoft.Agents.AI.Purview;
4+
5+
/// <summary>
6+
/// Shared constants for the Purview service.
7+
/// </summary>
8+
internal static class Constants
9+
{
10+
/// <summary>
11+
/// The odata type property name used in requests and responses.
12+
/// </summary>
13+
public const string ODataTypePropertyName = "@odata.type";
14+
15+
/// <summary>
16+
/// The OData Graph namespace used for odata types.
17+
/// </summary>
18+
public const string ODataGraphNamespace = "microsoft.graph";
19+
20+
/// <summary>
21+
/// The name of the property that contains the conversation id.
22+
/// </summary>
23+
public const string ConversationId = "conversationId";
24+
25+
/// <summary>
26+
/// The name of the property that contains the user id.
27+
/// </summary>
28+
public const string UserId = "userId";
29+
}

0 commit comments

Comments
 (0)