Compare commits

..
Author SHA1 Message Date
Korolev Dmitry 4ebbed6fd6 copilot changes 2025-11-04 19:30:46 +01:00
Korolev DmitryandGitHub 900135b092 Merge branch 'main' into dmkorolev/agentthreadstorages 2025-11-04 19:05:16 +01:00
Korolev Dmitry 9bbfab6c1e rename to unit tests 2025-11-04 19:00:34 +01:00
Korolev Dmitry 1538c631c8 renames / fixes 2025-11-04 18:47:35 +01:00
Korolev Dmitry 448785b6b3 move and rename 2025-11-04 18:43:34 +01:00
Korolev Dmitry 58af9ecb89 azurite as a step 2025-11-04 18:35:10 +01:00
Korolev Dmitry 027d1e04a7 rollback 2025-11-04 18:32:37 +01:00
Korolev Dmitry 3f7b0d1085 use services 2025-11-04 13:49:50 +01:00
Korolev Dmitry 8baacc4fe3 nit 2025-11-04 13:42:01 +01:00
Korolev Dmitry 2663467914 add azurite as dependency for dotnet tests 2025-11-04 13:38:05 +01:00
Korolev Dmitry 4de5f1b421 setup for azure blob as agentthreadstore 2025-11-04 13:29:56 +01:00
Korolev Dmitry 6c969418ac setup azurestorage proj 2025-11-03 19:19:14 +01:00
31 changed files with 599 additions and 106 deletions
+10 -1
View File
@@ -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:
@@ -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
View File
@@ -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" />
@@ -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 -->
+4 -2
View File
@@ -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" />
@@ -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" />
@@ -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" />
+3 -3
View File
@@ -2,9 +2,9 @@
<PropertyGroup>
<!-- Central version prefix - applies to all nuget packages. -->
<VersionPrefix>1.0.0</VersionPrefix>
<PackageVersion Condition="'$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).251104.1</PackageVersion>
<PackageVersion Condition="'$(VersionSuffix)' == ''">$(VersionPrefix)-preview.251104.1</PackageVersion>
<GitTag>1.0.0-preview.251104.1</GitTag>
<PackageVersion Condition="'$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).251028.1</PackageVersion>
<PackageVersion Condition="'$(VersionSuffix)' == ''">$(VersionPrefix)-preview.251028.1</PackageVersion>
<GitTag>1.0.0-preview.251028.1</GitTag>
<Configurations>Debug;Release;Publish</Configurations>
<IsPackable>true</IsPackable>
@@ -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" />
@@ -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;
@@ -27,6 +28,13 @@ builder.AddAIAgent(
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");
@@ -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();
}
}
@@ -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; }
}
@@ -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
{
/// <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);
});
}
@@ -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>
@@ -32,7 +32,7 @@
<ItemGroup>
<InternalsVisibleTo Include="Microsoft.Agents.AI.UnitTests" />
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
<InternalsVisibleTo Include="DynamicProxyGenAssembly2, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c547cac37abd99c8db225ef2f6c8a3602f3b3606cc9891605d02baa56104f4cfc0734aa39b93bf7852f7d9266654753cc297e7d2edfe0bac1cdcf9f717241550e0a7b191195b7667bb4f64bcb8e2121380fd1d9d46ad2d92d2d15605093924cceaf74c4861eff62abf69b9291ed0a340e113be11e6a7d3113e92484cf7045cc7"/>
</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 Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests;
/// <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 Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests;
/// <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()
{
using 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,55 @@
// 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 Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests.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();
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();
var responseText = $"Response #{this._responsesCounter++}";
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,84 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests.Mock;
using Microsoft.Extensions.AI;
using Xunit.Abstractions;
using ThreadStore = Microsoft.Agents.AI.Hosting.AgentThreadStore;
namespace Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests;
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; }
}
+1 -25
View File
@@ -7,29 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [1.0.0b251104] - 2025-11-04
### Added
- Introducing the Anthropic Client ([#1819](https://github.com/microsoft/agent-framework/pull/1819))
### Changed
- [BREAKING] Consolidate workflow run APIs ([#1723](https://github.com/microsoft/agent-framework/pull/1723))
- [BREAKING] Remove request_type param from ctx.request_info() ([#1824](https://github.com/microsoft/agent-framework/pull/1824))
- [BREAKING] Cleanup of dependencies ([#1803](https://github.com/microsoft/agent-framework/pull/1803))
- [BREAKING] Replace `RequestInfoExecutor` with `request_info` API and `@response_handler` ([#1466](https://github.com/microsoft/agent-framework/pull/1466))
- Azure AI Search Support Update + Refactored Samples & Unit Tests ([#1683](https://github.com/microsoft/agent-framework/pull/1683))
- Lab: Updates to GAIA module ([#1763](https://github.com/microsoft/agent-framework/pull/1763))
### Fixed
- Azure AI `top_p` and `temperature` parameters fix ([#1839](https://github.com/microsoft/agent-framework/pull/1839))
- Ensure agent thread is part of checkpoint ([#1756](https://github.com/microsoft/agent-framework/pull/1756))
- Fix middleware and cleanup confusing function ([#1865](https://github.com/microsoft/agent-framework/pull/1865))
- Fix type compatibility check ([#1753](https://github.com/microsoft/agent-framework/pull/1753))
- Fix mcp tool cloning for handoff pattern ([#1883](https://github.com/microsoft/agent-framework/pull/1883))
## [1.0.0b251028] - 2025-10-28
### Added
@@ -147,8 +124,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
For more information, see the [announcement blog post](https://devblogs.microsoft.com/foundry/introducing-microsoft-agent-framework-the-open-source-engine-for-agentic-ai-apps/).
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251104...HEAD
[1.0.0b251104]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251028...python-1.0.0b251104
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251028...HEAD
[1.0.0b251028]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251016...python-1.0.0b251028
[1.0.0b251016]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251007...python-1.0.0b251016
[1.0.0b251007]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251001...python-1.0.0b251007
+1 -1
View File
@@ -4,7 +4,7 @@ description = "A2A integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b251104"
version = "1.0.0b251028"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
+1 -1
View File
@@ -4,7 +4,7 @@ description = "Anthropic integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b251104"
version = "1.0.0b251028"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
+1 -1
View File
@@ -4,7 +4,7 @@ description = "Azure AI Foundry integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b251104"
version = "1.0.0b251028"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
+1 -1
View File
@@ -4,7 +4,7 @@ description = "Copilot Studio integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b251104"
version = "1.0.0b251028"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -80,14 +80,6 @@ def _clone_chat_agent(agent: ChatAgent) -> ChatAgent:
options = agent.chat_options
middleware = list(agent.middleware or [])
# Reconstruct the original tools list by combining regular tools with MCP tools.
# ChatAgent.__init__ separates MCP tools into _local_mcp_tools during initialization,
# so we need to recombine them here to pass the complete tools list to the constructor.
# This makes sure MCP tools are preserved when cloning agents for handoff workflows.
all_tools = list(options.tools) if options.tools else []
if agent._local_mcp_tools:
all_tools.extend(agent._local_mcp_tools)
return ChatAgent(
chat_client=agent.chat_client,
instructions=options.instructions,
@@ -109,7 +101,7 @@ def _clone_chat_agent(agent: ChatAgent) -> ChatAgent:
store=options.store,
temperature=options.temperature,
tool_choice=options.tool_choice, # type: ignore[arg-type]
tools=all_tools if all_tools else None,
tools=list(options.tools) if options.tools else None,
top_p=options.top_p,
user=options.user,
additional_chat_options=dict(options.additional_properties),
+1 -1
View File
@@ -4,7 +4,7 @@ description = "Microsoft Agent Framework for building AI Agents with Python. Thi
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b251104"
version = "1.0.0b251028"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -3,7 +3,6 @@
from collections.abc import AsyncIterable, AsyncIterator
from dataclasses import dataclass
from typing import Any, cast
from unittest.mock import MagicMock
import pytest
@@ -11,7 +10,6 @@ from agent_framework import (
AgentRunResponse,
AgentRunResponseUpdate,
BaseAgent,
ChatAgent,
ChatMessage,
FunctionCallContent,
HandoffBuilder,
@@ -22,8 +20,6 @@ from agent_framework import (
WorkflowEvent,
WorkflowOutputEvent,
)
from agent_framework._mcp import MCPTool
from agent_framework._workflows._handoff import _clone_chat_agent
@dataclass
@@ -372,32 +368,3 @@ async def test_handoff_async_termination_condition() -> None:
user_messages = [msg for msg in final_conv_list if msg.role == Role.USER]
assert len(user_messages) == 2
assert termination_call_count > 0
async def test_clone_chat_agent_preserves_mcp_tools() -> None:
"""Test that _clone_chat_agent preserves MCP tools when cloning an agent."""
mock_chat_client = MagicMock()
mock_mcp_tool = MagicMock(spec=MCPTool)
mock_mcp_tool.name = "test_mcp_tool"
def sample_function() -> str:
return "test"
original_agent = ChatAgent(
chat_client=mock_chat_client,
name="TestAgent",
instructions="Test instructions",
tools=[mock_mcp_tool, sample_function],
)
assert hasattr(original_agent, "_local_mcp_tools")
assert len(original_agent._local_mcp_tools) == 1
assert original_agent._local_mcp_tools[0] == mock_mcp_tool
cloned_agent = _clone_chat_agent(original_agent)
assert hasattr(cloned_agent, "_local_mcp_tools")
assert len(cloned_agent._local_mcp_tools) == 1
assert cloned_agent._local_mcp_tools[0] == mock_mcp_tool
assert len(cloned_agent.chat_options.tools) == 1
+1 -1
View File
@@ -4,7 +4,7 @@ description = "Debug UI for Microsoft Agent Framework with OpenAI-compatible API
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b251104"
version = "1.0.0b251028"
license-files = ["LICENSE"]
urls.homepage = "https://github.com/microsoft/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
+1 -1
View File
@@ -4,7 +4,7 @@ description = "Experimental modules for Microsoft Agent Framework"
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b251104"
version = "1.0.0b251028"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
+1 -1
View File
@@ -4,7 +4,7 @@ description = "Mem0 integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b251104"
version = "1.0.0b251028"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
+1 -1
View File
@@ -4,7 +4,7 @@ description = "Microsoft Purview (Graph dataSecurityAndGovernance) integration f
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b251104"
version = "1.0.0b251028"
license-files = ["LICENSE"]
urls.homepage = "https://github.com/microsoft/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
+1 -1
View File
@@ -4,7 +4,7 @@ description = "Redis integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b251104"
version = "1.0.0b251028"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
+1 -1
View File
@@ -4,7 +4,7 @@ description = "Microsoft Agent Framework for building AI Agents with Python. Thi
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b251104"
version = "1.0.0b251028"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
+11 -21
View File
@@ -73,7 +73,7 @@ wheels = [
[[package]]
name = "agent-framework"
version = "1.0.0b251104"
version = "1.0.0b251028"
source = { virtual = "." }
dependencies = [
{ name = "agent-framework-a2a", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -160,7 +160,7 @@ docs = [
[[package]]
name = "agent-framework-a2a"
version = "1.0.0b251104"
version = "1.0.0b251028"
source = { editable = "packages/a2a" }
dependencies = [
{ name = "a2a-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -175,7 +175,7 @@ requires-dist = [
[[package]]
name = "agent-framework-anthropic"
version = "1.0.0b251104"
version = "1.0.0b251028"
source = { editable = "packages/anthropic" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -190,7 +190,7 @@ requires-dist = [
[[package]]
name = "agent-framework-azure-ai"
version = "1.0.0b251104"
version = "1.0.0b251028"
source = { editable = "packages/azure-ai" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -207,7 +207,7 @@ requires-dist = [
[[package]]
name = "agent-framework-copilotstudio"
version = "1.0.0b251104"
version = "1.0.0b251028"
source = { editable = "packages/copilotstudio" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -222,7 +222,7 @@ requires-dist = [
[[package]]
name = "agent-framework-core"
version = "1.0.0b251104"
version = "1.0.0b251028"
source = { editable = "packages/core" }
dependencies = [
{ name = "azure-identity", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -274,7 +274,7 @@ provides-extras = ["all"]
[[package]]
name = "agent-framework-devui"
version = "1.0.0b251104"
version = "1.0.0b251028"
source = { editable = "packages/devui" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -308,7 +308,7 @@ provides-extras = ["dev", "all"]
[[package]]
name = "agent-framework-lab"
version = "1.0.0b251104"
version = "1.0.0b251028"
source = { editable = "packages/lab" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -399,7 +399,7 @@ dev = [
[[package]]
name = "agent-framework-mem0"
version = "1.0.0b251104"
version = "1.0.0b251028"
source = { editable = "packages/mem0" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -414,7 +414,7 @@ requires-dist = [
[[package]]
name = "agent-framework-purview"
version = "1.0.0b251104"
version = "1.0.0b251028"
source = { editable = "packages/purview" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -431,7 +431,7 @@ requires-dist = [
[[package]]
name = "agent-framework-redis"
version = "1.0.0b251104"
version = "1.0.0b251028"
source = { editable = "packages/redis" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -2039,8 +2039,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/91/ae2eb6b7979e2f9b035a9f612cf70f1bf54aad4e1d125129bef1eae96f19/greenlet-3.2.4-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c2ca18a03a8cfb5b25bc1cbe20f3d9a4c80d8c3b13ba3df49ac3961af0b1018d", size = 584358, upload-time = "2025-08-07T13:18:23.708Z" },
{ url = "https://files.pythonhosted.org/packages/f7/85/433de0c9c0252b22b16d413c9407e6cb3b41df7389afc366ca204dbc1393/greenlet-3.2.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9fe0a28a7b952a21e2c062cd5756d34354117796c6d9215a87f55e38d15402c5", size = 1113550, upload-time = "2025-08-07T13:42:37.467Z" },
{ url = "https://files.pythonhosted.org/packages/a1/8d/88f3ebd2bc96bf7747093696f4335a0a8a4c5acfcf1b757717c0d2474ba3/greenlet-3.2.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8854167e06950ca75b898b104b63cc646573aa5fef1353d4508ecdd1ee76254f", size = 1137126, upload-time = "2025-08-07T13:18:20.239Z" },
{ url = "https://files.pythonhosted.org/packages/f1/29/74242b7d72385e29bcc5563fba67dad94943d7cd03552bac320d597f29b2/greenlet-3.2.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f47617f698838ba98f4ff4189aef02e7343952df3a615f847bb575c3feb177a7", size = 1544904, upload-time = "2025-11-04T12:42:04.763Z" },
{ url = "https://files.pythonhosted.org/packages/c8/e2/1572b8eeab0f77df5f6729d6ab6b141e4a84ee8eb9bc8c1e7918f94eda6d/greenlet-3.2.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af41be48a4f60429d5cad9d22175217805098a9ef7c40bfef44f7669fb9d74d8", size = 1611228, upload-time = "2025-11-04T12:42:08.423Z" },
{ url = "https://files.pythonhosted.org/packages/d6/6f/b60b0291d9623c496638c582297ead61f43c4b72eef5e9c926ef4565ec13/greenlet-3.2.4-cp310-cp310-win_amd64.whl", hash = "sha256:73f49b5368b5359d04e18d15828eecc1806033db5233397748f4ca813ff1056c", size = 298654, upload-time = "2025-08-07T13:50:00.469Z" },
{ url = "https://files.pythonhosted.org/packages/a4/de/f28ced0a67749cac23fecb02b694f6473f47686dff6afaa211d186e2ef9c/greenlet-3.2.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:96378df1de302bc38e99c3a9aa311967b7dc80ced1dcc6f171e99842987882a2", size = 272305, upload-time = "2025-08-07T13:15:41.288Z" },
{ url = "https://files.pythonhosted.org/packages/09/16/2c3792cba130000bf2a31c5272999113f4764fd9d874fb257ff588ac779a/greenlet-3.2.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1ee8fae0519a337f2329cb78bd7a8e128ec0f881073d43f023c7b8d4831d5246", size = 632472, upload-time = "2025-08-07T13:42:55.044Z" },
@@ -2050,8 +2048,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1f/8e/abdd3f14d735b2929290a018ecf133c901be4874b858dd1c604b9319f064/greenlet-3.2.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2523e5246274f54fdadbce8494458a2ebdcdbc7b802318466ac5606d3cded1f8", size = 587684, upload-time = "2025-08-07T13:18:25.164Z" },
{ url = "https://files.pythonhosted.org/packages/5d/65/deb2a69c3e5996439b0176f6651e0052542bb6c8f8ec2e3fba97c9768805/greenlet-3.2.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1987de92fec508535687fb807a5cea1560f6196285a4cde35c100b8cd632cc52", size = 1116647, upload-time = "2025-08-07T13:42:38.655Z" },
{ url = "https://files.pythonhosted.org/packages/3f/cc/b07000438a29ac5cfb2194bfc128151d52f333cee74dd7dfe3fb733fc16c/greenlet-3.2.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:55e9c5affaa6775e2c6b67659f3a71684de4c549b3dd9afca3bc773533d284fa", size = 1142073, upload-time = "2025-08-07T13:18:21.737Z" },
{ url = "https://files.pythonhosted.org/packages/67/24/28a5b2fa42d12b3d7e5614145f0bd89714c34c08be6aabe39c14dd52db34/greenlet-3.2.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c9c6de1940a7d828635fbd254d69db79e54619f165ee7ce32fda763a9cb6a58c", size = 1548385, upload-time = "2025-11-04T12:42:11.067Z" },
{ url = "https://files.pythonhosted.org/packages/6a/05/03f2f0bdd0b0ff9a4f7b99333d57b53a7709c27723ec8123056b084e69cd/greenlet-3.2.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03c5136e7be905045160b1b9fdca93dd6727b180feeafda6818e6496434ed8c5", size = 1613329, upload-time = "2025-11-04T12:42:12.928Z" },
{ url = "https://files.pythonhosted.org/packages/d8/0f/30aef242fcab550b0b3520b8e3561156857c94288f0332a79928c31a52cf/greenlet-3.2.4-cp311-cp311-win_amd64.whl", hash = "sha256:9c40adce87eaa9ddb593ccb0fa6a07caf34015a29bf8d344811665b573138db9", size = 299100, upload-time = "2025-08-07T13:44:12.287Z" },
{ url = "https://files.pythonhosted.org/packages/44/69/9b804adb5fd0671f367781560eb5eb586c4d495277c93bde4307b9e28068/greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd", size = 274079, upload-time = "2025-08-07T13:15:45.033Z" },
{ url = "https://files.pythonhosted.org/packages/46/e9/d2a80c99f19a153eff70bc451ab78615583b8dac0754cfb942223d2c1a0d/greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb", size = 640997, upload-time = "2025-08-07T13:42:56.234Z" },
@@ -2061,8 +2057,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/19/0d/6660d55f7373b2ff8152401a83e02084956da23ae58cddbfb0b330978fe9/greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0", size = 607586, upload-time = "2025-08-07T13:18:28.544Z" },
{ url = "https://files.pythonhosted.org/packages/8e/1a/c953fdedd22d81ee4629afbb38d2f9d71e37d23caace44775a3a969147d4/greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0", size = 1123281, upload-time = "2025-08-07T13:42:39.858Z" },
{ url = "https://files.pythonhosted.org/packages/3f/c7/12381b18e21aef2c6bd3a636da1088b888b97b7a0362fac2e4de92405f97/greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f", size = 1151142, upload-time = "2025-08-07T13:18:22.981Z" },
{ url = "https://files.pythonhosted.org/packages/27/45/80935968b53cfd3f33cf99ea5f08227f2646e044568c9b1555b58ffd61c2/greenlet-3.2.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee7a6ec486883397d70eec05059353b8e83eca9168b9f3f9a361971e77e0bcd0", size = 1564846, upload-time = "2025-11-04T12:42:15.191Z" },
{ url = "https://files.pythonhosted.org/packages/69/02/b7c30e5e04752cb4db6202a3858b149c0710e5453b71a3b2aec5d78a1aab/greenlet-3.2.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:326d234cbf337c9c3def0676412eb7040a35a768efc92504b947b3e9cfc7543d", size = 1633814, upload-time = "2025-11-04T12:42:17.175Z" },
{ url = "https://files.pythonhosted.org/packages/e9/08/b0814846b79399e585f974bbeebf5580fbe59e258ea7be64d9dfb253c84f/greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02", size = 299899, upload-time = "2025-08-07T13:38:53.448Z" },
{ url = "https://files.pythonhosted.org/packages/49/e8/58c7f85958bda41dafea50497cbd59738c5c43dbbea5ee83d651234398f4/greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", size = 272814, upload-time = "2025-08-07T13:15:50.011Z" },
{ url = "https://files.pythonhosted.org/packages/62/dd/b9f59862e9e257a16e4e610480cfffd29e3fae018a68c2332090b53aac3d/greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", size = 641073, upload-time = "2025-08-07T13:42:57.23Z" },
@@ -2072,8 +2066,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" },
{ url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" },
{ url = "https://files.pythonhosted.org/packages/a2/15/0d5e4e1a66fab130d98168fe984c509249c833c1a3c16806b90f253ce7b9/greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae", size = 1149210, upload-time = "2025-08-07T13:18:24.072Z" },
{ url = "https://files.pythonhosted.org/packages/1c/53/f9c440463b3057485b8594d7a638bed53ba531165ef0ca0e6c364b5cc807/greenlet-3.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e343822feb58ac4d0a1211bd9399de2b3a04963ddeec21530fc426cc121f19b", size = 1564759, upload-time = "2025-11-04T12:42:19.395Z" },
{ url = "https://files.pythonhosted.org/packages/47/e4/3bb4240abdd0a8d23f4f88adec746a3099f0d86bfedb623f063b2e3b4df0/greenlet-3.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca7f6f1f2649b89ce02f6f229d7c19f680a6238af656f61e0115b24857917929", size = 1634288, upload-time = "2025-11-04T12:42:21.174Z" },
{ url = "https://files.pythonhosted.org/packages/0b/55/2321e43595e6801e105fcfdee02b34c0f996eb71e6ddffca6b10b7e1d771/greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b", size = 299685, upload-time = "2025-08-07T13:24:38.824Z" },
{ url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586, upload-time = "2025-08-07T13:16:08.004Z" },
{ url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346, upload-time = "2025-08-07T13:42:59.944Z" },
@@ -2081,8 +2073,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659, upload-time = "2025-08-07T13:53:17.759Z" },
{ url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355, upload-time = "2025-08-07T13:18:34.517Z" },
{ url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512, upload-time = "2025-08-07T13:18:33.969Z" },
{ url = "https://files.pythonhosted.org/packages/23/6e/74407aed965a4ab6ddd93a7ded3180b730d281c77b765788419484cdfeef/greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269", size = 1612508, upload-time = "2025-11-04T12:42:23.427Z" },
{ url = "https://files.pythonhosted.org/packages/0d/da/343cd760ab2f92bac1845ca07ee3faea9fe52bee65f7bcb19f16ad7de08b/greenlet-3.2.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:015d48959d4add5d6c9f6c5210ee3803a830dce46356e3bc326d6776bde54681", size = 1680760, upload-time = "2025-11-04T12:42:25.341Z" },
{ url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425, upload-time = "2025-08-07T13:32:27.59Z" },
]