diff --git a/.github/workflows/dotnet-build-and-test.yml b/.github/workflows/dotnet-build-and-test.yml index 1a51f94119..b7a8ab453a 100644 --- a/.github/workflows/dotnet-build-and-test.yml +++ b/.github/workflows/dotnet-build-and-test.yml @@ -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!') \ No newline at end of file diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 69d3e03d31..c19b0b152d 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -21,6 +21,7 @@ + @@ -105,6 +106,7 @@ + diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index de8aef42fc..964030a36b 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -133,7 +133,7 @@ - + @@ -275,6 +275,7 @@ + @@ -298,8 +299,9 @@ - + + diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/AgentWebChat.AgentHost.csproj b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/AgentWebChat.AgentHost.csproj index 802c864c1f..7347a642ae 100644 --- a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/AgentWebChat.AgentHost.csproj +++ b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/AgentWebChat.AgentHost.csproj @@ -8,6 +8,7 @@ + diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs index 571b07b1d5..81430070eb 100644 --- a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs +++ b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs @@ -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 @@ 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("chat-model"); diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/Blob/AzureBlobAgentThreadStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/Blob/AzureBlobAgentThreadStore.cs new file mode 100644 index 0000000000..744f96616a --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/Blob/AzureBlobAgentThreadStore.cs @@ -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; + + /// + /// Initializes a new instance of the class using a . + /// + /// The blob container client to use for storage operations. + /// Optional configuration options. If , default options will be used. + /// is . + public AzureBlobAgentThreadStore(BlobContainerClient containerClient, AzureBlobAgentThreadStoreOptions? options = null) + { + this._containerClient = containerClient ?? throw new ArgumentNullException(nameof(containerClient)); + this._options = options ?? new AzureBlobAgentThreadStoreOptions(); + } + + /// + 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); + } + + /// + public override async ValueTask 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(); + } + } + + /// + /// Ensures that the blob container exists, creating it if necessary. + /// + 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; + } + } + + /// + /// Generates the blob name for a given agent and conversation. + /// + // 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}"; + } + + /// + /// Sanitizes a string to be safe for use in blob names. + /// + 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(); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/Blob/AzureBlobAgentThreadStoreOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/Blob/AzureBlobAgentThreadStoreOptions.cs new file mode 100644 index 0000000000..d7b5cf8dd4 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/Blob/AzureBlobAgentThreadStoreOptions.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Hosting.AzureStorage.Blob; + +/// +/// Configuration options for . +/// +public sealed class AzureBlobAgentThreadStoreOptions +{ + /// + /// Gets or sets a value indicating whether to automatically create the container if it doesn't exist. + /// + /// + /// Defaults to . + /// + public bool CreateContainerIfNotExists { get; set; } = true; + + /// + /// Gets or sets the blob name prefix to use for organizing threads. + /// + /// + /// This can be used to namespace threads within a container. + /// For example, setting this to "prod/" will store all blobs under a "prod/" prefix. + /// + public string? BlobNamePrefix { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/HostedAgentBuilderExtensions.Blob.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/HostedAgentBuilderExtensions.Blob.cs new file mode 100644 index 0000000000..0f9ca9b3bd --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/HostedAgentBuilderExtensions.Blob.cs @@ -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; + +/// +/// Provides extension methods for configuring . +/// +public static partial class HostedAgentBuilderExtensions +{ + /// + /// Configures the host agent builder to use an Azure Blob thread store for agent thread management. + /// + /// The host agent builder to configure with the Azure blob thread store. + /// The blob container client to use for storage operations. + /// Optional configuration options for the blob thread store. + /// The same instance, configured to use Azure blob thread store. + public static IHostedAgentBuilder WithAzureBlobThreadStore(this IHostedAgentBuilder builder, BlobContainerClient containerClient, AzureBlobAgentThreadStoreOptions? options = null) + => WithAzureBlobThreadStore(builder, sp => containerClient, options); + + /// + /// Configures the agent builder to use Azure Blob Storage as the thread store for agent state persistence. + /// + /// The agent builder to configure with Azure Blob thread store support. + /// A factory function that provides a configured BlobContainerClient instance for accessing the Azure Blob + /// container used to store thread data. + /// Optional settings for customizing the Azure Blob thread store behavior. If null, default options are used. + /// The same agent builder instance, configured to use Azure Blob Storage for thread persistence. + public static IHostedAgentBuilder WithAzureBlobThreadStore( + this IHostedAgentBuilder builder, + Func createBlobContainer, + AzureBlobAgentThreadStoreOptions? options = null) + => builder.WithThreadStore((sp, key) => + { + var blobContainer = createBlobContainer(sp); + return new AzureBlobAgentThreadStore(blobContainer, options); + }); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/Microsoft.Agents.AI.Hosting.AzureStorage.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/Microsoft.Agents.AI.Hosting.AzureStorage.csproj new file mode 100644 index 0000000000..7944a6fb13 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/Microsoft.Agents.AI.Hosting.AzureStorage.csproj @@ -0,0 +1,27 @@ + + + + $(ProjectsCoreTargetFrameworks) + $(ProjectsDebugCoreTargetFrameworks) + preview + true + + + Microsoft Agent Framework AzureStorage + Provides AzureStorage integration with Microsoft Agent Framework. + + + + + + + + + + + + + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests/AzureBlobAgentThreadStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests/AzureBlobAgentThreadStoreTests.cs new file mode 100644 index 0000000000..cda485e6dc --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests/AzureBlobAgentThreadStoreTests.cs @@ -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; + +/// +/// Tests for . +/// +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 chatMessages) + { + var texts = chatMessages.SelectMany(x => x.Contents).OfType().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); +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests/AzureStorageEmulatorAvailabilityHelper.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests/AzureStorageEmulatorAvailabilityHelper.cs new file mode 100644 index 0000000000..4ebc607cc9 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests/AzureStorageEmulatorAvailabilityHelper.cs @@ -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; + +/// +/// Helper class to check if Azurite (Azure Storage Emulator) is available and running. +/// +internal static class AzureStorageEmulatorAvailabilityHelper +{ + private const string AzuriteConnectionString = "UseDevelopmentStorage=true"; + + /// + /// Checks if Azurite is running and accessible. + /// + /// if Azurite is available; otherwise, . + public static async Task 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."); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests.csproj new file mode 100644 index 0000000000..6b76739854 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests.csproj @@ -0,0 +1,17 @@ + + + + $(ProjectsCoreTargetFrameworks) + $(ProjectsDebugCoreTargetFrameworks) + True + + + + + + + + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests/Mock/MockChatClient.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests/Mock/MockChatClient.cs new file mode 100644 index 0000000000..b99e0f5011 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests/Mock/MockChatClient.cs @@ -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 GetResponseAsync( + IEnumerable 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 GetStreamingResponseAsync( + IEnumerable 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(object? serviceKey = null) + { + return default; + } + + public void Dispose() + { + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests/TestRunner.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests/TestRunner.cs new file mode 100644 index 0000000000..8e13c20015 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests/TestRunner.cs @@ -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 RunAgentAsync(string userMessage) + => this.RunAgentAsync(new ChatMessage(ChatRole.User, userMessage)); + + public async Task 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 ResponseMessages => this.Response.Messages; + + public ChatClientAgentThread Thread { get; init; } + public IList ThreadMessages { get; init; } +}