mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
setup for azure blob as agentthreadstore
This commit is contained in:
@@ -145,5 +145,6 @@
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageVersion Include="Xunit.SkippableFact" Version="1.5.23" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -279,6 +279,7 @@
|
||||
<Project Path="tests/AgentConformance.IntegrationTests/AgentConformance.IntegrationTests.csproj" />
|
||||
<Project Path="tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistent.IntegrationTests.csproj" />
|
||||
<Project Path="tests/CopilotStudio.IntegrationTests/CopilotStudio.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Hosting.AgentThreadStore.IntegrationTests/Hosting.AgentThreadStore.IntegrationTests.csproj" Id="1ae09573-2a69-4bf0-98a7-90616b74c2cc" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Mem0.IntegrationTests/Microsoft.Agents.AI.Mem0.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.csproj" />
|
||||
<Project Path="tests/OpenAIAssistant.IntegrationTests/OpenAIAssistant.IntegrationTests.csproj" />
|
||||
|
||||
+4
-5
@@ -56,10 +56,8 @@ internal sealed class AzureBlobAgentThreadStore : AgentThreadStore
|
||||
var blobClient = this._containerClient.GetBlobClient(blobName);
|
||||
|
||||
JsonElement serializedThread = thread.Serialize();
|
||||
#pragma warning disable CA2007 // Consider calling ConfigureAwait on the awaited task
|
||||
await using Stream stream = await blobClient.OpenWriteAsync(overwrite: true, s_uploadJsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
await using Utf8JsonWriter writer = new(stream);
|
||||
#pragma warning restore CA2007 // Consider calling ConfigureAwait on the awaited task
|
||||
using Stream stream = await blobClient.OpenWriteAsync(overwrite: true, s_uploadJsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
using Utf8JsonWriter writer = new(stream);
|
||||
|
||||
serializedThread.WriteTo(writer);
|
||||
await writer.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||
@@ -118,7 +116,8 @@ internal sealed class AzureBlobAgentThreadStore : AgentThreadStore
|
||||
/// <summary>
|
||||
/// Generates the blob name for a given agent and conversation.
|
||||
/// </summary>
|
||||
private string GetBlobName(string agentId, string conversationId)
|
||||
// internal for testing
|
||||
internal string GetBlobName(string agentId, string conversationId)
|
||||
{
|
||||
string sanitizedAgentId = this.SanitizeBlobNameSegment(agentId);
|
||||
string sanitizedConversationId = this.SanitizeBlobNameSegment(conversationId);
|
||||
|
||||
-15
@@ -3,7 +3,6 @@
|
||||
using System;
|
||||
using Azure.Storage.Blobs;
|
||||
using Microsoft.Agents.AI.Hosting.AzureStorage.Blob;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting;
|
||||
|
||||
@@ -12,20 +11,6 @@ namespace Microsoft.Agents.AI.Hosting;
|
||||
/// </summary>
|
||||
public static partial class HostedAgentBuilderExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configures the host agent builder to use an Azure Blob thread store with dependency injection.
|
||||
/// Resolves <see cref="BlobContainerClient"/> from the service provider.
|
||||
/// </summary>
|
||||
/// <param name="builder">The host agent builder to configure with the Azure blob thread store.</param>
|
||||
/// <param name="options">Optional configuration options for the blob thread store.</param>
|
||||
/// <returns>The same <paramref name="builder"/> instance, configured to use Azure blob thread store.</returns>
|
||||
/// <remarks>
|
||||
/// This overload requires a <see cref="BlobContainerClient"/> to be registered in the service collection.
|
||||
/// Use Azure.Extensions.AspNetCore.Configuration.Secrets or similar to register the client.
|
||||
/// </remarks>
|
||||
public static IHostedAgentBuilder WithAzureBlobThreadStore(this IHostedAgentBuilder builder, AzureBlobAgentThreadStoreOptions? options = null)
|
||||
=> WithAzureBlobThreadStore(builder, sp => sp.GetRequiredKeyedService<BlobContainerClient>(builder.Name), options);
|
||||
|
||||
/// <summary>
|
||||
/// Configures the host agent builder to use an Azure Blob thread store for agent thread management.
|
||||
/// </summary>
|
||||
|
||||
+6
-2
@@ -1,8 +1,8 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>$(ProjectsTargetFrameworks)</TargetFrameworks>
|
||||
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">net9.0</TargetFrameworks>
|
||||
<TargetFrameworks>$(ProjectsCoreTargetFrameworks)</TargetFrameworks>
|
||||
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsDebugCoreTargetFrameworks)</TargetFrameworks>
|
||||
<VersionSuffix>preview</VersionSuffix>
|
||||
<InjectSharedThrow>true</InjectSharedThrow>
|
||||
|
||||
@@ -20,4 +20,8 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI.Hosting\Microsoft.Agents.AI.Hosting.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Hosting.AgentThreadStore.IntegrationTests" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.Storage.Blobs;
|
||||
using Microsoft.Agents.AI.Hosting.AzureStorage.Blob;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace Hosting.AgentThreadStore.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="AzureBlobAgentThreadStore"/>.
|
||||
/// </summary>
|
||||
public sealed class AzureBlobAgentThreadStoreTests(ITestOutputHelper output) : IAsyncLifetime
|
||||
{
|
||||
private const string AzuriteConnectionString = "UseDevelopmentStorage=true";
|
||||
private const string TestContainerName = "agent-threads-test";
|
||||
|
||||
#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.
|
||||
private BlobServiceClient _blobServiceClient;
|
||||
private BlobContainerClient _containerClient;
|
||||
#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
await AzureStorageEmulatorAvailabilityHelper.SkipIfNotAvailableAsync();
|
||||
|
||||
this._blobServiceClient = new BlobServiceClient(AzuriteConnectionString);
|
||||
this._containerClient = this._blobServiceClient.GetBlobContainerClient(TestContainerName);
|
||||
|
||||
// Clean up any existing test container
|
||||
await this._containerClient.DeleteIfExistsAsync();
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
if (this._containerClient is not null)
|
||||
{
|
||||
await this._containerClient.DeleteIfExistsAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task AIHostAgent_SavesAndRetrievesThread_UsingAzureBlobStoreAsync()
|
||||
{
|
||||
AzureBlobAgentThreadStore threadStore = new(this._containerClient!);
|
||||
var testRunner = TestRunner.Initialize(output, threadStore);
|
||||
var blobName = GetBlobContainerName(threadStore, testRunner);
|
||||
|
||||
var runResult = await testRunner.RunAgentAsync("hello agent");
|
||||
Assert.Single(runResult.ResponseMessages);
|
||||
Assert.Equal(2, runResult.ThreadMessages.Count);
|
||||
await this.AssertBlobHasTextAsync(blobName, runResult.ThreadMessages);
|
||||
|
||||
var runResult2 = await testRunner.RunAgentAsync("hello again");
|
||||
Assert.Single(runResult2.ResponseMessages);
|
||||
Assert.Equal(4, runResult2.ThreadMessages.Count);
|
||||
await this.AssertBlobHasTextAsync(blobName, runResult2.ThreadMessages);
|
||||
}
|
||||
|
||||
private Task AssertBlobHasTextAsync(string blobName, IList<ChatMessage> chatMessages)
|
||||
{
|
||||
var texts = chatMessages.SelectMany(x => x.Contents).OfType<TextContent>().Select(x => x.Text).ToArray();
|
||||
return this.AssertBlobHasTextAsync(blobName, texts);
|
||||
}
|
||||
|
||||
private async Task AssertBlobHasTextAsync(string blobName, params string[] expectedTexts)
|
||||
{
|
||||
var blobClient = this._containerClient.GetBlobClient(blobName);
|
||||
var exists = await blobClient.ExistsAsync();
|
||||
Assert.True(exists, $"Blob '{blobName}' should exist.");
|
||||
|
||||
var downloadResponse = await blobClient.DownloadContentAsync();
|
||||
var blobJson = downloadResponse.Value.Content.ToString();
|
||||
output.WriteLine($"Actual blob json: {blobJson}");
|
||||
|
||||
foreach (var text in expectedTexts)
|
||||
{
|
||||
Assert.Contains(text, blobJson);
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetBlobContainerName(AzureBlobAgentThreadStore threadStore, TestRunner testRunner)
|
||||
=> threadStore.GetBlobName(testRunner.HostAgent.Id, testRunner.ConversationId);
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure;
|
||||
using Azure.Storage.Blobs;
|
||||
using Skip = Xunit.Skip;
|
||||
|
||||
namespace Hosting.AgentThreadStore.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Helper class to check if Azurite (Azure Storage Emulator) is available and running.
|
||||
/// </summary>
|
||||
internal static class AzureStorageEmulatorAvailabilityHelper
|
||||
{
|
||||
private const string AzuriteConnectionString = "UseDevelopmentStorage=true";
|
||||
|
||||
/// <summary>
|
||||
/// Checks if Azurite is running and accessible.
|
||||
/// </summary>
|
||||
/// <returns><see langword="true"/> if Azurite is available; otherwise, <see langword="false"/>.</returns>
|
||||
public static async Task<bool> IsAvailableAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
BlobServiceClient serviceClient = new(AzuriteConnectionString);
|
||||
|
||||
// Try to get service properties to verify connection
|
||||
await serviceClient.GetPropertiesAsync(cancellationToken);
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (RequestFailedException)
|
||||
{
|
||||
// Azurite is not running or not accessible
|
||||
return false;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Any other exception means Azurite is not available
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task SkipIfNotAvailableAsync()
|
||||
{
|
||||
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
|
||||
bool isAvailable = await IsAvailableAsync(cts.Token);
|
||||
Skip.IfNot(isAvailable, "Azurite / Azure Storage Emulator is not running. Start Azurite to run these tests.");
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>$(ProjectsCoreTargetFrameworks)</TargetFrameworks>
|
||||
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsDebugCoreTargetFrameworks)</TargetFrameworks>
|
||||
<InjectSharedIntegrationTestCode>True</InjectSharedIntegrationTestCode>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Xunit.SkippableFact" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Hosting.AzureStorage\Microsoft.Agents.AI.Hosting.AzureStorage.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,64 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Hosting.AgentThreadStore.IntegrationTests.Mock;
|
||||
|
||||
internal sealed class MockChatClient : IChatClient
|
||||
{
|
||||
private int _responsesCounter = 1;
|
||||
|
||||
public ChatClientMetadata? Metadata { get; }
|
||||
|
||||
public async Task<ChatResponse> GetResponseAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
ChatOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await Task.Yield();
|
||||
|
||||
List<ChatMessage> messageList = messages.ToList();
|
||||
ChatMessage lastUserMessage = messageList.LastOrDefault(m => m.Role == ChatRole.User)
|
||||
?? new ChatMessage(ChatRole.User, "No user message");
|
||||
|
||||
ChatMessage responseMessage = new(ChatRole.Assistant, $"Response #{this._responsesCounter++}");
|
||||
|
||||
return new ChatResponse([responseMessage]);
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
ChatOptions? options = null,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
await Task.Yield();
|
||||
|
||||
List<ChatMessage> messageList = messages.ToList();
|
||||
ChatMessage lastUserMessage = messageList.LastOrDefault(m => m.Role == ChatRole.User)
|
||||
?? new ChatMessage(ChatRole.User, "No user message");
|
||||
|
||||
string responseText = $"Mock response to: {lastUserMessage.Text}";
|
||||
|
||||
yield return new ChatResponseUpdate(ChatRole.Assistant, responseText);
|
||||
}
|
||||
|
||||
public object? GetService(Type serviceType, object? serviceKey = null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public TService? GetService<TService>(object? serviceKey = null)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Hosting.AgentThreadStore.IntegrationTests.Mock;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Hosting;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.VisualStudio.TestPlatform.Utilities;
|
||||
using Xunit.Abstractions;
|
||||
using Xunit.Sdk;
|
||||
using ThreadStore = Microsoft.Agents.AI.Hosting.AgentThreadStore;
|
||||
|
||||
namespace Hosting.AgentThreadStore.IntegrationTests;
|
||||
|
||||
internal sealed class TestRunner
|
||||
{
|
||||
private int _requestCounter = 1;
|
||||
|
||||
private readonly ITestOutputHelper _testOutputHelper;
|
||||
public AIHostAgent HostAgent { get; }
|
||||
public string ConversationId { get; }
|
||||
|
||||
private TestRunner(ITestOutputHelper testOutputHelper, AIHostAgent hostAgent, string conversationId)
|
||||
{
|
||||
this._testOutputHelper = testOutputHelper;
|
||||
this.HostAgent = hostAgent;
|
||||
this.ConversationId = conversationId;
|
||||
}
|
||||
|
||||
public static TestRunner Initialize(
|
||||
ITestOutputHelper testOutputHelper,
|
||||
ThreadStore threadStore,
|
||||
IChatClient? chatClient = null)
|
||||
{
|
||||
chatClient ??= new MockChatClient();
|
||||
|
||||
var chatClientAgent = new ChatClientAgent(chatClient);
|
||||
var hostAgent = new AIHostAgent(chatClientAgent, threadStore);
|
||||
|
||||
var conversationId = NewConversationId();
|
||||
|
||||
return new(testOutputHelper, hostAgent, conversationId);
|
||||
}
|
||||
|
||||
public Task<HostAgentRunResult> RunAgentAsync(string userMessage)
|
||||
=> this.RunAgentAsync(new ChatMessage(ChatRole.User, userMessage));
|
||||
|
||||
public async Task<HostAgentRunResult> RunAgentAsync(ChatMessage userMessage)
|
||||
{
|
||||
if (userMessage.Contents.FirstOrDefault() is TextContent text)
|
||||
{
|
||||
text.Text = $"Request #{this._requestCounter++}: {text.Text}";
|
||||
}
|
||||
|
||||
AgentThread thread = await this.HostAgent.GetOrCreateThreadAsync(this.ConversationId);
|
||||
var response = await this.HostAgent.RunAsync(thread: thread, messages: [userMessage]);
|
||||
|
||||
await this.HostAgent.SaveThreadAsync(this.ConversationId, thread);
|
||||
this._testOutputHelper.WriteLine($"Saved thread {this.ConversationId}");
|
||||
|
||||
var chatClientAgentThread = thread as ChatClientAgentThread;
|
||||
Assert.NotNull(chatClientAgentThread);
|
||||
Assert.NotNull(chatClientAgentThread.MessageStore);
|
||||
|
||||
var threadMessages = (await chatClientAgentThread.MessageStore.GetMessagesAsync()).ToList();
|
||||
|
||||
return new()
|
||||
{
|
||||
Response = response,
|
||||
Thread = chatClientAgentThread,
|
||||
ThreadMessages = threadMessages
|
||||
};
|
||||
}
|
||||
|
||||
private static string NewConversationId() => Guid.NewGuid().ToString();
|
||||
}
|
||||
|
||||
internal struct HostAgentRunResult
|
||||
{
|
||||
public AgentRunResponse Response { get; init; }
|
||||
public IList<ChatMessage> ResponseMessages => this.Response.Messages;
|
||||
|
||||
public ChatClientAgentThread Thread { get; init; }
|
||||
public IList<ChatMessage> ThreadMessages { get; init; }
|
||||
}
|
||||
Reference in New Issue
Block a user