Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion .github/workflows/dotnet-build-and-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,15 @@ jobs:
dotnet
workflow-samples

- name: Start Azurite
if: matrix.os == 'ubuntu-latest'
run: |
docker run -d \
-p 10000:10000 \
-p 10001:10001 \
-p 10002:10002 \
mcr.microsoft.com/azure-storage/azurite:latest

- name: Setup dotnet
uses: actions/setup-dotnet@v5.0.0
with:
Expand Down Expand Up @@ -239,4 +248,4 @@ jobs:
if: contains(join(needs.*.result, ','), 'cancelled')
uses: actions/github-script@v8
with:
script: core.setFailed('Integration Tests Cancelled!')
script: core.setFailed('Integration Tests Cancelled!')
2 changes: 2 additions & 0 deletions dotnet/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
<PackageVersion Include="Azure.AI.OpenAI" Version="2.5.0-beta.1" />
<PackageVersion Include="Azure.Identity" Version="1.17.0" />
<PackageVersion Include="Azure.Monitor.OpenTelemetry.Exporter" Version="1.4.0" />
<PackageVersion Include="Azure.Storage.Blobs" Version="12.26.0" />
<!-- System.* -->
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="9.0.10" />
<PackageVersion Include="Microsoft.Bcl.HashCode" Version="6.0.0" />
Expand Down Expand Up @@ -105,6 +106,7 @@
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.abstractions" Version="2.0.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.3" />
<PackageVersion Include="Xunit.SkippableFact" Version="1.5.23" />
<PackageVersion Include="xretry" Version="1.9.0" />
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
<!-- Symbols -->
Expand Down
6 changes: 4 additions & 2 deletions dotnet/agent-framework-dotnet.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@
<Project Path="samples/GettingStarted/Workflows/Observability/WorkflowAsAnAgent/WorkflowAsAnAgentObservability.csproj" />
</Folder>
<Folder Name="/Samples/GettingStarted/Workflows/Visualization/">
<Project Path="samples/GettingStarted/Workflows/Visualization/Visualization.csproj" Id="99bf0bc6-2440-428e-b3e7-d880e4b7a5fd" />
<Project Path="samples/GettingStarted/Workflows/Visualization/Visualization.csproj" />
</Folder>
<Folder Name="/Samples/GettingStarted/Workflows/_Foundational/">
<Project Path="samples/GettingStarted/Workflows/_Foundational/01_ExecutorsAndEdges/01_ExecutorsAndEdges.csproj" />
Expand Down Expand Up @@ -275,6 +275,7 @@
<Project Path="src/Microsoft.Agents.AI.CopilotStudio/Microsoft.Agents.AI.CopilotStudio.csproj" />
<Project Path="src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj" />
<Project Path="src/Microsoft.Agents.AI.Hosting.A2A/Microsoft.Agents.AI.Hosting.A2A.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.Mem0/Microsoft.Agents.AI.Mem0.csproj" />
Expand All @@ -298,8 +299,9 @@
<Project Path="tests/Microsoft.Agents.AI.A2A.UnitTests/Microsoft.Agents.AI.A2A.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Abstractions.UnitTests/Microsoft.Agents.AI.Abstractions.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.AzureAI.UnitTests/Microsoft.Agents.AI.AzureAI.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Hosting.A2A.Tests/Microsoft.Agents.AI.Hosting.A2A.Tests.csproj" Id="2a1c544d-237d-4436-8732-ba0c447ac06b" />
<Project Path="tests/Microsoft.Agents.AI.Hosting.A2A.Tests/Microsoft.Agents.AI.Hosting.A2A.Tests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/Microsoft.Agents.AI.Hosting.OpenAI.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.UnitTests/Microsoft.Agents.AI.Hosting.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Mem0.UnitTests/Microsoft.Agents.AI.Mem0.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.OpenAI.UnitTests/Microsoft.Agents.AI.OpenAI.UnitTests.csproj" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Hosting.AzureStorage\Microsoft.Agents.AI.Hosting.AzureStorage.csproj" />
<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" />
Expand Down
8 changes: 8 additions & 0 deletions dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using A2A.AspNetCore;
using AgentWebChat.AgentHost;
using AgentWebChat.AgentHost.Utilities;
using Azure.Storage.Blobs;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Hosting;
using Microsoft.Agents.AI.Workflows;
Expand All @@ -27,6 +28,13 @@
chatClientServiceKey: "chat-model")
.WithInMemoryThreadStore();

builder.AddAIAgent(
"gambler",
instructions: "You are a gambler. Talk like a gambler.",
description: "An agent which gambles",
chatClientServiceKey: "chat-model")
.WithAzureBlobThreadStore(sp => new BlobContainerClient(connectionString: "UseDevelopmentStorage=true", "agent-threads"));

builder.AddAIAgent("knights-and-knaves", (sp, key) =>
{
var chatClient = sp.GetRequiredKeyedService<IChatClient>("chat-model");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
// Copyright (c) Microsoft. All rights reserved.

using System;
using System.IO;
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.Blob;

internal sealed class AzureBlobAgentThreadStore : AgentThreadStore
{
private static readonly BlobOpenWriteOptions s_uploadJsonOptions = new()
{
HttpHeaders = new()
{
ContentType = "application/json"
}
};

private readonly BlobContainerClient _containerClient;
private readonly AzureBlobAgentThreadStoreOptions _options;
private bool _containerInitialized;

/// <summary>
/// Initializes a new instance of the <see cref="AzureBlobAgentThreadStore"/> class using a <see cref="BlobContainerClient"/>.
/// </summary>
/// <param name="containerClient">The blob container client to use for storage operations.</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>
public AzureBlobAgentThreadStore(BlobContainerClient containerClient, AzureBlobAgentThreadStoreOptions? options = null)
{
this._containerClient = containerClient ?? throw new ArgumentNullException(nameof(containerClient));
this._options = options ?? new AzureBlobAgentThreadStoreOptions();
}

/// <inheritdoc/>
public override async ValueTask SaveThreadAsync(
AIAgent agent,
string conversationId,
AgentThread thread,
CancellationToken cancellationToken = default)
{
Throw.IfNull(agent);
Throw.IfNull(conversationId);
Throw.IfNull(thread);

await this.EnsureContainerExistsAsync(cancellationToken).ConfigureAwait(false);

var blobName = this.GetBlobName(agent.Id, conversationId);
var blobClient = this._containerClient.GetBlobClient(blobName);

JsonElement serializedThread = thread.Serialize();
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);
}

/// <inheritdoc/>
public override async ValueTask<AgentThread> GetThreadAsync(
AIAgent agent,
string conversationId,
CancellationToken cancellationToken = default)
{
Throw.IfNull(agent);
Throw.IfNull(conversationId);

await this.EnsureContainerExistsAsync(cancellationToken).ConfigureAwait(false);

var blobName = this.GetBlobName(agent.Id, conversationId);
var blobClient = this._containerClient.GetBlobClient(blobName);

try
{
var stream = await blobClient.OpenReadAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
var jsonDoc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
var serializedThread = jsonDoc.RootElement;

return agent.DeserializeThread(serializedThread);
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
// Blob doesn't exist, return a new thread
return agent.GetNewThread();
}
}

/// <summary>
/// Ensures that the blob container exists, creating it if necessary.
/// </summary>
private async Task EnsureContainerExistsAsync(CancellationToken cancellationToken)
{
if (this._containerInitialized)
{
return;
}

if (!this._containerInitialized)
{
if (this._options.CreateContainerIfNotExists)
{
await this._containerClient.CreateIfNotExistsAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
}

this._containerInitialized = true;
}
}

/// <summary>
/// Generates the blob name for a given agent and conversation.
/// </summary>
// internal for testing
internal string GetBlobName(string agentId, string conversationId)
{
string sanitizedAgentId = this.SanitizeBlobNameSegment(agentId);
string sanitizedConversationId = this.SanitizeBlobNameSegment(conversationId);
string baseName = $"{sanitizedAgentId}/{sanitizedConversationId}.json";

return string.IsNullOrEmpty(this._options.BlobNamePrefix)
? baseName
: $"{this._options.BlobNamePrefix.TrimEnd('/')}/{baseName}";
}

/// <summary>
/// Sanitizes a string to be safe for use in blob names.
/// </summary>
private string SanitizeBlobNameSegment(string input)
{
if (string.IsNullOrWhiteSpace(input))
{
return "default";
}

// Replace invalid characters with underscore
StringBuilder builder = new(input.Length);
foreach (char c in input)
{
if (char.IsLetterOrDigit(c) || c == '-' || c == '_' || c == '.')
{
builder.Append(c);
}
else
{
builder.Append('_');
}
}

return builder.ToString();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Copyright (c) Microsoft. All rights reserved.

namespace Microsoft.Agents.AI.Hosting.AzureStorage.Blob;

/// <summary>
/// Configuration options for <see cref="AzureBlobAgentThreadStore"/>.
/// </summary>
public sealed class AzureBlobAgentThreadStoreOptions
{
/// <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"/>.
/// </remarks>
public bool CreateContainerIfNotExists { get; set; } = true;

/// <summary>
/// Gets or sets the blob name prefix to use for organizing threads.
/// </summary>
/// <remarks>
/// This can be used to namespace threads within a container.
/// For example, setting this to "prod/" will store all blobs under a "prod/" prefix.
/// </remarks>
public string? BlobNamePrefix { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// Copyright (c) Microsoft. All rights reserved.

using System;
using Azure.Storage.Blobs;
using Microsoft.Agents.AI.Hosting.AzureStorage.Blob;

namespace Microsoft.Agents.AI.Hosting;

/// <summary>
/// Provides extension methods for configuring <see cref="AIAgent"/>.
/// </summary>
public static partial class HostedAgentBuilderExtensions
{
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure if we need other API here, but felt like BlobContainerClient is the best option - it allows to set the most granular access to the container itself.

/// <summary>
/// Configures the host agent builder to use an Azure Blob thread store for agent thread management.
/// </summary>
/// <param name="builder">The host agent builder to configure with the Azure blob thread store.</param>
/// <param name="containerClient">The blob container client to use for storage operations.</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>
public static IHostedAgentBuilder WithAzureBlobThreadStore(this IHostedAgentBuilder builder, BlobContainerClient containerClient, AzureBlobAgentThreadStoreOptions? options = null)
=> WithAzureBlobThreadStore(builder, sp => containerClient, options);

/// <summary>
/// Configures the agent builder to use Azure Blob Storage as the thread store for agent state persistence.
/// </summary>
/// <param name="builder">The agent builder to configure with Azure Blob thread store support.</param>
/// <param name="createBlobContainer">A factory function that provides a configured BlobContainerClient instance for accessing the Azure Blob
/// container used to store thread data.</param>
/// <param name="options">Optional settings for customizing the Azure Blob thread store behavior. If null, default options are used.</param>
/// <returns>The same agent builder instance, configured to use Azure Blob Storage for thread persistence.</returns>
public static IHostedAgentBuilder WithAzureBlobThreadStore(
this IHostedAgentBuilder builder,
Func<IServiceProvider, BlobContainerClient> createBlobContainer,
AzureBlobAgentThreadStoreOptions? options = null)
=> builder.WithThreadStore((sp, key) =>
{
var blobContainer = createBlobContainer(sp);
return new AzureBlobAgentThreadStore(blobContainer, options);
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFrameworks>$(ProjectsCoreTargetFrameworks)</TargetFrameworks>
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsDebugCoreTargetFrameworks)</TargetFrameworks>
<VersionSuffix>preview</VersionSuffix>
<InjectSharedThrow>true</InjectSharedThrow>

<!-- NuGet Package Settings -->
<Title>Microsoft Agent Framework AzureStorage</Title>
<Description>Provides AzureStorage integration with Microsoft Agent Framework.</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>

<ItemGroup>
<InternalsVisibleTo Include="Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests" />
</ItemGroup>
</Project>
Loading
Loading