setup for azure blob as agentthreadstore

This commit is contained in:
Korolev Dmitry
2025-11-04 13:29:56 +01:00
Unverified
parent 6c969418ac
commit 4de5f1b421
10 changed files with 320 additions and 22 deletions
+1
View File
@@ -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>
+1
View File
@@ -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" />
@@ -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);
@@ -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>
@@ -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>
@@ -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);
}
@@ -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.");
}
}
@@ -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; }
}