.NET: Add Azure Blob Storage session persistence (#1893)
* setup azurestorage proj * setup for azure blob as agentthreadstore * add azurite as dependency for dotnet tests * use services * rollback * azurite as a step * move and rename * renames / fixes * rename to unit tests * copilot changes * .NET: Modernize Azure Blob session storage Copilot-Session: 35e63850-1a85-4f7c-ac80-2274534c13b5 * .NET: Test hosted Blob session persistence Copilot-Session: 35e63850-1a85-4f7c-ac80-2274534c13b5 * .NET: Address Azure Blob storage review feedback Copilot-Session: 35e63850-1a85-4f7c-ac80-2274534c13b5 * .NET: Use default test target frameworks Copilot-Session: 35e63850-1a85-4f7c-ac80-2274534c13b5 --------- Co-authored-by: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Copilot-Session: 35e63850-1a85-4f7c-ac80-2274534c13b5
This commit is contained in:
+1
-1
@@ -113,6 +113,7 @@
|
||||
/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/ @chetantoshniwal @rogerbarreto @SergeyMenshykh @westey-m
|
||||
/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/ @chetantoshniwal @rogerbarreto @SergeyMenshykh @westey-m
|
||||
/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/ @chetantoshniwal @rogerbarreto @SergeyMenshykh @westey-m
|
||||
/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/ @chetantoshniwal @rogerbarreto @SergeyMenshykh @westey-m
|
||||
/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ @chetantoshniwal @rogerbarreto @SergeyMenshykh @westey-m
|
||||
/dotnet/src/Microsoft.Agents.AI.Hyperlight/ @chetantoshniwal @westey-m @SergeyMenshykh
|
||||
/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/ @chetantoshniwal @westey-m @SergeyMenshykh
|
||||
@@ -127,4 +128,3 @@
|
||||
/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Foundry/ @chetantoshniwal @peibekwe @rogerbarreto
|
||||
/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/ @chetantoshniwal @peibekwe @rogerbarreto
|
||||
/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/ @chetantoshniwal @peibekwe @rogerbarreto
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ jobs:
|
||||
outputs:
|
||||
dotnetChanges: ${{ steps.filter.outputs.dotnet }}
|
||||
cosmosDbChanges: ${{ steps.filter.outputs.cosmosdb }}
|
||||
azureStorageChanges: ${{ steps.filter.outputs.azurestorage }}
|
||||
foundryHostingChanges: ${{ steps.filter.outputs.foundryHosting }}
|
||||
coreChanged: ${{ steps.filter.outputs.core }}
|
||||
steps:
|
||||
@@ -49,6 +50,12 @@ jobs:
|
||||
- 'dotnet/**'
|
||||
cosmosdb:
|
||||
- 'dotnet/src/Microsoft.Agents.AI.CosmosNoSql/**'
|
||||
azurestorage:
|
||||
- 'dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/**'
|
||||
- 'dotnet/tests/Microsoft.Agents.AI.Hosting.AzureStorage.IntegrationTests/**'
|
||||
- 'dotnet/tests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests/**'
|
||||
- 'dotnet/Directory.Packages.props'
|
||||
- '.github/workflows/dotnet-build-and-test.yml'
|
||||
# The Foundry hosted-agent IT is costly (builds a container, pushes to ACR,
|
||||
# provisions live agents). Only run it when the project under test, its
|
||||
# dependency chain, the test container, the test fixture, or their tooling
|
||||
@@ -120,6 +127,7 @@ jobs:
|
||||
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
|
||||
with:
|
||||
global-json-file: ${{ github.workspace }}/dotnet/global.json
|
||||
|
||||
- name: Build dotnet solutions
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -200,6 +208,27 @@ jobs:
|
||||
Start-CosmosDbEmulator -NoUI -Key "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw=="
|
||||
echo "COSMOSDB_EMULATOR_AVAILABLE=true" >> $env:GITHUB_ENV
|
||||
|
||||
- name: Start Azurite Blob service
|
||||
if: ${{ runner.os == 'Linux' && (needs.paths-filter.outputs.azureStorageChanges == 'true' || (github.event_name != 'pull_request' && matrix.integration-tests)) }}
|
||||
shell: bash
|
||||
run: |
|
||||
docker run --detach --rm \
|
||||
--name azurite \
|
||||
--publish 10000:10000 \
|
||||
mcr.microsoft.com/azure-storage/azurite:3.35.0@sha256:647c63a91102a9d8e8000aab803436e1fc85fbb285e7ce830a82ee5d6661cf37 \
|
||||
azurite-blob --blobHost 0.0.0.0 --blobPort 10000 --skipApiVersionCheck
|
||||
|
||||
for attempt in {1..30}; do
|
||||
if (echo > /dev/tcp/127.0.0.1/10000) > /dev/null 2>&1; then
|
||||
echo "AZURITE_AVAILABLE=true" >> "$GITHUB_ENV"
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
docker logs azurite
|
||||
exit 1
|
||||
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
|
||||
with:
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
<PackageVersion Include="Azure.AI.OpenAI" Version="2.9.0-beta.1" />
|
||||
<PackageVersion Include="Azure.Core" Version="1.60.0" />
|
||||
<PackageVersion Include="Azure.Identity" Version="1.21.0" />
|
||||
<PackageVersion Include="Azure.Storage.Blobs" Version="12.29.1" />
|
||||
<PackageVersion Include="DotNetEnv" Version="3.1.1" />
|
||||
<PackageVersion Include="Azure.Monitor.OpenTelemetry.Exporter" Version="1.5.0" />
|
||||
<!-- Google Gemini -->
|
||||
|
||||
@@ -605,6 +605,7 @@
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting.A2A/Microsoft.Agents.AI.Hosting.A2A.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting.AspNetCore/Microsoft.Agents.AI.Hosting.AspNetCore.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting.AzureStorage/Microsoft.Agents.AI.Hosting.AzureStorage.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting.OpenAI/Microsoft.Agents.AI.Hosting.OpenAI.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting/Microsoft.Agents.AI.Hosting.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hyperlight/Microsoft.Agents.AI.Hyperlight.csproj" />
|
||||
@@ -636,6 +637,7 @@
|
||||
<Project Path="tests/Foundry.IntegrationTests/Foundry.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.GitHub.Copilot.IntegrationTests/Microsoft.Agents.AI.GitHub.Copilot.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.AzureStorage.IntegrationTests/Microsoft.Agents.AI.Hosting.AzureStorage.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hyperlight.IntegrationTests/Microsoft.Agents.AI.Hyperlight.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Mem0.IntegrationTests/Microsoft.Agents.AI.Mem0.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Tools.Shell.IntegrationTests/Microsoft.Agents.AI.Tools.Shell.IntegrationTests.csproj" />
|
||||
@@ -660,6 +662,7 @@
|
||||
<Project Path="tests/Microsoft.Agents.AI.Harness.UnitTests/Microsoft.Agents.AI.Harness.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Microsoft.Agents.AI.Hosting.A2A.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.UnitTests/Microsoft.Agents.AI.Hosting.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hyperlight.UnitTests/Microsoft.Agents.AI.Hyperlight.UnitTests.csproj" />
|
||||
@@ -677,4 +680,3 @@
|
||||
<Project Path="tests/Microsoft.Agents.AI.Workflows.UnitTests/Microsoft.Agents.AI.Workflows.UnitTests.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"src\\Microsoft.Agents.AI.Hosting.A2A\\Microsoft.Agents.AI.Hosting.A2A.csproj",
|
||||
"src\\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj",
|
||||
"src\\Microsoft.Agents.AI.Hosting.AspNetCore\\Microsoft.Agents.AI.Hosting.AspNetCore.csproj",
|
||||
"src\\Microsoft.Agents.AI.Hosting.AzureStorage\\Microsoft.Agents.AI.Hosting.AzureStorage.csproj",
|
||||
"src\\Microsoft.Agents.AI.Hosting.OpenAI\\Microsoft.Agents.AI.Hosting.OpenAI.csproj",
|
||||
"src\\Microsoft.Agents.AI.Hosting\\Microsoft.Agents.AI.Hosting.csproj",
|
||||
"src\\Microsoft.Agents.AI.LocalCodeAct\\Microsoft.Agents.AI.LocalCodeAct.csproj",
|
||||
|
||||
+1
@@ -12,6 +12,7 @@
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hosting\Microsoft.Agents.AI.Hosting.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hosting.AzureStorage\Microsoft.Agents.AI.Hosting.AzureStorage.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hosting.A2A.AspNetCore\Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hosting.OpenAI\Microsoft.Agents.AI.Hosting.OpenAI.csproj" />
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
using AgentWebChat.AgentHost;
|
||||
using AgentWebChat.AgentHost.Custom;
|
||||
using AgentWebChat.AgentHost.Utilities;
|
||||
using Azure.Storage.Blobs;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.DevUI;
|
||||
using Microsoft.Agents.AI.Hosting;
|
||||
@@ -43,8 +44,26 @@ var pirateAgentBuilder = builder.AddAIAgent(
|
||||
description: "An agent that speaks like a pirate.",
|
||||
chatClientServiceKey: "chat-model")
|
||||
.WithAITool(new CustomAITool())
|
||||
.WithAITool(new CustomFunctionTool())
|
||||
.WithInMemorySessionStore();
|
||||
.WithAITool(new CustomFunctionTool());
|
||||
|
||||
// Set both environment variables to replace development-only in-memory storage with Azure Blob Storage.
|
||||
string? blobConnectionString = Environment.GetEnvironmentVariable("AZURE_STORAGE_BLOB_CONNECTION_STRING");
|
||||
if (string.IsNullOrWhiteSpace(blobConnectionString))
|
||||
{
|
||||
pirateAgentBuilder.WithInMemorySessionStore();
|
||||
}
|
||||
else
|
||||
{
|
||||
string? blobContainerName = Environment.GetEnvironmentVariable("AZURE_STORAGE_BLOB_CONTAINER_NAME");
|
||||
if (string.IsNullOrWhiteSpace(blobContainerName))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"AZURE_STORAGE_BLOB_CONTAINER_NAME must be set when AZURE_STORAGE_BLOB_CONNECTION_STRING is configured.");
|
||||
}
|
||||
|
||||
BlobContainerClient containerClient = new(blobConnectionString, blobContainerName);
|
||||
pirateAgentBuilder.WithAzureBlobSessionStore(containerClient);
|
||||
}
|
||||
|
||||
var knightsKnavesAgentBuilder = builder.AddAIAgent("knights-and-knaves", (sp, key) =>
|
||||
{
|
||||
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using Azure.Storage.Blobs;
|
||||
using Microsoft.Agents.AI.Hosting.AzureStorage;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Provides hosted agent registration extensions for Azure Blob Storage session persistence.
|
||||
/// </summary>
|
||||
public static class AzureBlobHostedAgentBuilderExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configures a hosted agent to persist sessions in an Azure Blob Storage container.
|
||||
/// </summary>
|
||||
/// <param name="builder">The hosted agent builder to configure.</param>
|
||||
/// <param name="containerClient">The Blob container client used to store sessions.</param>
|
||||
/// <param name="options">Optional session store configuration.</param>
|
||||
/// <param name="withIsolation">
|
||||
/// Whether to scope session IDs with the configured <see cref="AgentIsolationKeyProvider"/>.
|
||||
/// </param>
|
||||
/// <returns>The supplied <paramref name="builder"/>.</returns>
|
||||
public static IHostedAgentBuilder WithAzureBlobSessionStore(
|
||||
this IHostedAgentBuilder builder,
|
||||
BlobContainerClient containerClient,
|
||||
AzureBlobAgentSessionStoreOptions? options = null,
|
||||
bool withIsolation = true)
|
||||
{
|
||||
Throw.IfNull(builder);
|
||||
Throw.IfNull(containerClient);
|
||||
|
||||
return builder.WithAzureBlobSessionStore(
|
||||
(_, _) => containerClient,
|
||||
options,
|
||||
ServiceLifetime.Singleton,
|
||||
withIsolation);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configures a hosted agent to persist sessions in an Azure Blob Storage container supplied by a factory.
|
||||
/// </summary>
|
||||
/// <param name="builder">The hosted agent builder to configure.</param>
|
||||
/// <param name="createBlobContainerClient">
|
||||
/// A factory that receives the service provider and stable hosted agent registration name.
|
||||
/// </param>
|
||||
/// <param name="options">Optional session store configuration.</param>
|
||||
/// <param name="lifetime">The dependency injection lifetime of the registered session store.</param>
|
||||
/// <param name="withIsolation">
|
||||
/// Whether to scope session IDs with the configured <see cref="AgentIsolationKeyProvider"/>.
|
||||
/// </param>
|
||||
/// <returns>The supplied <paramref name="builder"/>.</returns>
|
||||
public static IHostedAgentBuilder WithAzureBlobSessionStore(
|
||||
this IHostedAgentBuilder builder,
|
||||
Func<IServiceProvider, string, BlobContainerClient> createBlobContainerClient,
|
||||
AzureBlobAgentSessionStoreOptions? options = null,
|
||||
ServiceLifetime lifetime = ServiceLifetime.Singleton,
|
||||
bool withIsolation = true)
|
||||
{
|
||||
Throw.IfNull(builder);
|
||||
Throw.IfNull(createBlobContainerClient);
|
||||
|
||||
return builder.WithSessionStore(
|
||||
(serviceProvider, agentName) =>
|
||||
{
|
||||
BlobContainerClient containerClient =
|
||||
Throw.IfNull(createBlobContainerClient(serviceProvider, agentName));
|
||||
return new AzureBlobAgentSessionStore(containerClient, agentName, options);
|
||||
},
|
||||
lifetime,
|
||||
withIsolation);
|
||||
}
|
||||
}
|
||||
+237
@@ -0,0 +1,237 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure;
|
||||
using Azure.Storage.Blobs;
|
||||
using Azure.Storage.Blobs.Models;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.AzureStorage;
|
||||
|
||||
/// <summary>
|
||||
/// Provides an Azure Blob Storage implementation of <see cref="AgentSessionStore"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The <c>agentNamespace</c> supplied to the constructor forms a stable storage partition and must remain the same
|
||||
/// across application restarts. Hosted agent registration extensions supply the hosted agent registration name.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Serialized sessions can contain conversation content and personally identifiable information. Configure the
|
||||
/// container with appropriate access controls, encryption, retention, and deletion policies. Hosts serving multiple
|
||||
/// users should register this store through <c>WithAzureBlobSessionStore</c>, which enables isolation-key scoping by
|
||||
/// default.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class AzureBlobAgentSessionStore : AgentSessionStore
|
||||
{
|
||||
private const int MaxBlobNameLength = 1024;
|
||||
private const int BaseBlobNameLength = 137;
|
||||
|
||||
private static readonly BlobUploadOptions s_uploadOptions = new()
|
||||
{
|
||||
HttpHeaders = new BlobHttpHeaders { ContentType = "application/json" },
|
||||
};
|
||||
|
||||
private readonly BlobContainerClient _containerClient;
|
||||
private readonly object _containerInitializationLock = new();
|
||||
private readonly string _agentKey;
|
||||
private readonly string? _blobNamePrefix;
|
||||
private readonly bool _createContainerIfNotExists;
|
||||
private Task? _containerInitializationTask;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AzureBlobAgentSessionStore"/> class.
|
||||
/// </summary>
|
||||
/// <param name="containerClient">The blob container client to use for storage operations.</param>
|
||||
/// <param name="agentNamespace">A stable name that identifies the agent across application restarts.</param>
|
||||
/// <param name="options">Optional configuration options. If <see langword="null"/>, default options will be used.</param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="containerClient"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="ArgumentException"><paramref name="agentNamespace"/> is empty or consists only of whitespace.</exception>
|
||||
/// <exception cref="ArgumentException"><paramref name="options"/> specifies a Blob name prefix that exceeds the Azure Blob name limit.</exception>
|
||||
public AzureBlobAgentSessionStore(
|
||||
BlobContainerClient containerClient,
|
||||
string agentNamespace,
|
||||
AzureBlobAgentSessionStoreOptions? options = null)
|
||||
{
|
||||
this._containerClient = Throw.IfNull(containerClient);
|
||||
this._agentKey = ComputeKey(Throw.IfNullOrWhitespace(agentNamespace));
|
||||
|
||||
options ??= new AzureBlobAgentSessionStoreOptions();
|
||||
this._createContainerIfNotExists = options.CreateContainerIfNotExists;
|
||||
this._blobNamePrefix = NormalizePrefix(options.BlobNamePrefix);
|
||||
|
||||
if (this._blobNamePrefix is { Length: > MaxBlobNameLength - BaseBlobNameLength - 1 })
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"The Blob name prefix must not exceed {MaxBlobNameLength - BaseBlobNameLength - 1} characters.",
|
||||
nameof(options));
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask SaveSessionAsync(
|
||||
AIAgent agent,
|
||||
string sessionStoreId,
|
||||
AgentSession session,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Throw.IfNull(agent);
|
||||
Throw.IfNull(sessionStoreId);
|
||||
Throw.IfNull(session);
|
||||
|
||||
await this.EnsureContainerExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
JsonElement serializedSession = await agent.SerializeSessionAsync(session, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
BlobClient blobClient = this._containerClient.GetBlobClient(this.GetBlobName(sessionStoreId));
|
||||
await blobClient.UploadAsync(
|
||||
BinaryData.FromString(serializedSession.GetRawText()),
|
||||
s_uploadOptions,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask<AgentSession> GetSessionAsync(
|
||||
AIAgent agent,
|
||||
string sessionStoreId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Throw.IfNull(agent);
|
||||
Throw.IfNull(sessionStoreId);
|
||||
|
||||
await this.EnsureContainerExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
BlobClient blobClient = this._containerClient.GetBlobClient(this.GetBlobName(sessionStoreId));
|
||||
|
||||
try
|
||||
{
|
||||
Response<BlobDownloadResult> response = await blobClient.DownloadContentAsync(cancellationToken).ConfigureAwait(false);
|
||||
using JsonDocument document = JsonDocument.Parse(response.Value.Content);
|
||||
return await agent.DeserializeSessionAsync(document.RootElement, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.ErrorCode == BlobErrorCode.BlobNotFound.ToString())
|
||||
{
|
||||
return await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask DeleteSessionAsync(
|
||||
AIAgent agent,
|
||||
string sessionStoreId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Throw.IfNull(agent);
|
||||
Throw.IfNull(sessionStoreId);
|
||||
|
||||
BlobClient blobClient = this._containerClient.GetBlobClient(this.GetBlobName(sessionStoreId));
|
||||
|
||||
try
|
||||
{
|
||||
await blobClient.DeleteIfExistsAsync(
|
||||
DeleteSnapshotsOption.IncludeSnapshots,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.ErrorCode == BlobErrorCode.ContainerNotFound.ToString())
|
||||
{
|
||||
// A missing container cannot contain the requested session, so deletion remains idempotent.
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EnsureContainerExistsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!this._createContainerIfNotExists)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Task initializationTask;
|
||||
lock (this._containerInitializationLock)
|
||||
{
|
||||
initializationTask = this._containerInitializationTask ??= this.CreateContainerIfNotExistsAsync();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await WaitWithCancellationAsync(initializationTask, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch when (initializationTask.IsFaulted || initializationTask.IsCanceled)
|
||||
{
|
||||
lock (this._containerInitializationLock)
|
||||
{
|
||||
if (ReferenceEquals(this._containerInitializationTask, initializationTask))
|
||||
{
|
||||
this._containerInitializationTask = null;
|
||||
}
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CreateContainerIfNotExistsAsync()
|
||||
=> await this._containerClient.CreateIfNotExistsAsync(cancellationToken: CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
private string GetBlobName(string sessionStoreId)
|
||||
{
|
||||
string sessionKey = ComputeKey(sessionStoreId);
|
||||
string baseName = $"v1/{this._agentKey}/{sessionKey}.json";
|
||||
|
||||
return this._blobNamePrefix is null
|
||||
? baseName
|
||||
: $"{this._blobNamePrefix}/{baseName}";
|
||||
}
|
||||
|
||||
private static async Task WaitWithCancellationAsync(Task task, CancellationToken cancellationToken)
|
||||
{
|
||||
if (task.IsCompleted || !cancellationToken.CanBeCanceled)
|
||||
{
|
||||
await task.ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var cancellationTaskSource = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
using (cancellationToken.Register(() => cancellationTaskSource.TrySetCanceled()))
|
||||
{
|
||||
Task completedTask = await Task.WhenAny(task, cancellationTaskSource.Task).ConfigureAwait(false);
|
||||
await completedTask.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static string ComputeKey(string value)
|
||||
{
|
||||
byte[] input = Encoding.UTF8.GetBytes(value);
|
||||
#if NET8_0_OR_GREATER
|
||||
return Convert.ToHexString(SHA256.HashData(input));
|
||||
#else
|
||||
using SHA256 sha256 = SHA256.Create();
|
||||
byte[] hash = sha256.ComputeHash(input);
|
||||
char[] result = new char[hash.Length * 2];
|
||||
|
||||
for (int index = 0; index < hash.Length; index++)
|
||||
{
|
||||
byte valueByte = hash[index];
|
||||
result[index * 2] = ToHexChar(valueByte >> 4);
|
||||
result[(index * 2) + 1] = ToHexChar(valueByte & 0x0F);
|
||||
}
|
||||
|
||||
return new string(result);
|
||||
#endif
|
||||
}
|
||||
|
||||
#if !NET8_0_OR_GREATER
|
||||
private static char ToHexChar(int value)
|
||||
=> (char)(value < 10 ? '0' + value : 'A' + value - 10);
|
||||
#endif
|
||||
|
||||
private static string? NormalizePrefix(string? prefix)
|
||||
{
|
||||
string? normalized = prefix?.Trim('/');
|
||||
return string.IsNullOrEmpty(normalized) ? null : normalized;
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.AzureStorage;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for <see cref="AzureBlobAgentSessionStore"/>.
|
||||
/// </summary>
|
||||
public sealed class AzureBlobAgentSessionStoreOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to automatically create the container if it doesn't exist.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Defaults to <see langword="true"/>.
|
||||
/// Set this to <see langword="false"/> when the supplied identity has data access but cannot create containers.
|
||||
/// </remarks>
|
||||
public bool CreateContainerIfNotExists { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the blob name prefix to use for organizing sessions.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This can be used to namespace sessions within a container.
|
||||
/// For example, setting this to "prod/" will store all blobs under a "prod/" prefix.
|
||||
/// The normalized prefix cannot exceed 886 characters.
|
||||
/// </remarks>
|
||||
public string? BlobNamePrefix { get; set; }
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<VersionSuffix>preview</VersionSuffix>
|
||||
<InjectSharedThrow>true</InjectSharedThrow>
|
||||
|
||||
<!-- NuGet Package Settings -->
|
||||
<Title>Microsoft Agent Framework Azure Blob Storage integration</Title>
|
||||
<Description>Provides Azure Blob Storage session persistence for Microsoft Agent Framework hosting.</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Storage.Blobs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI.Hosting\Microsoft.Agents.AI.Hosting.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
#if NET8_0_OR_GREATER
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Azure;
|
||||
using Azure.Storage.Blobs;
|
||||
using Azure.Storage.Blobs.Models;
|
||||
using Microsoft.Agents.AI.Hosting.AzureStorage.Tests;
|
||||
using Shared.IntegrationTests;
|
||||
#endif
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.AzureStorage.IntegrationTests;
|
||||
|
||||
public sealed class AzureBlobAgentSessionStoreIntegrationTests
|
||||
{
|
||||
#if NET8_0_OR_GREATER
|
||||
[Fact(Skip = "Requires a provisioned Azure Storage account and data-plane permissions in CI.")]
|
||||
public async Task HostedAgentThroughTestServer_PersistsSessionInLiveBlobStorageAsync()
|
||||
{
|
||||
// Arrange
|
||||
string? endpoint = Environment.GetEnvironmentVariable("AZURE_STORAGE_BLOB_ENDPOINT");
|
||||
Assert.SkipWhen(string.IsNullOrWhiteSpace(endpoint), "AZURE_STORAGE_BLOB_ENDPOINT is not configured.");
|
||||
|
||||
BlobServiceClient serviceClient = new(
|
||||
new Uri(endpoint),
|
||||
TestAzureCliCredentials.CreateAzureCliCredential());
|
||||
BlobContainerClient containerClient =
|
||||
serviceClient.GetBlobContainerClient($"af-session-it-{Guid.NewGuid():N}");
|
||||
|
||||
try
|
||||
{
|
||||
// Act
|
||||
await using FakeTestAgentHost host =
|
||||
await FakeTestAgentHost.StartAsync(containerClient);
|
||||
FakeTestAgentHost.FakeTestAgentRunResult result = await host.RunTwoTurnsAsync();
|
||||
List<BlobItem> blobs = [];
|
||||
await foreach (BlobItem blob in containerClient.GetBlobsAsync())
|
||||
{
|
||||
blobs.Add(blob);
|
||||
}
|
||||
|
||||
BlobItem storedBlob = Assert.Single(blobs);
|
||||
BlobClient storedBlobClient = containerClient.GetBlobClient(storedBlob.Name);
|
||||
Response<BlobDownloadResult> download = await storedBlobClient.DownloadContentAsync();
|
||||
string persistedSession = download.Value.Content.ToString();
|
||||
|
||||
// Assert
|
||||
Assert.Contains("Turn 1", result.FirstResponse, StringComparison.Ordinal);
|
||||
Assert.Contains("Turn 2", result.SecondResponse, StringComparison.Ordinal);
|
||||
Assert.EndsWith(".json", storedBlob.Name, StringComparison.Ordinal);
|
||||
Assert.Equal("application/json", storedBlob.Properties.ContentType);
|
||||
Assert.Contains("turnCounter", persistedSession, StringComparison.Ordinal);
|
||||
Assert.Contains("\"count\":2", persistedSession, StringComparison.Ordinal);
|
||||
Assert.True((await storedBlobClient.ExistsAsync()).Value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await containerClient.DeleteIfExistsAsync();
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<InjectSharedIntegrationTestAzureCredentialsCode>True</InjectSharedIntegrationTestAzureCredentialsCode>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">
|
||||
<PackageReference Include="AGUI.Abstractions" />
|
||||
<PackageReference Include="AGUI.Client" />
|
||||
<PackageReference Include="AGUI.Server" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.TestHost" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Hosting.AzureStorage\Microsoft.Agents.AI.Hosting.AzureStorage.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
|
||||
<Compile Include="..\Microsoft.Agents.AI.Hosting.AzureStorage.TestUtilities\*.cs" LinkBase="TestUtilities" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AGUI.Abstractions;
|
||||
using AGUI.Client;
|
||||
using Azure.Storage.Blobs;
|
||||
using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting.Server;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.AzureStorage.Tests;
|
||||
|
||||
internal sealed class FakeTestAgentHost : IAsyncDisposable
|
||||
{
|
||||
private const string AgentName = "azure-blob-session-agent";
|
||||
|
||||
private readonly WebApplication _app;
|
||||
private readonly HttpClient _client;
|
||||
|
||||
private FakeTestAgentHost(WebApplication app, HttpClient client)
|
||||
{
|
||||
this._app = app;
|
||||
this._client = client;
|
||||
}
|
||||
|
||||
public static async Task<FakeTestAgentHost> StartAsync(BlobContainerClient containerClient)
|
||||
{
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||
builder.WebHost.UseTestServer();
|
||||
builder.Services.AddAGUIServer();
|
||||
builder.Services
|
||||
.AddAIAgent(AgentName, (_, name) => new SessionCountingAgent(name))
|
||||
.WithAzureBlobSessionStore(containerClient, withIsolation: false);
|
||||
|
||||
WebApplication app = builder.Build();
|
||||
app.MapAGUIServer(AgentName, "/agent");
|
||||
await app.StartAsync();
|
||||
|
||||
TestServer server = app.Services.GetRequiredService<IServer>() as TestServer
|
||||
?? throw new InvalidOperationException("TestServer was not registered.");
|
||||
HttpClient client = server.CreateClient();
|
||||
client.BaseAddress = new Uri("http://localhost/agent");
|
||||
|
||||
return new FakeTestAgentHost(app, client);
|
||||
}
|
||||
|
||||
public async Task<FakeTestAgentRunResult> RunTwoTurnsAsync()
|
||||
{
|
||||
var chatClient = new AGUIChatClient(new(this._client, ""));
|
||||
AIAgent clientAgent = chatClient.AsAIAgent(
|
||||
instructions: null,
|
||||
name: "client-agent",
|
||||
description: "Client for the in-memory test agent host.",
|
||||
tools: []);
|
||||
AgentSession clientSession = await clientAgent.CreateSessionAsync();
|
||||
|
||||
ChatMessage firstMessage = new(ChatRole.User, "first turn");
|
||||
List<AgentResponseUpdate> firstUpdates = [];
|
||||
await foreach (AgentResponseUpdate update in clientAgent.RunStreamingAsync(
|
||||
[firstMessage],
|
||||
clientSession,
|
||||
new AgentRunOptions(),
|
||||
CancellationToken.None))
|
||||
{
|
||||
firstUpdates.Add(update);
|
||||
}
|
||||
|
||||
RunStartedEvent runStarted = firstUpdates
|
||||
.Select(update => update.AsChatResponseUpdate().RawRepresentation as RunStartedEvent)
|
||||
.First(evt => evt is not null)!;
|
||||
|
||||
ChatMessage secondMessage = new(ChatRole.User, "second turn");
|
||||
var continuationOptions = new ChatClientAgentRunOptions
|
||||
{
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
RawRepresentationFactory = _ => new RunAgentInput
|
||||
{
|
||||
ThreadId = runStarted.ThreadId,
|
||||
ParentRunId = runStarted.RunId,
|
||||
Messages = new[] { secondMessage }.AsAGUIMessages().ToList(),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
List<AgentResponseUpdate> secondUpdates = [];
|
||||
await foreach (AgentResponseUpdate update in clientAgent.RunStreamingAsync(
|
||||
[secondMessage],
|
||||
clientSession,
|
||||
continuationOptions,
|
||||
CancellationToken.None))
|
||||
{
|
||||
secondUpdates.Add(update);
|
||||
}
|
||||
|
||||
return new(
|
||||
firstUpdates.ToAgentResponse().Text,
|
||||
secondUpdates.ToAgentResponse().Text);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
this._client.Dispose();
|
||||
await this._app.DisposeAsync();
|
||||
}
|
||||
|
||||
internal sealed record FakeTestAgentRunResult(
|
||||
string FirstResponse,
|
||||
string SecondResponse);
|
||||
|
||||
private sealed class SessionCountingAgent(string name) : AIAgent
|
||||
{
|
||||
protected override string? IdCore => name;
|
||||
|
||||
public override string? Name => name;
|
||||
|
||||
public override string? Description => "A deterministic agent that stores its turn count in the session.";
|
||||
|
||||
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default)
|
||||
=> new(new SessionCountingAgentSession());
|
||||
|
||||
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(
|
||||
JsonElement serializedState,
|
||||
JsonSerializerOptions? jsonSerializerOptions = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> new(serializedState.Deserialize<SessionCountingAgentSession>(jsonSerializerOptions)!);
|
||||
|
||||
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(
|
||||
AgentSession session,
|
||||
JsonSerializerOptions? jsonSerializerOptions = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (session is not SessionCountingAgentSession countingSession)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"The session type '{session.GetType().Name}' is not supported.");
|
||||
}
|
||||
|
||||
return new(JsonSerializer.SerializeToElement(countingSession, jsonSerializerOptions));
|
||||
}
|
||||
|
||||
protected override async Task<AgentResponse> RunCoreAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session = null,
|
||||
AgentRunOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
List<AgentResponseUpdate> updates = [];
|
||||
await foreach (AgentResponseUpdate update in this.RunStreamingAsync(
|
||||
messages,
|
||||
session,
|
||||
options,
|
||||
cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
updates.Add(update);
|
||||
}
|
||||
|
||||
return updates.ToAgentResponse();
|
||||
}
|
||||
|
||||
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session = null,
|
||||
AgentRunOptions? options = null,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
int turnCount = (session?.StateBag.GetValue<TurnCounter>("turnCounter")?.Count ?? 0) + 1;
|
||||
session?.StateBag.SetValue("turnCounter", new TurnCounter { Count = turnCount });
|
||||
|
||||
yield return new AgentResponseUpdate
|
||||
{
|
||||
MessageId = Guid.NewGuid().ToString("N"),
|
||||
Role = ChatRole.Assistant,
|
||||
Contents = [new TextContent($"Turn {turnCount}: session persisted")],
|
||||
};
|
||||
|
||||
await Task.Yield();
|
||||
}
|
||||
|
||||
private sealed class TurnCounter
|
||||
{
|
||||
public int Count { get; set; }
|
||||
}
|
||||
|
||||
private sealed class SessionCountingAgentSession : AgentSession
|
||||
{
|
||||
public SessionCountingAgentSession()
|
||||
{
|
||||
}
|
||||
|
||||
[JsonConstructor]
|
||||
public SessionCountingAgentSession(AgentSessionStateBag stateBag)
|
||||
: base(stateBag)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+298
@@ -0,0 +1,298 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure;
|
||||
using Azure.Storage.Blobs;
|
||||
using Azure.Storage.Blobs.Models;
|
||||
#if NET8_0_OR_GREATER
|
||||
using Microsoft.Agents.AI.Hosting.AzureStorage.Tests;
|
||||
#endif
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies Azure Blob Storage session persistence.
|
||||
/// </summary>
|
||||
public sealed class AzureBlobAgentSessionStoreTests : IAsyncLifetime
|
||||
{
|
||||
private static readonly string s_connectionString =
|
||||
Environment.GetEnvironmentVariable("AZURE_STORAGE_BLOB_CONNECTION_STRING") ?? "UseDevelopmentStorage=true";
|
||||
private static readonly BlobClientOptions s_clientOptions = new()
|
||||
{
|
||||
Retry =
|
||||
{
|
||||
MaxRetries = 0,
|
||||
NetworkTimeout = TimeSpan.FromSeconds(3),
|
||||
},
|
||||
};
|
||||
private static readonly BlobServiceClient s_blobServiceClient = new(s_connectionString, s_clientOptions);
|
||||
private static readonly Task<bool> s_azuriteAvailability = IsAzuriteAvailableAsync();
|
||||
|
||||
private readonly BlobContainerClient _containerClient;
|
||||
private bool _azuriteAvailable;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AzureBlobAgentSessionStoreTests"/> class.
|
||||
/// </summary>
|
||||
public AzureBlobAgentSessionStoreTests()
|
||||
{
|
||||
this._containerClient = s_blobServiceClient.GetBlobContainerClient($"agent-sessions-{Guid.NewGuid():N}");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
this._azuriteAvailable = await s_azuriteAvailability;
|
||||
bool required = string.Equals(
|
||||
Environment.GetEnvironmentVariable("AZURITE_AVAILABLE"),
|
||||
bool.TrueString,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
Assert.SkipWhen(!required && !this._azuriteAvailable, "Azurite is not available.");
|
||||
Assert.True(this._azuriteAvailable, "Azurite was required but could not be reached.");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (this._azuriteAvailable)
|
||||
{
|
||||
await this._containerClient.DeleteIfExistsAsync();
|
||||
}
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveAndGetSessionAsync_PersistsAcrossStoreAndAgentInstancesAsync()
|
||||
{
|
||||
// Arrange
|
||||
AIAgent savingAgent = new ChatClientAgent(new NotInvokedChatClient(), name: "assistant");
|
||||
AIAgent loadingAgent = new ChatClientAgent(new NotInvokedChatClient(), name: "assistant");
|
||||
AgentSession session = await savingAgent.CreateSessionAsync();
|
||||
session.StateBag.SetValue("marker", "saved");
|
||||
|
||||
var savingStore = new AzureBlobAgentSessionStore(this._containerClient, "assistant");
|
||||
var loadingStore = new AzureBlobAgentSessionStore(this._containerClient, "assistant");
|
||||
|
||||
// Act
|
||||
await savingStore.SaveSessionAsync(savingAgent, "session-1", session);
|
||||
AgentSession restored = await loadingStore.GetSessionAsync(loadingAgent, "session-1");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("saved", restored.StateBag.GetValue<string>("marker"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveAndGetSessionAsync_SupportsDistinctLongOpaqueIdsAsync()
|
||||
{
|
||||
// Arrange
|
||||
AIAgent agent = new ChatClientAgent(new NotInvokedChatClient(), name: "assistant");
|
||||
var store = new AzureBlobAgentSessionStore(this._containerClient, "assistant");
|
||||
string commonPrefix = new('s', 2048);
|
||||
string firstId = commonPrefix + "\0:first";
|
||||
string secondId = commonPrefix + "\u0001/second";
|
||||
|
||||
AgentSession firstSession = await agent.CreateSessionAsync();
|
||||
firstSession.StateBag.SetValue("marker", "first");
|
||||
AgentSession secondSession = await agent.CreateSessionAsync();
|
||||
secondSession.StateBag.SetValue("marker", "second");
|
||||
|
||||
// Act
|
||||
await store.SaveSessionAsync(agent, firstId, firstSession);
|
||||
await store.SaveSessionAsync(agent, secondId, secondSession);
|
||||
AgentSession restoredFirst = await store.GetSessionAsync(agent, firstId);
|
||||
AgentSession restoredSecond = await store.GetSessionAsync(agent, secondId);
|
||||
List<string> blobNames = [];
|
||||
await foreach (BlobItem blob in this._containerClient.GetBlobsAsync())
|
||||
{
|
||||
blobNames.Add(blob.Name);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Equal("first", restoredFirst.StateBag.GetValue<string>("marker"));
|
||||
Assert.Equal("second", restoredSecond.StateBag.GetValue<string>("marker"));
|
||||
Assert.Equal(2, blobNames.Count);
|
||||
Assert.All(blobNames, blobName => Assert.InRange(blobName.Length, 1, 1024));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteSessionAsync_RemovesStoredSessionAndIgnoresMissingSessionAsync()
|
||||
{
|
||||
// Arrange
|
||||
AIAgent agent = new ChatClientAgent(new NotInvokedChatClient(), name: "assistant");
|
||||
var store = new AzureBlobAgentSessionStore(this._containerClient, "assistant");
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
session.StateBag.SetValue("marker", "saved");
|
||||
await store.SaveSessionAsync(agent, "session-to-delete", session);
|
||||
|
||||
// Act
|
||||
await store.DeleteSessionAsync(agent, "session-to-delete");
|
||||
AgentSession restored = await store.GetSessionAsync(agent, "session-to-delete");
|
||||
await store.DeleteSessionAsync(agent, "session-to-delete");
|
||||
|
||||
// Assert
|
||||
Assert.Null(restored.StateBag.GetValue<string>("marker"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveSessionAsync_OverwritesExistingSessionAsJsonAsync()
|
||||
{
|
||||
// Arrange
|
||||
AIAgent agent = new ChatClientAgent(new NotInvokedChatClient(), name: "assistant");
|
||||
var store = new AzureBlobAgentSessionStore(this._containerClient, "assistant");
|
||||
AgentSession first = await agent.CreateSessionAsync();
|
||||
first.StateBag.SetValue("marker", "first");
|
||||
AgentSession second = await agent.CreateSessionAsync();
|
||||
second.StateBag.SetValue("marker", "second");
|
||||
|
||||
// Act
|
||||
await store.SaveSessionAsync(agent, "session-1", first);
|
||||
await store.SaveSessionAsync(agent, "session-1", second);
|
||||
AgentSession restored = await store.GetSessionAsync(agent, "session-1");
|
||||
List<BlobItem> blobs = [];
|
||||
await foreach (BlobItem blob in this._containerClient.GetBlobsAsync())
|
||||
{
|
||||
blobs.Add(blob);
|
||||
}
|
||||
|
||||
// Assert
|
||||
BlobItem storedBlob = Assert.Single(blobs);
|
||||
Assert.Equal("application/json", storedBlob.Properties.ContentType);
|
||||
Assert.Equal("second", restored.StateBag.GetValue<string>("marker"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSessionAsync_MissingContainerWithoutAutoCreatePropagatesErrorAsync()
|
||||
{
|
||||
// Arrange
|
||||
AIAgent agent = new ChatClientAgent(new NotInvokedChatClient(), name: "assistant");
|
||||
BlobContainerClient missingContainer = s_blobServiceClient.GetBlobContainerClient($"missing-{Guid.NewGuid():N}");
|
||||
var store = new AzureBlobAgentSessionStore(
|
||||
missingContainer,
|
||||
"assistant",
|
||||
new AzureBlobAgentSessionStoreOptions { CreateContainerIfNotExists = false });
|
||||
|
||||
// Act
|
||||
RequestFailedException exception = await Assert.ThrowsAsync<RequestFailedException>(
|
||||
() => store.GetSessionAsync(agent, "session-1").AsTask());
|
||||
|
||||
// Assert
|
||||
Assert.Equal(BlobErrorCode.ContainerNotFound.ToString(), exception.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSessionAsync_ReturnsIndependentSnapshotsAsync()
|
||||
{
|
||||
// Arrange
|
||||
AIAgent agent = new ChatClientAgent(new NotInvokedChatClient(), name: "assistant");
|
||||
var store = new AzureBlobAgentSessionStore(this._containerClient, "assistant");
|
||||
AgentSession original = await agent.CreateSessionAsync();
|
||||
original.StateBag.SetValue("marker", "saved");
|
||||
await store.SaveSessionAsync(agent, "session-1", original);
|
||||
|
||||
// Act
|
||||
AgentSession first = await store.GetSessionAsync(agent, "session-1");
|
||||
AgentSession second = await store.GetSessionAsync(agent, "session-1");
|
||||
first.StateBag.SetValue("marker", "changed");
|
||||
AgentSession third = await store.GetSessionAsync(agent, "session-1");
|
||||
|
||||
// Assert
|
||||
Assert.NotSame(first, second);
|
||||
Assert.Equal("saved", second.StateBag.GetValue<string>("marker"));
|
||||
Assert.Equal("saved", third.StateBag.GetValue<string>("marker"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveSessionAsync_ConcurrentFirstWritesCreateContainerSafelyAsync()
|
||||
{
|
||||
// Arrange
|
||||
AIAgent agent = new ChatClientAgent(new NotInvokedChatClient(), name: "assistant");
|
||||
var store = new AzureBlobAgentSessionStore(this._containerClient, "assistant");
|
||||
List<Task> writes = [];
|
||||
|
||||
for (int index = 0; index < 16; index++)
|
||||
{
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
session.StateBag.SetValue("marker", index.ToString());
|
||||
writes.Add(store.SaveSessionAsync(agent, $"session-{index}", session).AsTask());
|
||||
}
|
||||
|
||||
// Act
|
||||
await Task.WhenAll(writes);
|
||||
List<BlobItem> blobs = [];
|
||||
await foreach (BlobItem blob in this._containerClient.GetBlobsAsync())
|
||||
{
|
||||
blobs.Add(blob);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Equal(16, blobs.Count);
|
||||
}
|
||||
|
||||
#if NET8_0_OR_GREATER
|
||||
[Fact]
|
||||
public async Task HostedAgentThroughTestServer_PersistsSessionInAzuriteAsync()
|
||||
{
|
||||
// Arrange
|
||||
await using FakeTestAgentHost host =
|
||||
await FakeTestAgentHost.StartAsync(this._containerClient);
|
||||
|
||||
// Act
|
||||
FakeTestAgentHost.FakeTestAgentRunResult result = await host.RunTwoTurnsAsync();
|
||||
List<BlobItem> blobs = [];
|
||||
await foreach (BlobItem blob in this._containerClient.GetBlobsAsync())
|
||||
{
|
||||
blobs.Add(blob);
|
||||
}
|
||||
|
||||
BlobItem storedBlob = Assert.Single(blobs);
|
||||
Response<BlobDownloadResult> download = await this._containerClient
|
||||
.GetBlobClient(storedBlob.Name)
|
||||
.DownloadContentAsync();
|
||||
string persistedSession = download.Value.Content.ToString();
|
||||
|
||||
// Assert
|
||||
Assert.Contains("Turn 1", result.FirstResponse, StringComparison.Ordinal);
|
||||
Assert.Contains("Turn 2", result.SecondResponse, StringComparison.Ordinal);
|
||||
Assert.Equal("application/json", storedBlob.Properties.ContentType);
|
||||
Assert.Contains("turnCounter", persistedSession, StringComparison.Ordinal);
|
||||
Assert.Contains("\"count\":2", persistedSession, StringComparison.Ordinal);
|
||||
}
|
||||
#endif
|
||||
|
||||
[Fact]
|
||||
public void Constructor_BlobNamePrefixExceedsAzureLimit_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var options = new AzureBlobAgentSessionStoreOptions
|
||||
{
|
||||
BlobNamePrefix = new string('p', 887),
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentException>(
|
||||
() => new AzureBlobAgentSessionStore(this._containerClient, "assistant", options));
|
||||
}
|
||||
|
||||
private static async Task<bool> IsAzuriteAvailableAsync()
|
||||
{
|
||||
using CancellationTokenSource cancellationTokenSource = new(TimeSpan.FromSeconds(3));
|
||||
|
||||
try
|
||||
{
|
||||
await s_blobServiceClient.GetPropertiesAsync(cancellationToken: cancellationTokenSource.Token);
|
||||
return true;
|
||||
}
|
||||
catch (RequestFailedException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationTokenSource.IsCancellationRequested)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Azure.Storage.Blobs;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies Azure Blob Storage registration for hosted agents.
|
||||
/// </summary>
|
||||
public sealed class AzureBlobHostedAgentBuilderExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void WithAzureBlobSessionStore_RegistersSingletonWithIsolation()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
IHostedAgentBuilder builder = services.AddAIAgent(
|
||||
"assistant",
|
||||
(_, key) => new ChatClientAgent(new NotInvokedChatClient(), name: key));
|
||||
BlobContainerClient containerClient = new("UseDevelopmentStorage=true", "agent-sessions");
|
||||
|
||||
// Act
|
||||
builder.WithAzureBlobSessionStore(containerClient);
|
||||
using ServiceProvider serviceProvider = services.BuildServiceProvider();
|
||||
AgentSessionStore store = serviceProvider.GetRequiredKeyedService<AgentSessionStore>("assistant");
|
||||
|
||||
// Assert
|
||||
ServiceDescriptor descriptor = Assert.Single(
|
||||
services,
|
||||
service =>
|
||||
service.ServiceType == typeof(AgentSessionStore) &&
|
||||
service.ServiceKey as string == "assistant");
|
||||
Assert.Equal(ServiceLifetime.Singleton, descriptor.Lifetime);
|
||||
Assert.IsType<IsolationKeyScopedAgentSessionStore>(store);
|
||||
Assert.NotNull(store.GetService<AzureBlobAgentSessionStore>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithAzureBlobSessionStoreFactory_UsesAgentNameLifetimeAndIsolationOptions()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
IHostedAgentBuilder builder = services.AddAIAgent(
|
||||
"assistant",
|
||||
(_, key) => new ChatClientAgent(new NotInvokedChatClient(), name: key));
|
||||
BlobContainerClient containerClient = new("UseDevelopmentStorage=true", "agent-sessions");
|
||||
string? receivedAgentName = null;
|
||||
|
||||
// Act
|
||||
builder.WithAzureBlobSessionStore(
|
||||
(_, agentName) =>
|
||||
{
|
||||
receivedAgentName = agentName;
|
||||
return containerClient;
|
||||
},
|
||||
lifetime: ServiceLifetime.Scoped,
|
||||
withIsolation: false);
|
||||
using ServiceProvider serviceProvider = services.BuildServiceProvider();
|
||||
AgentSessionStore store = serviceProvider.GetRequiredKeyedService<AgentSessionStore>("assistant");
|
||||
|
||||
// Assert
|
||||
ServiceDescriptor descriptor = Assert.Single(
|
||||
services,
|
||||
service =>
|
||||
service.ServiceType == typeof(AgentSessionStore) &&
|
||||
service.ServiceKey as string == "assistant");
|
||||
Assert.Equal("assistant", receivedAgentName);
|
||||
Assert.Equal(ServiceLifetime.Scoped, descriptor.Lifetime);
|
||||
Assert.IsType<AzureBlobAgentSessionStore>(store);
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">
|
||||
<PackageReference Include="AGUI.Abstractions" />
|
||||
<PackageReference Include="AGUI.Client" />
|
||||
<PackageReference Include="AGUI.Server" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.TestHost" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Hosting.AzureStorage\Microsoft.Agents.AI.Hosting.AzureStorage.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
|
||||
<Compile Include="..\Microsoft.Agents.AI.Hosting.AzureStorage.TestUtilities\*.cs" LinkBase="TestUtilities" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests;
|
||||
|
||||
internal sealed class NotInvokedChatClient : IChatClient
|
||||
{
|
||||
public Task<ChatResponse> GetResponseAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
ChatOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
ChatOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public object? GetService(Type serviceType, object? serviceKey = null) => null;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user