From 41d441420e8bbc37e9d895f307b58ef5f885fdc7 Mon Sep 17 00:00:00 2001 From: Reuben Bond <203839+ReubenBond@users.noreply.github.com> Date: Tue, 22 Jul 2025 13:05:58 -0700 Subject: [PATCH] Initial draft of actor runtime abstractions (#197) * Initial draft of actor runtime abstractions --- .github/workflows/dotnet-build-and-test.yml | 10 +- dotnet/Directory.Packages.props | 48 +- dotnet/agent-framework-dotnet.slnx | 17 + dotnet/samples/Directory.Build.props | 15 - .../NewOpenAIAssistantChatClient.cs | 9 - .../GettingStarted/GettingStarted.csproj | 26 +- .../InMemoryActorStateStorageExample.cs | 127 +++ .../ActorFrameworkWebApplicationExtensions.cs | 172 ++++ .../AgentsJsonContext.cs | 21 + .../ChatClientAgentActor.cs | 124 +++ .../ChatClientAgentRunRequest.cs | 12 + .../HelloHttpApi.ApiService.csproj | 27 + .../HostApplicationBuilderAgentExtensions.cs | 32 + .../InvocationResponse.cs | 15 + .../HelloHttpApi.ApiService/Log.cs | 140 ++++ .../HelloHttpApi.ApiService/PingResponse.cs | 14 + .../PingResponseStatus.cs | 9 + .../HelloHttpApi.ApiService/Program.cs | 32 + .../Properties/launchSettings.json | 23 + .../Utilities/ChatClientConnectionInfo.cs | 80 ++ .../Utilities/ChatClientExtensions.cs | 144 ++++ .../appsettings.Development.json | 8 + .../HelloHttpApi.ApiService/appsettings.json | 9 + .../HelloHttpApi.AppHost.csproj | 24 + .../HelloHttpApi.AppHost/ModelExtensions.cs | 266 ++++++ .../HelloHttpApi.AppHost/Program.cs | 19 + .../Properties/launchSettings.json | 29 + .../appsettings.Development.json | 8 + .../HelloHttpApi.AppHost/appsettings.json | 9 + .../HelloHttpApi.ServiceDefaults.csproj | 22 + .../ServiceDefaultsExtensions.cs | 124 +++ .../HelloHttpApi.Web/AgentClient.cs | 215 +++++ .../HelloHttpApi.Web/Components/App.razor | 20 + .../Components/Layout/MainLayout.razor | 23 + .../Components/Layout/MainLayout.razor.css | 96 +++ .../Components/Layout/NavMenu.razor | 29 + .../Components/Layout/NavMenu.razor.css | 102 +++ .../Components/Pages/Counter.razor | 19 + .../Components/Pages/Error.razor | 38 + .../Components/Pages/Home.razor | 7 + .../Components/Pages/PirateTalk.razor | 208 +++++ .../HelloHttpApi.Web/Components/Routes.razor | 6 + .../Components/_Imports.razor | 11 + .../HelloHttpApi.Web/HelloHttpApi.Web.csproj | 14 + .../HelloHttpApi/HelloHttpApi.Web/Program.cs | 45 + .../Properties/launchSettings.json | 23 + .../appsettings.Development.json | 8 + .../HelloHttpApi.Web/appsettings.json | 9 + .../HelloHttpApi.Web/wwwroot/app.css | 56 ++ .../HelloHttpApi.Web/wwwroot/favicon.png | Bin 0 -> 1148 bytes .../ActorId.cs | 27 + .../ActorJsonContext.cs | 41 + .../ActorMessage.cs | 39 + .../ActorMessageType.cs | 23 + .../ActorMessageWriteOperation.cs | 14 + .../ActorReadOperation.cs | 31 + .../ActorReadOperationBatch.cs | 19 + .../ActorReadOperationType.cs | 23 + .../ActorReadResult.cs | 31 + .../ActorReadResultType.cs | 23 + .../ActorRequest.cs | 36 + .../ActorRequestMessage.cs | 39 + .../ActorRequestUpdate.cs | 26 + .../ActorResponse.cs | 36 + .../ActorResponseHandle.cs | 46 ++ .../ActorResponseMessage.cs | 39 + .../ActorStateReadOperation.cs | 14 + .../ActorStateWriteOperation.cs | 19 + .../ActorType.cs | 31 +- .../ActorWriteOperation.cs | 32 + .../ActorWriteOperationBatch.cs | 26 + .../ActorWriteOperationType.cs | 35 + .../GetValueOperation.cs | 23 + .../GetValueResult.cs | 24 + .../IActor.cs | 23 + .../IActorClient.cs | 30 + .../IActorRuntimeBuilder.cs | 18 + .../IActorRuntimeContext.cs | 51 ++ .../IActorStateStorage.cs | 32 + .../InMemoryActorStateStorage.cs | 384 +++++++++ .../JsonSerializerExtensions.cs | 33 + .../ListKeysOperation.cs | 30 + .../ListKeysResult.cs | 31 + ...ions.AI.Agents.Runtime.Abstractions.csproj | 6 + .../ReadResponse.cs | 26 + .../RemoveKeyOperation.cs | 23 + .../RequestStatus.cs | 35 + .../SendRequestOperation.cs | 23 + .../SetValueOperation.cs | 31 + .../UpdateRequestOperation.cs | 39 + .../WriteResponse.cs | 29 + .../ActivityExtensions.cs | 409 +++++++++ .../ActorRuntimeBuilder.cs | 96 +++ .../ActorRuntimeHostingExtensions.cs | 23 + .../ActorRuntimeJsonContext.cs | 16 + .../ActorRuntimeOpenTelemetryConsts.cs | 782 ++++++++++++++++++ .../InProcessActorContext.Log.cs | 131 +++ .../InProcessActorContext.cs | 426 ++++++++++ .../InProcessActorRuntime.cs | 211 +++++ .../JsonSerializerExtensions.cs | 33 + ...rosoft.Extensions.AI.Agents.Runtime.csproj | 40 + .../AgentsJsonContext.cs | 20 + .../ChatCompletion/ChatClientAgentThread.cs | 93 +++ .../JsonSerializerExtensions.cs | 33 + .../Microsoft.Extensions.AI.Agents.csproj | 1 + .../InMemoryActorStateStorageTests.cs | 514 ++++++++++++ .../JsonSerializationTests.cs | 335 ++++++++ .../ChatClientAgentThreadTests.cs | 255 ++++++ 108 files changed, 7445 insertions(+), 58 deletions(-) create mode 100644 dotnet/samples/GettingStarted/InMemoryActorStateStorageExample.cs create mode 100644 dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/ActorFrameworkWebApplicationExtensions.cs create mode 100644 dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/AgentsJsonContext.cs create mode 100644 dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/ChatClientAgentActor.cs create mode 100644 dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/ChatClientAgentRunRequest.cs create mode 100644 dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/HelloHttpApi.ApiService.csproj create mode 100644 dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/HostApplicationBuilderAgentExtensions.cs create mode 100644 dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/InvocationResponse.cs create mode 100644 dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/Log.cs create mode 100644 dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/PingResponse.cs create mode 100644 dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/PingResponseStatus.cs create mode 100644 dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/Program.cs create mode 100644 dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/Properties/launchSettings.json create mode 100644 dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/Utilities/ChatClientConnectionInfo.cs create mode 100644 dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/Utilities/ChatClientExtensions.cs create mode 100644 dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/appsettings.Development.json create mode 100644 dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/appsettings.json create mode 100644 dotnet/samples/HelloHttpApi/HelloHttpApi.AppHost/HelloHttpApi.AppHost.csproj create mode 100644 dotnet/samples/HelloHttpApi/HelloHttpApi.AppHost/ModelExtensions.cs create mode 100644 dotnet/samples/HelloHttpApi/HelloHttpApi.AppHost/Program.cs create mode 100644 dotnet/samples/HelloHttpApi/HelloHttpApi.AppHost/Properties/launchSettings.json create mode 100644 dotnet/samples/HelloHttpApi/HelloHttpApi.AppHost/appsettings.Development.json create mode 100644 dotnet/samples/HelloHttpApi/HelloHttpApi.AppHost/appsettings.json create mode 100644 dotnet/samples/HelloHttpApi/HelloHttpApi.ServiceDefaults/HelloHttpApi.ServiceDefaults.csproj create mode 100644 dotnet/samples/HelloHttpApi/HelloHttpApi.ServiceDefaults/ServiceDefaultsExtensions.cs create mode 100644 dotnet/samples/HelloHttpApi/HelloHttpApi.Web/AgentClient.cs create mode 100644 dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/App.razor create mode 100644 dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Layout/MainLayout.razor create mode 100644 dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Layout/MainLayout.razor.css create mode 100644 dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Layout/NavMenu.razor create mode 100644 dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Layout/NavMenu.razor.css create mode 100644 dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Pages/Counter.razor create mode 100644 dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Pages/Error.razor create mode 100644 dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Pages/Home.razor create mode 100644 dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Pages/PirateTalk.razor create mode 100644 dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Routes.razor create mode 100644 dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/_Imports.razor create mode 100644 dotnet/samples/HelloHttpApi/HelloHttpApi.Web/HelloHttpApi.Web.csproj create mode 100644 dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Program.cs create mode 100644 dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Properties/launchSettings.json create mode 100644 dotnet/samples/HelloHttpApi/HelloHttpApi.Web/appsettings.Development.json create mode 100644 dotnet/samples/HelloHttpApi/HelloHttpApi.Web/appsettings.json create mode 100644 dotnet/samples/HelloHttpApi/HelloHttpApi.Web/wwwroot/app.css create mode 100644 dotnet/samples/HelloHttpApi/HelloHttpApi.Web/wwwroot/favicon.png create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorJsonContext.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorMessage.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorMessageType.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorMessageWriteOperation.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorReadOperation.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorReadOperationBatch.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorReadOperationType.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorReadResult.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorReadResultType.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorRequest.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorRequestMessage.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorRequestUpdate.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorResponse.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorResponseHandle.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorResponseMessage.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorStateReadOperation.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorStateWriteOperation.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorWriteOperation.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorWriteOperationBatch.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorWriteOperationType.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/GetValueOperation.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/GetValueResult.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/IActor.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/IActorClient.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/IActorRuntimeBuilder.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/IActorRuntimeContext.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/IActorStateStorage.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/InMemoryActorStateStorage.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/JsonSerializerExtensions.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ListKeysOperation.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ListKeysResult.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ReadResponse.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/RemoveKeyOperation.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/RequestStatus.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/SendRequestOperation.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/SetValueOperation.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/UpdateRequestOperation.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/WriteResponse.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/ActivityExtensions.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/ActorRuntimeBuilder.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/ActorRuntimeHostingExtensions.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/ActorRuntimeJsonContext.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/ActorRuntimeOpenTelemetryConsts.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/InProcessActorContext.Log.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/InProcessActorContext.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/InProcessActorRuntime.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/JsonSerializerExtensions.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/Microsoft.Extensions.AI.Agents.Runtime.csproj create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents/AgentsJsonContext.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents/JsonSerializerExtensions.cs create mode 100644 dotnet/tests/Microsoft.Extensions.AI.Agents.Runtime.Abstractions.UnitTests/InMemoryActorStateStorageTests.cs create mode 100644 dotnet/tests/Microsoft.Extensions.AI.Agents.Runtime.Abstractions.UnitTests/JsonSerializationTests.cs diff --git a/.github/workflows/dotnet-build-and-test.yml b/.github/workflows/dotnet-build-and-test.yml index 0f76248556..4eb936dc24 100644 --- a/.github/workflows/dotnet-build-and-test.yml +++ b/.github/workflows/dotnet-build-and-test.yml @@ -63,7 +63,7 @@ jobs: run: | export SOLUTIONS=$(find ./dotnet/ -type f -name "*.slnx" | tr '\n' ' ') for solution in $SOLUTIONS; do - dotnet build $solution -f ${{ matrix.targetFramework }} -c ${{ matrix.configuration }} --warnaserror + dotnet build $solution -c ${{ matrix.configuration }} --warnaserror done - name: Package install check shell: bash @@ -78,7 +78,7 @@ jobs: dotnet pack $solution /property:TargetFrameworks=${{ matrix.targetFramework }} -c ${{ matrix.configuration }} --no-build --no-restore --output "$TEMP_DIR/artifacts" done - cd "$TEMP_DIR" + pushd "$TEMP_DIR" # Create a new console app to test the package installation dotnet new console -f ${{ matrix.targetFramework }} --name packcheck --output consoleapp @@ -91,13 +91,13 @@ jobs: dotnet nuget add source https://api.nuget.org/v3/index.json --name nuget.org --configfile consoleapp/nuget.config # Change to project directory to ensure local nuget.config is used - cd consoleapp + pushd consoleapp dotnet add packcheck.csproj package Microsoft.Extensions.AI.Agents --prerelease dotnet build -f ${{ matrix.targetFramework }} -c ${{ matrix.configuration }} packcheck.csproj # Clean up - cd .. - cd .. + popd + popd rm -rf "$TEMP_DIR" - name: Run Unit Tests Windows diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 5ce115c1bf..63038c7fbf 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -3,47 +3,59 @@ true + true + + + - + + + + + + + + + - - + + - - - - + + + + - - - + + + - + - - + + - + - - + + @@ -59,12 +71,12 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 0490ce2aa6..f26b08ba50 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -34,6 +34,20 @@ + + + + + + + + + + + + + + @@ -130,6 +144,9 @@ + + + diff --git a/dotnet/samples/Directory.Build.props b/dotnet/samples/Directory.Build.props index ae7ca1f99e..5b2fe94743 100644 --- a/dotnet/samples/Directory.Build.props +++ b/dotnet/samples/Directory.Build.props @@ -4,23 +4,8 @@ false - true false net472;net9.0 - - - - - - - - - - - - - - diff --git a/dotnet/samples/GettingStarted/External/MEAI.OpenAI/NewOpenAIAssistantChatClient.cs b/dotnet/samples/GettingStarted/External/MEAI.OpenAI/NewOpenAIAssistantChatClient.cs index 8c58871438..769eb74d74 100644 --- a/dotnet/samples/GettingStarted/External/MEAI.OpenAI/NewOpenAIAssistantChatClient.cs +++ b/dotnet/samples/GettingStarted/External/MEAI.OpenAI/NewOpenAIAssistantChatClient.cs @@ -6,24 +6,15 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Linq; using System.Reflection; using System.Runtime.CompilerServices; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; -using System.Threading; -using System.Threading.Tasks; using Microsoft.Shared.Diagnostics; using OpenAI; using OpenAI.Assistants; -using OpenAI.Audio; -using OpenAI.Chat; -using OpenAI.Embeddings; #pragma warning disable CA1031 // Do not catch general exception types #pragma warning disable SA1005 // Single line comments should begin with single space diff --git a/dotnet/samples/GettingStarted/GettingStarted.csproj b/dotnet/samples/GettingStarted/GettingStarted.csproj index 243ec485f0..44f0befb85 100644 --- a/dotnet/samples/GettingStarted/GettingStarted.csproj +++ b/dotnet/samples/GettingStarted/GettingStarted.csproj @@ -1,10 +1,5 @@  - - $(ProjectsTargetFrameworks) - $(ProjectsDebugTargetFrameworks) - - GettingStarted Library @@ -12,6 +7,13 @@ $(NoWarn);CA1707;CA1716;IDE0009;IDE1006;OPENAI001; enable true + true + true + + + + $(ProjectsTargetFrameworks) + $(ProjectsDebugTargetFrameworks) @@ -29,6 +31,12 @@ + + + + + + @@ -42,11 +50,11 @@ - - true - + + + + - Always diff --git a/dotnet/samples/GettingStarted/InMemoryActorStateStorageExample.cs b/dotnet/samples/GettingStarted/InMemoryActorStateStorageExample.cs new file mode 100644 index 0000000000..61c93a90b0 --- /dev/null +++ b/dotnet/samples/GettingStarted/InMemoryActorStateStorageExample.cs @@ -0,0 +1,127 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; + +namespace Microsoft.Extensions.AI.Agents.Runtime.Samples; + +/// +/// Example demonstrating how to use the InMemoryActorStateStorage. +/// +public static class InMemoryActorStateStorageExample +{ + /// + /// Demonstrates the basic usage of InMemoryActorStateStorage. + /// + /// A task representing the asynchronous operation. + public static async Task RunAsync() + { + // Create an in-memory actor state storage + var storage = new InMemoryActorStateStorage(); + + // Create an actor ID + var actorId = new ActorId("ExampleActor", "instance1"); + + Console.WriteLine("=== InMemoryActorStateStorage Example ==="); + Console.WriteLine(); + + // 1. Write some initial state + Console.WriteLine("1. Writing initial state..."); + var initialOperations = new List + { + new SetValueOperation("name", JsonSerializer.SerializeToElement("John Doe")), + new SetValueOperation("age", JsonSerializer.SerializeToElement(30)), + new SetValueOperation("city", JsonSerializer.SerializeToElement("Seattle")) + }; + + var writeResult = await storage.WriteStateAsync(actorId, initialOperations, "0").ConfigureAwait(false); + Console.WriteLine($" Write successful: {writeResult.Success}"); + Console.WriteLine($" New ETag: {writeResult.ETag}"); + Console.WriteLine($" Actor count: {storage.ActorCount}"); + Console.WriteLine($" Key count for actor: {storage.GetKeyCount(actorId)}"); + Console.WriteLine(); + + // 2. Read the state back + Console.WriteLine("2. Reading state back..."); + var readOperations = new List + { + new GetValueOperation("name"), + new GetValueOperation("age"), + new GetValueOperation("city"), + new GetValueOperation("nonexistent"), // This won't exist + new ListKeysOperation(continuationToken: null) // List all keys + }; + + var readResult = await storage.ReadStateAsync(actorId, readOperations).ConfigureAwait(false); + Console.WriteLine($" Current ETag: {readResult.ETag}"); + Console.WriteLine(" Results:"); + + foreach (var result in readResult.Results) + { + switch (result) + { + case GetValueResult getValue: + Console.WriteLine($" - Get value: {getValue.Value?.ToString() ?? "null"}"); + break; + + case ListKeysResult listKeys: + Console.WriteLine($" - Keys: [{string.Join(", ", listKeys.Keys)}]"); + break; + } + } + Console.WriteLine(); + + // 3. Update some values + Console.WriteLine("3. Updating state..."); + var updateOperations = new List + { + new SetValueOperation("age", JsonSerializer.SerializeToElement(31)), // Update age + new SetValueOperation("email", JsonSerializer.SerializeToElement("john@example.com")), // Add email + new RemoveKeyOperation("city") // Remove city + }; + + var updateResult = await storage.WriteStateAsync(actorId, updateOperations, writeResult.ETag).ConfigureAwait(false); + Console.WriteLine($" Update successful: {updateResult.Success}"); + Console.WriteLine($" New ETag: {updateResult.ETag}"); + Console.WriteLine($" Key count for actor: {storage.GetKeyCount(actorId)}"); + Console.WriteLine(); + + // 4. Try to update with wrong ETag (should fail) + Console.WriteLine("4. Trying to update with wrong ETag..."); + var failingOperations = new List + { + new SetValueOperation("shouldFail", JsonSerializer.SerializeToElement("this should fail")) + }; + + var failResult = await storage.WriteStateAsync(actorId, failingOperations, "wrong-etag").ConfigureAwait(false); + Console.WriteLine($" Update successful: {failResult.Success}"); + Console.WriteLine($" Current ETag: {failResult.ETag}"); + Console.WriteLine(); + + // 5. Read final state + Console.WriteLine("5. Reading final state..."); + var finalReadOperations = new List + { + new ListKeysOperation(continuationToken: null) + }; + + var finalReadResult = await storage.ReadStateAsync(actorId, finalReadOperations).ConfigureAwait(false); + var finalKeys = finalReadResult.Results.OfType().First(); + Console.WriteLine($" Final keys: [{string.Join(", ", finalKeys.Keys)}]"); + + // 6. Read each value + foreach (var key in finalKeys.Keys) + { + var valueReadOperations = new List + { + new GetValueOperation(key) + }; + + var valueReadResult = await storage.ReadStateAsync(actorId, valueReadOperations).ConfigureAwait(false); + var getValue = valueReadResult.Results.OfType().First(); + Console.WriteLine($" - {key}: {getValue.Value}"); + } + + Console.WriteLine(); + Console.WriteLine("=== Example Complete ==="); + } +} diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/ActorFrameworkWebApplicationExtensions.cs b/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/ActorFrameworkWebApplicationExtensions.cs new file mode 100644 index 0000000000..a84fd5edd0 --- /dev/null +++ b/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/ActorFrameworkWebApplicationExtensions.cs @@ -0,0 +1,172 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics; +using System.Text.Json; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.AI.Agents.Runtime; + +internal static class ActorFrameworkWebApplicationExtensions +{ + public static void MapAgents(this WebApplication app) + { + app.MapPost( + "/invocations/actor/{name}/{sessionId}/{requestId}", async ( + string name, + string sessionId, + string requestId, + [FromQuery] bool? stream, + [FromBody] JsonElement request, + HttpContext context, + ILogger logger, + IActorClient actorClient, + CancellationToken cancellationToken) => + { + var stopwatch = Stopwatch.StartNew(); + var streamRequested = stream == true; + + Log.ActorInvocationStarted(logger, name, sessionId, requestId, streamRequested); + Log.ActorRequestReceived(logger, requestId, request.GetRawText().Length, streamRequested); + + try + { + var responseHandle = await actorClient.SendRequestAsync(new ActorRequest(new ActorId(name, sessionId), requestId, method: "run", @params: request), cancellationToken); + Log.ActorRequestSent(logger, requestId, name, sessionId); + + if (!responseHandle.TryGetResponse(out var response)) + { + Log.ActorResponseHandleObtained(logger, requestId, false); + + if (stream == true) + { + Log.SseStreamingStarted(logger, requestId); + // If no response is available and streaming is requested, stream the response handle. + var result = await StreamResponse(context, responseHandle, cancellationToken); + Log.ActorInvocationCompleted(logger, name, sessionId, requestId, RequestStatus.Pending, stopwatch.ElapsedMilliseconds); + return result; + } + + // Otherwise, wait for a response to become available. + Log.WaitingForActorResponse(logger, requestId); + response = await responseHandle.GetResponseAsync(cancellationToken); + } + else + { + Log.ActorResponseHandleObtained(logger, requestId, true); + } + + Log.ActorResponseReceived(logger, requestId, response.Status); + var processResult = await ProcessResponse(name, sessionId, requestId, stream, context, responseHandle, response, cancellationToken); + Log.ActorInvocationCompleted(logger, name, sessionId, requestId, response.Status, stopwatch.ElapsedMilliseconds); + return processResult; + } + catch (Exception ex) + { + Log.ActorInvocationFailed(logger, ex, name, sessionId, requestId, stopwatch.ElapsedMilliseconds); + return Results.Problem("An error occurred processing the request.", statusCode: 500); + } + + static async Task StreamResponse(HttpContext context, ActorResponseHandle responseHandle, CancellationToken cancellationToken) + { + var requestId = context.Request.RouteValues["requestId"]?.ToString() ?? "unknown"; + var logger = context.RequestServices.GetRequiredService>(); + + Log.SseStreamingStarted(logger, requestId); + InitializeSseResponse(context); + await context.Response.Body.FlushAsync(cancellationToken); + + var updateCount = 0; + try + { + await foreach (var progress in responseHandle.WatchUpdatesAsync(cancellationToken)) + { + // Properly serialize the progress data as JSON and escape for SSE + var progressJson = JsonSerializer.Serialize(progress.Data, (JsonSerializerOptions?)null); + var eventData = JsonSerializer.Serialize(new { @event = JsonDocument.Parse(progressJson).RootElement }); + var eventText = $"data: {eventData}\n\n"; + + await context.Response.WriteAsync(eventText, cancellationToken); + await context.Response.Body.FlushAsync(cancellationToken); + + updateCount++; + Log.SseProgressUpdateSent(logger, requestId, updateCount); + } + + // Send completion marker + await context.Response.WriteAsync("data: completed\n\n", cancellationToken); + await context.Response.Body.FlushAsync(cancellationToken); + + Log.SseStreamingCompleted(logger, requestId, updateCount); + } + catch (OperationCanceledException) + { + Log.SseStreamingCancelled(logger, requestId); + } + catch (Exception ex) + { + Log.SseStreamingError(logger, ex, requestId); + } + + // TODO: refactor the enclosing method so we don't need to return a result here. + return Results.Empty; + } + + static void InitializeSseResponse(HttpContext context) + { + context.Response.Headers.ContentType = "text/event-stream"; + context.Response.Headers.CacheControl = "no-cache,no-store"; + context.Response.Headers.Connection = "keep-alive"; + + // Make sure we disable all response buffering for SSE. + context.Response.Headers.ContentEncoding = "identity"; + context.Features.GetRequiredFeature().DisableBuffering(); + } + + static async Task ProcessResponse( + string name, + string sessionId, + string requestId, + bool? stream, + HttpContext context, + ActorResponseHandle responseHandle, + ActorResponse response, + CancellationToken cancellationToken) + { + var logger = context.RequestServices.GetRequiredService>(); + var isStreaming = stream != false && response.Status == RequestStatus.Pending; + + Log.ProcessingActorResponse(logger, requestId, response.Status, isStreaming); + + var result = response.Status switch + { + // If the response is pending & streaming is disabled, return a 202 Accepted with the messageId. + RequestStatus.Pending when stream == false => Results.Accepted($"/invocations/actor/{name}/{sessionId}/{requestId}"), + + // If streaming is not explicitly disabled, stream the response back. + RequestStatus.Pending => await StreamResponse(context, responseHandle, cancellationToken), + RequestStatus.Completed => Results.Ok(response.Data), + + // If the response failed, we can return a 500 Internal Server Error. + RequestStatus.Failed => Results.Problem("The invocation failed.", statusCode: 500), + RequestStatus.NotFound => Results.NotFound(new { message = "Not found." }),// If the actor is not found, we can return a 404 Not Found. + _ => throw new NotSupportedException($"Unsupported request status: {response.Status}"), + }; + + var responseType = response.Status switch + { + RequestStatus.Pending when stream == false => "Accepted", + RequestStatus.Pending => "Streaming", + RequestStatus.Completed => "Ok", + RequestStatus.Failed => "Problem", + RequestStatus.NotFound => "NotFound", + _ => "Unknown" + }; + + Log.ActorResponseProcessed(logger, requestId, responseType); + return result; + } + }) + .WithName("Invocations"); + } +} diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/AgentsJsonContext.cs b/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/AgentsJsonContext.cs new file mode 100644 index 0000000000..be8f843439 --- /dev/null +++ b/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/AgentsJsonContext.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Text.Json.Serialization; +using HelloHttpApi.ApiService; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.AI.Agents; +/// +/// Source-generated JSON type information for use by all Agents implementations. +/// +[JsonSourceGenerationOptions( + JsonSerializerDefaults.Web, + UseStringEnumConverter = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = false)] +[JsonSerializable(typeof(ChatMessage))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(ChatClientAgentThread))] +[JsonSerializable(typeof(ChatClientAgentRunRequest))] +[JsonSerializable(typeof(AgentRunResponseUpdate))] +internal sealed partial class AgentsJsonContext : JsonSerializerContext; diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/ChatClientAgentActor.cs b/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/ChatClientAgentActor.cs new file mode 100644 index 0000000000..221f287f3c --- /dev/null +++ b/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/ChatClientAgentActor.cs @@ -0,0 +1,124 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using HelloHttpApi.ApiService; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.AI.Agents; +using Microsoft.Extensions.AI.Agents.Runtime; + +internal sealed class ChatClientAgentActor(ChatClientAgent agent, JsonSerializerOptions jsonSerializerOptions, IActorRuntimeContext context, ILogger logger) : IActor +{ + private string? _etag; + private ChatClientAgentThread? _thread; + + public ValueTask DisposeAsync() => default; + + public async ValueTask RunAsync(CancellationToken cancellationToken) + { + Log.ActorStarted(logger, context.ActorId.ToString(), agent.Name ?? "Unknown"); + await Task.Yield(); + + // Restore thread state + var response = await context.ReadAsync( + new ActorReadOperationBatch([new GetValueOperation("thread")]), + cancellationToken).ConfigureAwait(false); + + this._etag = response.ETag; + if (response.Results[0] is GetValueResult threadResult) + { + if (threadResult.Value is { } threadJson) + { + // Deserialize the thread state if it exist + this._thread = threadJson.Deserialize( + (JsonTypeInfo)jsonSerializerOptions.GetTypeInfo(typeof(ChatClientAgentThread))); + } + } + + this._thread ??= (ChatClientAgentThread)agent.GetNewThread(); + Log.ThreadStateRestored(logger, context.ActorId.ToString(), response.Results[0] is GetValueResult { Value: not null }); + + while (!cancellationToken.IsCancellationRequested) + { + try + { + await foreach (var message in context.WatchMessagesAsync(cancellationToken).ConfigureAwait(false)) + { + switch (message.Type) + { + case ActorMessageType.Request: + await this.HandleAgentRequestAsync((ActorRequestMessage)message, cancellationToken).ConfigureAwait(false); + break; + case ActorMessageType.Response: + // Handle response messages if needed + break; + default: + Log.UnknownMessageType(logger, message.Type.ToString(), context.ActorId.ToString()); + break; + } + } + } + catch (Exception ex) + { + Log.ErrorProcessingMessages(logger, ex, context.ActorId.ToString()); + } + } + } + + private async Task HandleAgentRequestAsync(ActorRequestMessage message, CancellationToken cancellationToken) + { + var requestId = message.MessageId; + Debug.Assert(this._thread is not null); + Debug.Assert(this._etag is not null); + + // Parse the request to get the agent run parameters + List? messages; + if (message.Params is { } payload) + { + var arg = payload.Deserialize( + (JsonTypeInfo)jsonSerializerOptions.GetTypeInfo(typeof(ChatClientAgentRunRequest))); + messages = arg?.Messages; + } + + messages ??= []; + + Log.ProcessingAgentRequest(logger, requestId, context.ActorId.ToString(), messages.Count); + try + { + var typeInfo = (JsonTypeInfo)jsonSerializerOptions.GetTypeInfo(typeof(AgentRunResponseUpdate)); + var i = 0; + var updates = new List(); + await foreach (var update in agent.RunStreamingAsync(messages, this._thread, cancellationToken: cancellationToken).ConfigureAwait(false)) + { + var updateJson = JsonSerializer.SerializeToElement(update, typeInfo); + context.OnProgressUpdate(requestId, i++, updateJson); + updates.Add(update); + Log.AgentStreamingUpdate(logger, requestId, i); + } + + var serializedRunResponse = JsonSerializer.SerializeToElement( + updates.ToAgentRunResponse(), + (JsonTypeInfo)jsonSerializerOptions.GetTypeInfo(typeof(AgentRunResponse))); + var writeResponse = await context.WriteAsync( + new(this._etag, [new UpdateRequestOperation(requestId, RequestStatus.Completed, serializedRunResponse)]), cancellationToken) + .ConfigureAwait(false); + if (!writeResponse.Success) + { + Log.WriteOperationFailed(logger, context.ActorId.ToString(), requestId); + } + else + { + Log.AgentRequestCompleted(logger, requestId, updates.Count); + } + + this._etag = writeResponse.ETag; + } + catch (Exception exception) + { + Log.AgentRequestFailed(logger, exception, requestId, context.ActorId.ToString()); + + // TODO: Retry later? + } + } +} diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/ChatClientAgentRunRequest.cs b/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/ChatClientAgentRunRequest.cs new file mode 100644 index 0000000000..c367ad0bb6 --- /dev/null +++ b/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/ChatClientAgentRunRequest.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; +using Microsoft.Extensions.AI; + +namespace HelloHttpApi.ApiService; + +public sealed class ChatClientAgentRunRequest +{ + [JsonPropertyName("messages")] + public List Messages { get; set; } = []; +} diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/HelloHttpApi.ApiService.csproj b/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/HelloHttpApi.ApiService.csproj new file mode 100644 index 0000000000..2d8579447a --- /dev/null +++ b/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/HelloHttpApi.ApiService.csproj @@ -0,0 +1,27 @@ + + + + net9.0 + enable + enable + + + + + + + + + + + + + + + + + + + + + diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/HostApplicationBuilderAgentExtensions.cs b/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/HostApplicationBuilderAgentExtensions.cs new file mode 100644 index 0000000000..ef09f456f5 --- /dev/null +++ b/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/HostApplicationBuilderAgentExtensions.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.AI.Agents; +using Microsoft.Extensions.AI.Agents.Runtime; + +namespace HelloHttpApi.ApiService; + +public static class HostApplicationBuilderAgentExtensions +{ + public static IHostApplicationBuilder AddChatClientAgent(this IHostApplicationBuilder builder, string name, string instructions, string? chatClientKey = null) + { + var agentKey = $"agent:{name}"; + builder.Services.AddKeyedSingleton(agentKey, (sp, key) => + { + var chatClient = chatClientKey is null ? sp.GetRequiredService() : sp.GetRequiredKeyedService(chatClientKey); + return new ChatClientAgent(chatClient, instructions, name); + }); + var actorBuilder = builder.AddActorRuntime(); + + actorBuilder.AddActorType( + new ActorType(agentKey), + (sp, ctx) => new ChatClientAgentActor( + sp.GetRequiredKeyedService(agentKey), + sp.GetService() ?? JsonSerializerOptions.Web, + ctx, + sp.GetRequiredService>())); + + return builder; + } +} diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/InvocationResponse.cs b/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/InvocationResponse.cs new file mode 100644 index 0000000000..b2d4820bff --- /dev/null +++ b/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/InvocationResponse.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace HelloHttpApi.ApiService; + +public class InvocationResponse +{ + [JsonPropertyName("response")] + public JsonElement Response { get; set; } + + [JsonPropertyName("status")] + public string? Status { get; set; } = "success"; +} diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/Log.cs b/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/Log.cs new file mode 100644 index 0000000000..9255b38cba --- /dev/null +++ b/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/Log.cs @@ -0,0 +1,140 @@ +// Copyright (c) Microsoft. All rights reserved. + +using HelloHttpApi.ApiService; +using Microsoft.Extensions.AI.Agents.Runtime; +/// +/// High-performance logging messages using LoggerMessage source generator. +/// +internal static partial class Log +{ + // API endpoint logging + [LoggerMessage( + Level = LogLevel.Information, + Message = "Actor invocation started: Name={ActorName}, SessionId={SessionId}, RequestId={RequestId}, Stream={StreamRequested}")] + public static partial void ActorInvocationStarted(ILogger logger, string actorName, string sessionId, string requestId, bool streamRequested); + + [LoggerMessage( + Level = LogLevel.Information, + Message = "Actor invocation completed: Name={ActorName}, SessionId={SessionId}, RequestId={RequestId}, Status={Status}, Duration={DurationMs}ms")] + public static partial void ActorInvocationCompleted(ILogger logger, string actorName, string sessionId, string requestId, RequestStatus status, long durationMs); + + [LoggerMessage( + Level = LogLevel.Warning, + Message = "Actor invocation failed: Name={ActorName}, SessionId={SessionId}, RequestId={RequestId}, Duration={DurationMs}ms")] + public static partial void ActorInvocationFailed(ILogger logger, Exception exception, string actorName, string sessionId, string requestId, long durationMs); + + // SSE streaming logging + [LoggerMessage( + Level = LogLevel.Debug, + Message = "SSE streaming started for request: {RequestId}")] + public static partial void SseStreamingStarted(ILogger logger, string requestId); + + [LoggerMessage( + Level = LogLevel.Debug, + Message = "SSE progress update sent: RequestId={RequestId}, UpdateCount={UpdateCount}")] + public static partial void SseProgressUpdateSent(ILogger logger, string requestId, int updateCount); + + [LoggerMessage( + Level = LogLevel.Debug, + Message = "SSE streaming completed: RequestId={RequestId}, TotalUpdates={TotalUpdates}")] + public static partial void SseStreamingCompleted(ILogger logger, string requestId, int totalUpdates); + + [LoggerMessage( + Level = LogLevel.Warning, + Message = "SSE streaming cancelled: RequestId={RequestId}")] + public static partial void SseStreamingCancelled(ILogger logger, string requestId); + + [LoggerMessage( + Level = LogLevel.Error, + Message = "SSE streaming error: RequestId={RequestId}")] + public static partial void SseStreamingError(ILogger logger, Exception exception, string requestId); + + // Response processing logging + [LoggerMessage( + Level = LogLevel.Debug, + Message = "Processing actor response: RequestId={RequestId}, Status={Status}, IsStreaming={IsStreaming}")] + public static partial void ProcessingActorResponse(ILogger logger, string requestId, RequestStatus status, bool isStreaming); + + [LoggerMessage( + Level = LogLevel.Information, + Message = "Actor response processed successfully: RequestId={RequestId}, ResponseType={ResponseType}")] + public static partial void ActorResponseProcessed(ILogger logger, string requestId, string responseType); + + // Ping endpoint logging + [LoggerMessage( + Level = LogLevel.Debug, + Message = "Ping endpoint accessed: Status={Status}, TimeOfLastUpdate={TimeOfLastUpdate}")] + public static partial void PingEndpointAccessed(ILogger logger, PingResponseStatus status, long timeOfLastUpdate); + + // Request/Response logging + [LoggerMessage( + Level = LogLevel.Debug, + Message = "Actor request received: RequestId={RequestId}, PayloadSize={PayloadSize} bytes, Stream={StreamRequested}")] + public static partial void ActorRequestReceived(ILogger logger, string requestId, int payloadSize, bool streamRequested); + + [LoggerMessage( + Level = LogLevel.Debug, + Message = "Actor request sent to runtime: RequestId={RequestId}, ActorName={ActorName}, SessionId={SessionId}")] + public static partial void ActorRequestSent(ILogger logger, string requestId, string actorName, string sessionId); + + [LoggerMessage( + Level = LogLevel.Debug, + Message = "Actor response handle obtained: RequestId={RequestId}, HasImmediateResponse={HasImmediateResponse}")] + public static partial void ActorResponseHandleObtained(ILogger logger, string requestId, bool hasImmediateResponse); + + [LoggerMessage( + Level = LogLevel.Information, + Message = "Waiting for actor response: RequestId={RequestId}")] + public static partial void WaitingForActorResponse(ILogger logger, string requestId); + + [LoggerMessage( + Level = LogLevel.Debug, + Message = "Actor response received: RequestId={RequestId}, Status={Status}")] + public static partial void ActorResponseReceived(ILogger logger, string requestId, RequestStatus status); + + // ChatClientAgentActor logging + [LoggerMessage( + Level = LogLevel.Information, + Message = "Actor started: ActorId={ActorId}, AgentName={AgentName}")] + public static partial void ActorStarted(ILogger logger, string actorId, string agentName); + + [LoggerMessage( + Level = LogLevel.Debug, + Message = "Thread state restored: ActorId={ActorId}, HasExistingThread={HasExistingThread}")] + public static partial void ThreadStateRestored(ILogger logger, string actorId, bool hasExistingThread); + + [LoggerMessage( + Level = LogLevel.Information, + Message = "Processing agent request: RequestId={RequestId}, ActorId={ActorId}, MessageCount={MessageCount}")] + public static partial void ProcessingAgentRequest(ILogger logger, string requestId, string actorId, int messageCount); + + [LoggerMessage( + Level = LogLevel.Debug, + Message = "Agent streaming update: RequestId={RequestId}, UpdateNumber={UpdateNumber}")] + public static partial void AgentStreamingUpdate(ILogger logger, string requestId, int updateNumber); + + [LoggerMessage( + Level = LogLevel.Information, + Message = "Agent request completed: RequestId={RequestId}, TotalUpdates={TotalUpdates}")] + public static partial void AgentRequestCompleted(ILogger logger, string requestId, int totalUpdates); + + [LoggerMessage( + Level = LogLevel.Error, + Message = "Agent request failed: RequestId={RequestId}, ActorId={ActorId}")] + public static partial void AgentRequestFailed(ILogger logger, Exception exception, string requestId, string actorId); + + [LoggerMessage( + Level = LogLevel.Warning, + Message = "Unknown message type received: MessageType={MessageType}, ActorId={ActorId}")] + public static partial void UnknownMessageType(ILogger logger, string messageType, string actorId); + + [LoggerMessage( + Level = LogLevel.Warning, + Message = "Error processing messages: ActorId={ActorId}")] + public static partial void ErrorProcessingMessages(ILogger logger, Exception exception, string actorId); + + [LoggerMessage( + Level = LogLevel.Error, + Message = "Write operation failed: ActorId={ActorId}, RequestId={RequestId}")] + public static partial void WriteOperationFailed(ILogger logger, string actorId, string requestId); +} diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/PingResponse.cs b/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/PingResponse.cs new file mode 100644 index 0000000000..7ca5e90b0e --- /dev/null +++ b/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/PingResponse.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace HelloHttpApi.ApiService; + +public class PingResponse(PingResponseStatus status, long timeOfLastUpdate) +{ + [JsonPropertyName("status")] + public PingResponseStatus Status { get; } = status; + + [JsonPropertyName("time_of_last_update")] + public long TimeOfLastUpdate { get; } = timeOfLastUpdate; +} diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/PingResponseStatus.cs b/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/PingResponseStatus.cs new file mode 100644 index 0000000000..8a0c45d22b --- /dev/null +++ b/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/PingResponseStatus.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace HelloHttpApi.ApiService; + +public enum PingResponseStatus +{ + Healthy, + HealthyBusy, +} diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/Program.cs b/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/Program.cs new file mode 100644 index 0000000000..e10c4b9477 --- /dev/null +++ b/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/Program.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft. All rights reserved. + +using HelloHttpApi.ApiService; +using HelloHttpApi.ApiService.Utilities; + +var builder = WebApplication.CreateBuilder(args); + +// Add service defaults & Aspire client integrations. +builder.AddServiceDefaults(); + +// Add services to the container. +builder.Services.AddProblemDetails(); + +// Configure the chat model and our agent. +builder.AddKeyedChatClient("chat-model"); + +builder.AddChatClientAgent( + name: "pirate", + instructions: "You are a pirate. Speak like a pirate.", + chatClientKey: "chat-model"); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +app.UseExceptionHandler(); + +// Map the agents HTTP endpoints +app.MapAgents(); + +app.MapDefaultEndpoints(); + +app.Run(); diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/Properties/launchSettings.json b/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/Properties/launchSettings.json new file mode 100644 index 0000000000..b14d22e4fe --- /dev/null +++ b/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5390", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7373;http://localhost:5390", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/Utilities/ChatClientConnectionInfo.cs b/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/Utilities/ChatClientConnectionInfo.cs new file mode 100644 index 0000000000..5cb808b3e7 --- /dev/null +++ b/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/Utilities/ChatClientConnectionInfo.cs @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Data.Common; +using System.Diagnostics.CodeAnalysis; + +namespace HelloHttpApi.ApiService.Utilities; + +public class ChatClientConnectionInfo +{ + public Uri? Endpoint { get; init; } + public required string SelectedModel { get; init; } + + public ClientChatProvider Provider { get; init; } + public string? AccessKey { get; init; } + + // Example connection string: + // Endpoint=https://localhost:4523;Model=phi3.5;AccessKey=1234;Provider=ollama; + public static bool TryParse(string? connectionString, [NotNullWhen(true)] out ChatClientConnectionInfo? settings) + { + if (string.IsNullOrEmpty(connectionString)) + { + settings = null; + return false; + } + + var connectionBuilder = new DbConnectionStringBuilder + { + ConnectionString = connectionString + }; + + Uri? endpoint = null; + if (connectionBuilder.ContainsKey("Endpoint") && Uri.TryCreate(connectionBuilder["Endpoint"].ToString(), UriKind.Absolute, out endpoint)) + { + } + + string? model = null; + if (connectionBuilder.ContainsKey("Model")) + { + model = (string)connectionBuilder["Model"]; + } + + string? accessKey = null; + if (connectionBuilder.ContainsKey("AccessKey")) + { + accessKey = (string)connectionBuilder["AccessKey"]; + } + + var provider = ClientChatProvider.Unknown; + if (connectionBuilder.ContainsKey("Provider")) + { + var providerValue = (string)connectionBuilder["Provider"]; + Enum.TryParse(providerValue, ignoreCase: true, out provider); + } + + if (endpoint is null && provider != ClientChatProvider.OpenAI || model is null || provider == ClientChatProvider.Unknown) + { + settings = null; + return false; + } + + settings = new ChatClientConnectionInfo + { + Endpoint = endpoint, + SelectedModel = model, + AccessKey = accessKey, + Provider = provider + }; + + return true; + } +} + +public enum ClientChatProvider +{ + Unknown, + Ollama, + OpenAI, + AzureOpenAI, + AzureAIInference, +} diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/Utilities/ChatClientExtensions.cs b/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/Utilities/ChatClientExtensions.cs new file mode 100644 index 0000000000..ca050cced8 --- /dev/null +++ b/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/Utilities/ChatClientExtensions.cs @@ -0,0 +1,144 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Azure; +using Azure.AI.Inference; +using HelloHttpApi.ApiService.Utilities; +using Microsoft.Extensions.AI; +using OllamaSharp; + +namespace HelloHttpApi.ApiService.Utilities; + +public static class ChatClientExtensions +{ + public static ChatClientBuilder AddChatClient(this IHostApplicationBuilder builder, string connectionName) + { + var cs = builder.Configuration.GetConnectionString(connectionName); + + if (!ChatClientConnectionInfo.TryParse(cs, out var connectionInfo)) + { + throw new InvalidOperationException($"Invalid connection string: {cs}. Expected format: 'Endpoint=endpoint;AccessKey=your_access_key;Model=model_name;Provider=ollama/openai/azureopenai;'."); + } + + var chatClientBuilder = connectionInfo.Provider switch + { + ClientChatProvider.Ollama => builder.AddOllamaClient(connectionName, connectionInfo), + ClientChatProvider.OpenAI => builder.AddOpenAIClient(connectionName, connectionInfo), + ClientChatProvider.AzureOpenAI => builder.AddAzureOpenAIClient(connectionName).AddChatClient(connectionInfo.SelectedModel), + ClientChatProvider.AzureAIInference => builder.AddAzureInferenceClient(connectionName, connectionInfo), + _ => throw new NotSupportedException($"Unsupported provider: {connectionInfo.Provider}") + }; + + // Add OpenTelemetry tracing for the ChatClient activity source + chatClientBuilder.UseOpenTelemetry().UseLogging(); + + builder.Services.AddOpenTelemetry().WithTracing(t => t.AddSource("Experimental.Microsoft.Extensions.AI")); + + return chatClientBuilder; + } + + private static ChatClientBuilder AddOpenAIClient(this IHostApplicationBuilder builder, string connectionName, ChatClientConnectionInfo connectionInfo) + { + return builder.AddOpenAIClient(connectionName, settings => + { + settings.Endpoint = connectionInfo.Endpoint; + settings.Key = connectionInfo.AccessKey; + }) + .AddChatClient(connectionInfo.SelectedModel); + } + + private static ChatClientBuilder AddAzureInferenceClient(this IHostApplicationBuilder builder, string connectionName, ChatClientConnectionInfo connectionInfo) + { + return builder.Services.AddChatClient(sp => + { + var credential = new AzureKeyCredential(connectionInfo.AccessKey!); + + var client = new ChatCompletionsClient(connectionInfo.Endpoint, credential, new AzureAIInferenceClientOptions()); + + return client.AsIChatClient(connectionInfo.SelectedModel); + }); + } + + private static ChatClientBuilder AddOllamaClient(this IHostApplicationBuilder builder, string connectionName, ChatClientConnectionInfo connectionInfo) + { + var httpKey = $"{connectionName}_http"; + + builder.Services.AddHttpClient(httpKey, c => + { + c.BaseAddress = connectionInfo.Endpoint; + }); + + return builder.Services.AddChatClient(sp => + { + // Create a client for the Ollama API using the http client factory + var client = sp.GetRequiredService().CreateClient(httpKey); + + return new OllamaApiClient(client, connectionInfo.SelectedModel); + }); + } + + public static ChatClientBuilder AddKeyedChatClient(this IHostApplicationBuilder builder, string connectionName) + { + var cs = builder.Configuration.GetConnectionString(connectionName); + + if (!ChatClientConnectionInfo.TryParse(cs, out var connectionInfo)) + { + throw new InvalidOperationException($"Invalid connection string: {cs}. Expected format: 'Endpoint=endpoint;AccessKey=your_access_key;Model=model_name;Provider=ollama/openai/azureopenai;'."); + } + + var chatClientBuilder = connectionInfo.Provider switch + { + ClientChatProvider.Ollama => builder.AddKeyedOllamaClient(connectionName, connectionInfo), + ClientChatProvider.OpenAI => builder.AddKeyedOpenAIClient(connectionName, connectionInfo), + ClientChatProvider.AzureOpenAI => builder.AddKeyedAzureOpenAIClient(connectionName).AddKeyedChatClient(connectionName, connectionInfo.SelectedModel), + ClientChatProvider.AzureAIInference => builder.AddKeyedAzureInferenceClient(connectionName, connectionInfo), + _ => throw new NotSupportedException($"Unsupported provider: {connectionInfo.Provider}") + }; + + // Add OpenTelemetry tracing for the ChatClient activity source + chatClientBuilder.UseOpenTelemetry().UseLogging(); + + builder.Services.AddOpenTelemetry().WithTracing(t => t.AddSource("Experimental.Microsoft.Extensions.AI")); + + return chatClientBuilder; + } + + private static ChatClientBuilder AddKeyedOpenAIClient(this IHostApplicationBuilder builder, string connectionName, ChatClientConnectionInfo connectionInfo) + { + return builder.AddKeyedOpenAIClient(connectionName, settings => + { + settings.Endpoint = connectionInfo.Endpoint; + settings.Key = connectionInfo.AccessKey; + }) + .AddKeyedChatClient(connectionName, connectionInfo.SelectedModel); + } + + private static ChatClientBuilder AddKeyedAzureInferenceClient(this IHostApplicationBuilder builder, string connectionName, ChatClientConnectionInfo connectionInfo) + { + return builder.Services.AddKeyedChatClient(connectionName, sp => + { + var credential = new AzureKeyCredential(connectionInfo.AccessKey!); + + var client = new ChatCompletionsClient(connectionInfo.Endpoint, credential, new AzureAIInferenceClientOptions()); + + return client.AsIChatClient(connectionInfo.SelectedModel); + }); + } + + private static ChatClientBuilder AddKeyedOllamaClient(this IHostApplicationBuilder builder, string connectionName, ChatClientConnectionInfo connectionInfo) + { + var httpKey = $"{connectionName}_http"; + + builder.Services.AddHttpClient(httpKey, c => + { + c.BaseAddress = connectionInfo.Endpoint; + }); + + return builder.Services.AddKeyedChatClient(connectionName, sp => + { + // Create a client for the Ollama API using the http client factory + var client = sp.GetRequiredService().CreateClient(httpKey); + + return new OllamaApiClient(client, connectionInfo.SelectedModel); + }); + } +} diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/appsettings.Development.json b/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/appsettings.Development.json new file mode 100644 index 0000000000..527a02bb3c --- /dev/null +++ b/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Trace", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/appsettings.json b/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/appsettings.json new file mode 100644 index 0000000000..f41d5acb6b --- /dev/null +++ b/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Trace", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.AppHost/HelloHttpApi.AppHost.csproj b/dotnet/samples/HelloHttpApi/HelloHttpApi.AppHost/HelloHttpApi.AppHost.csproj new file mode 100644 index 0000000000..c0b12bead9 --- /dev/null +++ b/dotnet/samples/HelloHttpApi/HelloHttpApi.AppHost/HelloHttpApi.AppHost.csproj @@ -0,0 +1,24 @@ + + + + + + Exe + net9.0 + enable + enable + true + 2969a84d-8ee6-4304-8737-6e469a315aa8 + + + + + + + + + + + + + diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.AppHost/ModelExtensions.cs b/dotnet/samples/HelloHttpApi/HelloHttpApi.AppHost/ModelExtensions.cs new file mode 100644 index 0000000000..55bcd54ea0 --- /dev/null +++ b/dotnet/samples/HelloHttpApi/HelloHttpApi.AppHost/ModelExtensions.cs @@ -0,0 +1,266 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace HelloHttpApi.AppHost; + +public static class ModelExtensions +{ + public static IResourceBuilder AddAIModel(this IDistributedApplicationBuilder builder, string name) + { + var model = new AIModel(name); + return builder.CreateResourceBuilder(model); + } + + public static IResourceBuilder RunAsOpenAI(this IResourceBuilder builder, string modelName, IResourceBuilder apiKey) + { + if (builder.ApplicationBuilder.ExecutionContext.IsRunMode) + { + return builder.AsOpenAI(modelName, apiKey); + } + + return builder; + } + + public static IResourceBuilder PublishAsOpenAI(this IResourceBuilder builder, string modelName, IResourceBuilder apiKey) + { + if (builder.ApplicationBuilder.ExecutionContext.IsPublishMode) + { + return builder.AsOpenAI(modelName, apiKey); + } + + return builder; + } + + public static IResourceBuilder RunAsAzureOpenAI(this IResourceBuilder builder, string modelName, Action>? configure) + { + if (builder.ApplicationBuilder.ExecutionContext.IsRunMode) + { + return builder.AsAzureOpenAI(modelName, configure); + } + + return builder; + } + + public static IResourceBuilder PublishAsAzureOpenAI(this IResourceBuilder builder, string modelName, Action>? configure) + { + if (builder.ApplicationBuilder.ExecutionContext.IsPublishMode) + { + return builder.AsAzureOpenAI(modelName, configure); + } + + return builder; + } + + public static IResourceBuilder AsAzureOpenAI(this IResourceBuilder builder, string modelName, Action>? configure) + { + builder.Reset(); + + var openAIModel = builder.ApplicationBuilder.AddAzureOpenAI(builder.Resource.Name); + + configure?.Invoke(openAIModel); + + builder.Resource.UnderlyingResource = openAIModel.Resource; + // Add the model name to the connection string + builder.Resource.ConnectionString = ReferenceExpression.Create($"{openAIModel.Resource.ConnectionStringExpression};Model={modelName}"); + builder.Resource.Provider = "AzureOpenAI"; + return builder; + } + + public static IResourceBuilder RunAsAzureAIInference(this IResourceBuilder builder, string modelName, IResourceBuilder endpoint, IResourceBuilder apiKey) + { + if (builder.ApplicationBuilder.ExecutionContext.IsRunMode) + { + return builder.AsAzureAIInference(modelName, endpoint, apiKey); + } + + return builder; + } + + public static IResourceBuilder PublishAsAzureAIInference(this IResourceBuilder builder, string modelName, IResourceBuilder endpoint, IResourceBuilder apiKey) + { + if (builder.ApplicationBuilder.ExecutionContext.IsPublishMode) + { + return builder.AsAzureAIInference(modelName, endpoint, apiKey); + } + + return builder; + } + + public static IResourceBuilder AsAzureAIInference(this IResourceBuilder builder, string modelName, IResourceBuilder endpoint, IResourceBuilder apiKey) + { + builder.Reset(); + + // See: https://github.com/dotnet/aspire/issues/7641 + var csb = new ReferenceExpressionBuilder(); + csb.Append($"Endpoint={endpoint.Resource};"); + csb.Append($"AccessKey={apiKey.Resource};"); + csb.Append($"Model={modelName}"); + var cs = csb.Build(); + + builder.ApplicationBuilder.AddResource(builder.Resource); + + if (builder.ApplicationBuilder.ExecutionContext.IsRunMode) + { + var csTask = cs.GetValueAsync(default).AsTask(); + if (!csTask.IsCompletedSuccessfully) + { + throw new InvalidOperationException("Connection string could not be resolved!"); + } + +#pragma warning disable VSTHRD002 // Avoid problematic synchronous waits + builder.WithInitialState(new CustomResourceSnapshot + { + ResourceType = "Azure AI Inference Model", + State = KnownResourceStates.Running, + Properties = [ + new("ConnectionString", csTask.Result ) { IsSensitive = true } + ] + }); +#pragma warning restore VSTHRD002 // Avoid problematic synchronous waits + } + + builder.Resource.UnderlyingResource = builder.Resource; + builder.Resource.ConnectionString = cs; + builder.Resource.Provider = "AzureAIInference"; + + return builder; + } + + public static IResourceBuilder RunAsAzureAIInference(this IResourceBuilder builder, string modelName, string endpoint, IResourceBuilder apiKey) + { + if (builder.ApplicationBuilder.ExecutionContext.IsRunMode) + { + return builder.AsAzureAIInference(modelName, endpoint, apiKey); + } + + return builder; + } + + public static IResourceBuilder PublishAsAzureAIInference(this IResourceBuilder builder, string modelName, string endpoint, IResourceBuilder apiKey) + { + if (builder.ApplicationBuilder.ExecutionContext.IsPublishMode) + { + return builder.AsAzureAIInference(modelName, endpoint, apiKey); + } + + return builder; + } + + public static IResourceBuilder AsAzureAIInference(this IResourceBuilder builder, string modelName, string endpoint, IResourceBuilder apiKey) + { + builder.Reset(); + + // See: https://github.com/dotnet/aspire/issues/7641 + var csb = new ReferenceExpressionBuilder(); + csb.Append($"Endpoint={endpoint};"); + csb.Append($"AccessKey={apiKey.Resource};"); + csb.Append($"Model={modelName}"); + var cs = csb.Build(); + + builder.ApplicationBuilder.AddResource(builder.Resource); + + if (builder.ApplicationBuilder.ExecutionContext.IsRunMode) + { + var csTask = cs.GetValueAsync(default).AsTask(); + if (!csTask.IsCompletedSuccessfully) + { + throw new InvalidOperationException("Connection string could not be resolved!"); + } + +#pragma warning disable VSTHRD002 // Avoid problematic synchronous waits + builder.WithInitialState(new CustomResourceSnapshot + { + ResourceType = "Azure AI Inference Model", + State = KnownResourceStates.Running, + Properties = [ + new("ConnectionString", csTask.Result ) { IsSensitive = true } + ] + }); +#pragma warning restore VSTHRD002 // Avoid problematic synchronous waits + } + + builder.Resource.UnderlyingResource = builder.Resource; + builder.Resource.ConnectionString = cs; + builder.Resource.Provider = "AzureAIInference"; + + return builder; + } + + public static IResourceBuilder AsOpenAI(this IResourceBuilder builder, string modelName, IResourceBuilder apiKey) + { + builder.Reset(); + + // See: https://github.com/dotnet/aspire/issues/7641 + var csb = new ReferenceExpressionBuilder(); + csb.Append($"AccessKey={apiKey.Resource};"); + csb.Append($"Model={modelName}"); + var cs = csb.Build(); + + builder.ApplicationBuilder.AddResource(builder.Resource); + + if (builder.ApplicationBuilder.ExecutionContext.IsRunMode) + { + var csTask = cs.GetValueAsync(default).AsTask(); + if (!csTask.IsCompletedSuccessfully) + { + throw new InvalidOperationException("Connection string could not be resolved!"); + } + +#pragma warning disable VSTHRD002 // Avoid problematic synchronous waits + builder.WithInitialState(new CustomResourceSnapshot + { + ResourceType = "OpenAI Model", + State = KnownResourceStates.Running, + Properties = [ + new("ConnectionString", csTask.Result ) { IsSensitive = true } + ] + }); +#pragma warning restore VSTHRD002 // Avoid problematic synchronous waits + } + + builder.Resource.UnderlyingResource = builder.Resource; + builder.Resource.ConnectionString = cs; + builder.Resource.Provider = "OpenAI"; + + return builder; + } + + private static void Reset(this IResourceBuilder builder) + { + // Reset the properties of the AIModel resource + if (builder.Resource.UnderlyingResource is { } underlyingResource) + { + builder.ApplicationBuilder.Resources.Remove(underlyingResource); + + if (underlyingResource is IResourceWithParent resourceWithParent) + { + builder.ApplicationBuilder.Resources.Remove(resourceWithParent.Parent); + } + } + + builder.Resource.ConnectionString = null; + builder.Resource.Provider = null; + } +} + +// A resource representing an AI model. +public class AIModel(string name) : Resource(name), IResourceWithConnectionString +{ + internal string? Provider { get; set; } + internal IResourceWithConnectionString? UnderlyingResource { get; set; } + internal ReferenceExpression? ConnectionString { get; set; } + + public ReferenceExpression ConnectionStringExpression => + this.Build(); + + public ReferenceExpression Build() + { + var connectionString = this.ConnectionString ?? throw new InvalidOperationException("No connection string available."); + + if (this.Provider is null) + { + throw new InvalidOperationException("No provider configured."); + } + + return ReferenceExpression.Create($"{connectionString};Provider={this.Provider}"); + } +} diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.AppHost/Program.cs b/dotnet/samples/HelloHttpApi/HelloHttpApi.AppHost/Program.cs new file mode 100644 index 0000000000..97701fcf10 --- /dev/null +++ b/dotnet/samples/HelloHttpApi/HelloHttpApi.AppHost/Program.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft. All rights reserved. + +using HelloHttpApi.AppHost; + +var builder = DistributedApplication.CreateBuilder(args); + +var azOpenAiResource = builder.AddParameterFromConfiguration("AzureOpenAIName", "AzureOpenAI:Name"); +var azOpenAiResourceGroup = builder.AddParameterFromConfiguration("AzureOpenAIResourceGroup", "AzureOpenAI:ResourceGroup"); +var chatModel = builder.AddAIModel("chat-model").AsAzureOpenAI("gpt-4o", o => o.AsExisting(azOpenAiResource, azOpenAiResourceGroup)); + +var apiService = builder.AddProject("apiservice") + .WithReference(chatModel); + +builder.AddProject("webfrontend") + .WithExternalHttpEndpoints() + .WithReference(apiService) + .WaitFor(apiService); + +builder.Build().Run(); diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.AppHost/Properties/launchSettings.json b/dotnet/samples/HelloHttpApi/HelloHttpApi.AppHost/Properties/launchSettings.json new file mode 100644 index 0000000000..78978e33cc --- /dev/null +++ b/dotnet/samples/HelloHttpApi/HelloHttpApi.AppHost/Properties/launchSettings.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17277;http://localhost:15143", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21000", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22278" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15143", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19242", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20010" + } + } + } +} diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.AppHost/appsettings.Development.json b/dotnet/samples/HelloHttpApi/HelloHttpApi.AppHost/appsettings.Development.json new file mode 100644 index 0000000000..527a02bb3c --- /dev/null +++ b/dotnet/samples/HelloHttpApi/HelloHttpApi.AppHost/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Trace", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.AppHost/appsettings.json b/dotnet/samples/HelloHttpApi/HelloHttpApi.AppHost/appsettings.json new file mode 100644 index 0000000000..75c1fb6ea8 --- /dev/null +++ b/dotnet/samples/HelloHttpApi/HelloHttpApi.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Trace", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.ServiceDefaults/HelloHttpApi.ServiceDefaults.csproj b/dotnet/samples/HelloHttpApi/HelloHttpApi.ServiceDefaults/HelloHttpApi.ServiceDefaults.csproj new file mode 100644 index 0000000000..35628d9100 --- /dev/null +++ b/dotnet/samples/HelloHttpApi/HelloHttpApi.ServiceDefaults/HelloHttpApi.ServiceDefaults.csproj @@ -0,0 +1,22 @@ + + + + net9.0 + enable + enable + true + + + + + + + + + + + + + + + diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.ServiceDefaults/ServiceDefaultsExtensions.cs b/dotnet/samples/HelloHttpApi/HelloHttpApi.ServiceDefaults/ServiceDefaultsExtensions.cs new file mode 100644 index 0000000000..651ce71c94 --- /dev/null +++ b/dotnet/samples/HelloHttpApi/HelloHttpApi.ServiceDefaults/ServiceDefaultsExtensions.cs @@ -0,0 +1,124 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace Microsoft.Extensions.Hosting; + +// Adds common .NET Aspire services: service discovery, resilience, health checks, and OpenTelemetry. +// This project should be referenced by each service project in your solution. +// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults +public static class ServiceDefaultsExtensions +{ + public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Logging.SetMinimumLevel(LogLevel.Trace); + builder.ConfigureOpenTelemetry(); + + builder.AddDefaultHealthChecks(); + + builder.Services.AddServiceDiscovery(); + + builder.Services.ConfigureHttpClientDefaults(http => + { + // Turn on resilience by default + http.AddStandardResilienceHandler(); + + // Turn on service discovery by default + http.AddServiceDiscovery(); + }); + + // Uncomment the following to restrict the allowed schemes for service discovery. + // builder.Services.Configure(options => + // { + // options.AllowedSchemes = ["https"]; + // }); + + return builder; + } + + public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation(); + }) + .WithTracing(tracing => + { + tracing.AddSource(builder.Environment.ApplicationName) + .AddSource("Microsoft.Extensions.AI.Agents") + .AddSource("Microsoft.Extensions.AI.Agents.Runtime.InProcess") + .AddSource("Microsoft.Extensions.AI.Agents.Runtime.Abstractions.InMemoryActorStateStorage") + .AddAspNetCoreInstrumentation() + // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) + //.AddGrpcClientInstrumentation() + .AddHttpClientInstrumentation(); + }); + + builder.AddOpenTelemetryExporters(); + + return builder; + } + + private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + + if (useOtlpExporter) + { + builder.Services.AddOpenTelemetry().UseOtlpExporter(); + } + + // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) + //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) + //{ + // builder.Services.AddOpenTelemetry() + // .UseAzureMonitor(); + //} + + return builder; + } + + public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Services.AddHealthChecks() + // Add a default liveness check to ensure app is responsive + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + // Adding health checks endpoints to applications in non-development environments has security implications. + // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments. + if (app.Environment.IsDevelopment()) + { + // All health checks must pass for app to be considered ready to accept traffic after starting + app.MapHealthChecks("/health"); + + // Only health checks tagged with the "live" tag must pass for app to be considered alive + app.MapHealthChecks("/alive", new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }); + } + + return app; + } +} diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/AgentClient.cs b/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/AgentClient.cs new file mode 100644 index 0000000000..46349509b4 --- /dev/null +++ b/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/AgentClient.cs @@ -0,0 +1,215 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.AI.Agents; + +namespace HelloHttpApi.Web; + +public class AgentClient(HttpClient httpClient, ILogger logger) +{ + private static readonly JsonSerializerOptions s_jsonOptions = new(JsonSerializerDefaults.Web) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + public async IAsyncEnumerable SendMessageStreamAsync( + string agentName, + string message, + string sessionId = "default", + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var requestId = Guid.NewGuid().ToString(); + var request = new ChatClientAgentRunRequest + { + Messages = [new ChatMessage(ChatRole.User, message)] + }; + + var content = JsonContent.Create(request, s_jsonOptions.GetTypeInfo(AgentClientJsonContext.Default)); + + var requestUri = new Uri($"/invocations/actor/{agentName}/{sessionId}/{requestId}?stream=true", UriKind.Relative); + + var requestMessage = new HttpRequestMessage(HttpMethod.Post, requestUri) + { + Content = content + }; + + using var response = await httpClient.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + response.EnsureSuccessStatusCode(); + + using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + using var reader = new StreamReader(stream); + + string? line; + while ((line = await reader.ReadLineAsync(cancellationToken)) != null) + { + // If this indicates completion, break the loop + if (IsCompletionEvent(line)) + { + yield break; + } + + if (line.StartsWith("data: ", StringComparison.Ordinal)) + { + var jsonData = line.Substring(6); // Remove "data: " prefix + + if (TryParseEventData(jsonData, logger, out var responseUpdate)) + { + if (responseUpdate != null) + { + yield return responseUpdate; + } + } + else + { + logger.LogWarning("Received unrecognized event data: {JsonData}", jsonData); + } + } + } + } + + public async Task SendMessageAsync( + string agentName, + string message, + string sessionId = "default", + CancellationToken cancellationToken = default) + { + var requestId = Guid.NewGuid().ToString(); + var request = new ChatClientAgentRunRequest + { + Messages = [new ChatMessage(ChatRole.User, message)] + }; + + var content = JsonContent.Create(request, s_jsonOptions.GetTypeInfo(AgentClientJsonContext.Default)); + + var requestUri = new Uri($"/invocations/actor/{agentName}/{sessionId}/{requestId}?stream=false", UriKind.Relative); + + var requestMessage = new HttpRequestMessage(HttpMethod.Post, requestUri) + { + Content = content + }; + + using var response = await httpClient.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + response.EnsureSuccessStatusCode(); + + try + { + var agentResponse = await response.Content.ReadFromJsonAsync(s_jsonOptions.GetTypeInfo(AgentClientJsonContext.Default), cancellationToken); + return agentResponse ?? new AgentResponse { Content = "No response received", Status = "error" }; + } + catch (JsonException ex) + { + var responseContent = await response.Content.ReadAsStringAsync(cancellationToken); + logger.LogError(ex, "Failed to parse agent response JSON: {ResponseContent}", responseContent); + return new AgentResponse { Content = "Failed to parse response", Status = "error" }; + } + } + + private static bool TryParseEventData(string jsonData, ILogger logger, out AgentRunResponseUpdate? responseUpdate) + { + responseUpdate = null; + + try + { + var eventData = JsonSerializer.Deserialize(jsonData, s_jsonOptions.GetTypeInfo(AgentClientJsonContext.Default)); + if (eventData?.Event != null) + { + var eventElement = eventData.Event.Value; + + // Try to deserialize as AgentRunResponseUpdate for intermediate updates + try + { + var update = JsonSerializer.Deserialize(eventElement.GetRawText(), s_jsonOptions); + if (update != null) + { + responseUpdate = update; + return true; + } + } + catch (JsonException) + { + // If it fails to deserialize as AgentRunResponseUpdate, it might be something else + logger.LogDebug("Failed to deserialize event as AgentRunResponseUpdate, might be final response or other data"); + } + + // Fallback: create a simple update with the raw content + responseUpdate = new AgentRunResponseUpdate(ChatRole.Assistant, eventElement.ToString()); + return true; + } + } + catch (JsonException ex) + { + logger.LogError(ex, "Failed to parse event data JSON: {JsonData}", jsonData); + } + + return false; + } + + private static bool IsCompletionEvent(string line) => string.Equals("data: completed", line, StringComparison.Ordinal); +} + +public class ChatClientAgentRunRequest +{ + [JsonPropertyName("messages")] + public List Messages { get; set; } = []; +} + +public class EventData +{ + [JsonPropertyName("event")] + public JsonElement? Event { get; set; } +} + +public class AgentResponse +{ + [JsonPropertyName("content")] + public string Content { get; set; } = ""; + + [JsonPropertyName("status")] + public string Status { get; set; } = ""; +} + +/// +/// Provides extension methods for JSON serialization with source generation support. +/// +internal static class JsonSerializerExtensions +{ + /// + /// Gets the JsonTypeInfo for a type, preferring the one from options if available, + /// otherwise falling back to the source-generated context. + /// + /// The type to get JsonTypeInfo for. + /// The JsonSerializerOptions to check first. + /// The fallback JsonSerializerContext to use if not found in options. + /// The JsonTypeInfo for the requested type. + public static JsonTypeInfo GetTypeInfo(this JsonSerializerOptions options, JsonSerializerContext fallbackContext) + { + // Try to get from the options first (if a context is configured) + if (options.TypeInfoResolver?.GetTypeInfo(typeof(T), options) is JsonTypeInfo typeInfo) + { + return typeInfo; + } + + // Fall back to the provided source-generated context + return (JsonTypeInfo)fallbackContext.GetTypeInfo(typeof(T))!; + } +} + +/// +/// Source-generated JSON type information for use by AgentClient. +/// +[JsonSourceGenerationOptions( + JsonSerializerDefaults.Web, + UseStringEnumConverter = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = false)] +[JsonSerializable(typeof(ChatClientAgentRunRequest))] +[JsonSerializable(typeof(ChatMessage))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(EventData))] +[JsonSerializable(typeof(AgentRunResponseUpdate))] +[JsonSerializable(typeof(AgentResponse))] +[JsonSerializable(typeof(JsonElement))] +internal sealed partial class AgentClientJsonContext : JsonSerializerContext; diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/App.razor b/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/App.razor new file mode 100644 index 0000000000..2a9bdedfe7 --- /dev/null +++ b/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/App.razor @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Layout/MainLayout.razor b/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Layout/MainLayout.razor new file mode 100644 index 0000000000..5a24bb1371 --- /dev/null +++ b/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Layout/MainLayout.razor @@ -0,0 +1,23 @@ +@inherits LayoutComponentBase + +
+ + +
+
+ About +
+ +
+ @Body +
+
+
+ +
+ An unhandled error has occurred. + Reload + 🗙 +
diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Layout/MainLayout.razor.css b/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Layout/MainLayout.razor.css new file mode 100644 index 0000000000..038baf178b --- /dev/null +++ b/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Layout/MainLayout.razor.css @@ -0,0 +1,96 @@ +.page { + position: relative; + display: flex; + flex-direction: column; +} + +main { + flex: 1; +} + +.sidebar { + background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%); +} + +.top-row { + background-color: #f7f7f7; + border-bottom: 1px solid #d6d5d5; + justify-content: flex-end; + height: 3.5rem; + display: flex; + align-items: center; +} + + .top-row ::deep a, .top-row ::deep .btn-link { + white-space: nowrap; + margin-left: 1.5rem; + text-decoration: none; + } + + .top-row ::deep a:hover, .top-row ::deep .btn-link:hover { + text-decoration: underline; + } + + .top-row ::deep a:first-child { + overflow: hidden; + text-overflow: ellipsis; + } + +@media (max-width: 640.98px) { + .top-row { + justify-content: space-between; + } + + .top-row ::deep a, .top-row ::deep .btn-link { + margin-left: 0; + } +} + +@media (min-width: 641px) { + .page { + flex-direction: row; + } + + .sidebar { + width: 250px; + height: 100vh; + position: sticky; + top: 0; + } + + .top-row { + position: sticky; + top: 0; + z-index: 1; + } + + .top-row.auth ::deep a:first-child { + flex: 1; + text-align: right; + width: 0; + } + + .top-row, article { + padding-left: 2rem !important; + padding-right: 1.5rem !important; + } +} + +#blazor-error-ui { + background: lightyellow; + bottom: 0; + box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); + display: none; + left: 0; + padding: 0.6rem 1.25rem 0.7rem 1.25rem; + position: fixed; + width: 100%; + z-index: 1000; +} + + #blazor-error-ui .dismiss { + cursor: pointer; + position: absolute; + right: 0.75rem; + top: 0.5rem; + } diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Layout/NavMenu.razor b/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Layout/NavMenu.razor new file mode 100644 index 0000000000..8cc90687e7 --- /dev/null +++ b/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Layout/NavMenu.razor @@ -0,0 +1,29 @@ + + + + + diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Layout/NavMenu.razor.css b/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Layout/NavMenu.razor.css new file mode 100644 index 0000000000..1338edb61e --- /dev/null +++ b/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Layout/NavMenu.razor.css @@ -0,0 +1,102 @@ +.navbar-toggler { + appearance: none; + cursor: pointer; + width: 3.5rem; + height: 2.5rem; + color: white; + position: absolute; + top: 0.5rem; + right: 1rem; + border: 1px solid rgba(255, 255, 255, 0.1); + background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") no-repeat center/1.75rem rgba(255, 255, 255, 0.1); +} + +.navbar-toggler:checked { + background-color: rgba(255, 255, 255, 0.5); +} + +.top-row { + min-height: 3.5rem; + background-color: rgba(0,0,0,0.4); +} + +.navbar-brand { + font-size: 1.1rem; +} + +.bi { + display: inline-block; + position: relative; + width: 1.25rem; + height: 1.25rem; + margin-right: 0.75rem; + top: -1px; + background-size: cover; +} + +.bi-house-door-fill { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E"); +} + +.bi-plus-square-fill { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E"); +} + +.bi-list-nested { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E"); +} + +.nav-item { + font-size: 0.9rem; + padding-bottom: 0.5rem; +} + + .nav-item:first-of-type { + padding-top: 1rem; + } + + .nav-item:last-of-type { + padding-bottom: 1rem; + } + + .nav-item ::deep a { + color: #d7d7d7; + border-radius: 4px; + height: 3rem; + display: flex; + align-items: center; + line-height: 3rem; + } + +.nav-item ::deep a.active { + background-color: rgba(255,255,255,0.37); + color: white; +} + +.nav-item ::deep a:hover { + background-color: rgba(255,255,255,0.1); + color: white; +} + +.nav-scrollable { + display: none; +} + +.navbar-toggler:checked ~ .nav-scrollable { + display: block; +} + +@media (min-width: 641px) { + .navbar-toggler { + display: none; + } + + .nav-scrollable { + /* Never collapse the sidebar for wide screens */ + display: block; + + /* Allow sidebar to scroll for tall menus */ + height: calc(100vh - 3.5rem); + overflow-y: auto; + } +} diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Pages/Counter.razor b/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Pages/Counter.razor new file mode 100644 index 0000000000..1a4f8e7553 --- /dev/null +++ b/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Pages/Counter.razor @@ -0,0 +1,19 @@ +@page "/counter" +@rendermode InteractiveServer + +Counter + +

Counter

+ +

Current count: @currentCount

+ + + +@code { + private int currentCount = 0; + + private void IncrementCount() + { + currentCount++; + } +} diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Pages/Error.razor b/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Pages/Error.razor new file mode 100644 index 0000000000..fcaa7c6ef6 --- /dev/null +++ b/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Pages/Error.razor @@ -0,0 +1,38 @@ +@page "/Error" +@using System.Diagnostics + +Error + +

Error.

+

An error occurred while processing your request.

+ +@if (ShowRequestId) +{ +

+ Request ID: @requestId +

+} + +

Development Mode

+

+ Swapping to Development environment will display more detailed information about the error that occurred. +

+

+ The Development environment shouldn't be enabled for deployed applications. + It can result in displaying sensitive information from exceptions to end users. + For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development + and restarting the app. +

+ +@code{ + [CascadingParameter] + public HttpContext? HttpContext { get; set; } + + private string? requestId; + private bool ShowRequestId => !string.IsNullOrEmpty(requestId); + + protected override void OnInitialized() + { + requestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier; + } +} diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Pages/Home.razor b/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Pages/Home.razor new file mode 100644 index 0000000000..9001e0bd27 --- /dev/null +++ b/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Pages/Home.razor @@ -0,0 +1,7 @@ +@page "/" + +Home + +

Hello, world!

+ +Welcome to your new app. diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Pages/PirateTalk.razor b/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Pages/PirateTalk.razor new file mode 100644 index 0000000000..cba45d10a7 --- /dev/null +++ b/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Pages/PirateTalk.razor @@ -0,0 +1,208 @@ +@page "/pirate-talk" +@attribute [StreamRendering(true)] +@inject AgentClient AgentClient +@inject IJSRuntime JSRuntime +@inject ILogger Logger +@rendermode InteractiveServer +@using System.Text +@using System.Text.Json +@using Microsoft.Extensions.AI +@using Microsoft.Extensions.AI.Agents + +Pirate Talk + +

🏴‍☠️ Pirate Talk

+ +

Chat with a pirate agent! Send a message and get a response in pirate speak.

+ +
+
+ @foreach (var message in chatMessages) + { +
+ @(message.IsUser ? "You" : "🏴‍☠️ Pirate"): +
@message.Content
+
+ } + + @if (isStreaming && currentStreamedMessage.Length > 0) + { +
+ 🏴‍☠️ Pirate: +
@currentStreamedMessage
+
+ } +
+ +
+ + +
+
+ + + +@code { + private string currentMessage = ""; + private bool isStreaming = false; + private string currentStreamedMessage = ""; + private List chatMessages = new(); + private string sessionId = Guid.NewGuid().ToString(); + private const string AgentName = "agent:pirate"; + + protected override void OnInitialized() + { + Logger.LogDebug("Initializing PirateTalk component with session ID: {SessionId}", sessionId); + } + + private async Task SendMessage() + { + if (string.IsNullOrWhiteSpace(currentMessage) || isStreaming) + return; + + var userMessage = currentMessage.Trim(); + currentMessage = ""; + + Logger.LogInformation("User sending message: '{UserMessage}' in session {SessionId}", userMessage, sessionId); + + // Add user message to chat + chatMessages.Add(new ChatMessage { Content = userMessage, IsUser = true }); + Logger.LogDebug("Chat message added to collection. Total messages: {MessageCount}, Is user message: {IsUserMessage}", chatMessages.Count, true); + Logger.LogTrace("StateHasChanged called during streaming for session {SessionId}", sessionId); + StateHasChanged(); + await ScrollToBottom(); + + // Start streaming response + isStreaming = true; + currentStreamedMessage = ""; + Logger.LogDebug("Starting streaming response for session {SessionId}", sessionId); + Logger.LogTrace("StateHasChanged called during streaming for session {SessionId}", sessionId); + StateHasChanged(); + + try + { + var responseContent = new StringBuilder(); + + await foreach (var update in AgentClient.SendMessageStreamAsync(AgentName, userMessage, sessionId)) + { + Logger.LogTrace("Received streaming update with text length: {TextLength} for session {SessionId}", update.Text?.Length ?? 0, sessionId); + + // Extract text content from the AgentRunResponseUpdate + var content = update.Text ?? ""; + if (!string.IsNullOrEmpty(content)) + { + Logger.LogDebug("Extracted content from update: '{ExtractedContent}' for session {SessionId}", content, sessionId); + responseContent.Append(content); + currentStreamedMessage = responseContent.ToString(); + Logger.LogTrace("StateHasChanged called during streaming for session {SessionId}", sessionId); + StateHasChanged(); + await ScrollToBottom(); + } + } + + // Add the complete pirate response to chat messages + if (responseContent.Length > 0) + { + Logger.LogInformation("Streaming completed with total response length: {ResponseLength} for session {SessionId}", responseContent.Length, sessionId); + chatMessages.Add(new ChatMessage { Content = responseContent.ToString(), IsUser = false }); + Logger.LogDebug("Chat message added to collection. Total messages: {MessageCount}, Is user message: {IsUserMessage}", chatMessages.Count, false); + } + else + { + Logger.LogWarning("Empty response received from agent for session {SessionId}", sessionId); + chatMessages.Add(new ChatMessage { Content = "Arrr, something went wrong with me response, matey!", IsUser = false }); + Logger.LogDebug("Chat message added to collection. Total messages: {MessageCount}, Is user message: {IsUserMessage}", chatMessages.Count, false); + } + } + catch (Exception ex) + { + Logger.LogError(ex, "Error occurred while processing message in session {SessionId}: {ErrorMessage}", sessionId, ex.Message); + chatMessages.Add(new ChatMessage { Content = $"Arrr, encountered rough seas: {ex.Message}", IsUser = false }); + Logger.LogDebug("Chat message added to collection. Total messages: {MessageCount}, Is user message: {IsUserMessage}", chatMessages.Count, false); + } + finally + { + isStreaming = false; + currentStreamedMessage = ""; + Logger.LogTrace("StateHasChanged called during streaming for session {SessionId}", sessionId); + StateHasChanged(); + await ScrollToBottom(); + } + } + + private async Task HandleKeyPress(KeyboardEventArgs e) + { + Logger.LogDebug("Handling key press event: {Key} for session {SessionId}", e.Key, sessionId); + if (e.Key == "Enter" && !e.ShiftKey) + { + await SendMessage(); + } + } + + private async Task ScrollToBottom() + { + try + { + Logger.LogTrace("Scrolling chat to bottom for session {SessionId}", sessionId); + await JSRuntime.InvokeVoidAsync("scrollToBottom", "chat-messages"); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Failed to scroll to bottom due to JavaScript error for session {SessionId}", sessionId); + // Ignore JS errors + } + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + Logger.LogDebug("Component first render completed, JavaScript functions initialized for session {SessionId}", sessionId); + await JSRuntime.InvokeVoidAsync("eval", @" + window.scrollToBottom = function(elementId) { + const element = document.getElementById(elementId); + if (element) { + element.scrollTop = element.scrollHeight; + } + }; + "); + } + } + + private class ChatMessage + { + public string Content { get; set; } = ""; + public bool IsUser { get; set; } + } +} diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Routes.razor b/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Routes.razor new file mode 100644 index 0000000000..f756e19dfb --- /dev/null +++ b/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Routes.razor @@ -0,0 +1,6 @@ + + + + + + diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/_Imports.razor b/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/_Imports.razor new file mode 100644 index 0000000000..4c2793f6db --- /dev/null +++ b/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/_Imports.razor @@ -0,0 +1,11 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.AspNetCore.OutputCaching +@using Microsoft.JSInterop +@using HelloHttpApi.Web +@using HelloHttpApi.Web.Components diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/HelloHttpApi.Web.csproj b/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/HelloHttpApi.Web.csproj new file mode 100644 index 0000000000..e0aec4096f --- /dev/null +++ b/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/HelloHttpApi.Web.csproj @@ -0,0 +1,14 @@ + + + + net9.0 + enable + enable + + + + + + + + diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Program.cs b/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Program.cs new file mode 100644 index 0000000000..68c8f8be5c --- /dev/null +++ b/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Program.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft. All rights reserved. + +using HelloHttpApi.Web; +using HelloHttpApi.Web.Components; + +var builder = WebApplication.CreateBuilder(args); + +// Add service defaults & Aspire client integrations. +builder.AddServiceDefaults(); + +// Add services to the container. +builder.Services.AddRazorComponents() + .AddInteractiveServerComponents(); + +builder.Services.AddOutputCache(); + +builder.Services.AddHttpClient(client => + { + // This URL uses "https+http://" to indicate HTTPS is preferred over HTTP. + // Learn more about service discovery scheme resolution at https://aka.ms/dotnet/sdschemes. + client.BaseAddress = new("https+http://apiservice"); + }); + +var app = builder.Build(); + +if (!app.Environment.IsDevelopment()) +{ + app.UseExceptionHandler("/Error", createScopeForErrors: true); + // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. + app.UseHsts(); +} + +app.UseHttpsRedirection(); + +app.UseStaticFiles(); +app.UseAntiforgery(); + +app.UseOutputCache(); + +app.MapRazorComponents() + .AddInteractiveServerRenderMode(); + +app.MapDefaultEndpoints(); + +app.Run(); diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Properties/launchSettings.json b/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Properties/launchSettings.json new file mode 100644 index 0000000000..f0fb11469c --- /dev/null +++ b/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5154", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7020;http://localhost:5154", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/appsettings.Development.json b/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/appsettings.Development.json new file mode 100644 index 0000000000..527a02bb3c --- /dev/null +++ b/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Trace", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/appsettings.json b/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/appsettings.json new file mode 100644 index 0000000000..f41d5acb6b --- /dev/null +++ b/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Trace", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/wwwroot/app.css b/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/wwwroot/app.css new file mode 100644 index 0000000000..2a2a313024 --- /dev/null +++ b/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/wwwroot/app.css @@ -0,0 +1,56 @@ +html, body { + font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; +} + +a, .btn-link { + color: #006bb7; +} + +.btn-primary { + color: #fff; + background-color: #1b6ec2; + border-color: #1861ac; +} + +.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus { + box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb; +} + +.content { + padding-top: 1.1rem; +} + +h1:focus { + outline: none; +} + +.valid.modified:not([type=checkbox]) { + outline: 1px solid #26b050; +} + +.invalid { + outline: 1px solid #e51540; +} + +.validation-message { + color: #e51540; +} + +.blazor-error-boundary { + background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; + padding: 1rem 1rem 1rem 3.7rem; + color: white; +} + + .blazor-error-boundary::after { + content: "An error has occurred." + } + +.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder { + color: var(--bs-secondary-color); + text-align: end; +} + +.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder { + text-align: start; +} diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/wwwroot/favicon.png b/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/wwwroot/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..8422b59695935d180d11d5dbe99653e711097819 GIT binary patch literal 1148 zcmV-?1cUpDP)9h26h2-Cs%i*@Moc3?#6qJID|D#|3|2Hn7gTIYEkr|%Xjp);YgvFmB&0#2E2b=| zkVr)lMv9=KqwN&%obTp-$<51T%rx*NCwceh-E+=&e(oLO`@Z~7gybJ#U|^tB2Pai} zRN@5%1qsZ1e@R(XC8n~)nU1S0QdzEYlWPdUpH{wJ2Pd4V8kI3BM=)sG^IkUXF2-j{ zrPTYA6sxpQ`Q1c6mtar~gG~#;lt=s^6_OccmRd>o{*=>)KS=lM zZ!)iG|8G0-9s3VLm`bsa6e ze*TlRxAjXtm^F8V`M1%s5d@tYS>&+_ga#xKGb|!oUBx3uc@mj1%=MaH4GR0tPBG_& z9OZE;->dO@`Q)nr<%dHAsEZRKl zedN6+3+uGHejJp;Q==pskSAcRcyh@6mjm2z-uG;s%dM-u0*u##7OxI7wwyCGpS?4U zBFAr(%GBv5j$jS@@t@iI8?ZqE36I^4t+P^J9D^ELbS5KMtZ z{Qn#JnSd$15nJ$ggkF%I4yUQC+BjDF^}AtB7w348EL>7#sAsLWs}ndp8^DsAcOIL9 zTOO!!0!k2`9BLk25)NeZp7ev>I1Mn={cWI3Yhx2Q#DnAo4IphoV~R^c0x&nw*MoIV zPthX?{6{u}sMS(MxD*dmd5rU(YazQE59b|TsB5Tm)I4a!VaN@HYOR)DwH1U5y(E)z zQqQU*B%MwtRQ$%x&;1p%ANmc|PkoFJZ%<-uq%PX&C!c-7ypis=eP+FCeuv+B@h#{4 zGx1m0PjS~FJt}3mdt4c!lel`1;4W|03kcZRG+DzkTy|7-F~eDsV2Tx!73dM0H0CTh zl)F-YUkE1zEzEW(;JXc|KR5{ox%YTh{$%F$a36JP6Nb<0%#NbSh$dMYF-{ z1_x(Vx)}fs?5_|!5xBTWiiIQHG<%)*e=45Fhjw_tlnmlixq;mUdC$R8v#j( zhQ$9YR-o%i5Uc`S?6EC51!bTRK=Xkyb<18FkCKnS2;o*qlij1YA@-nRpq#OMTX&RbL<^2q@0qja!uIvI;j$6>~k@IMwD42=8$$!+R^@5o6HX(*n~ +[JsonConverter(typeof(Converter))] public readonly struct ActorId : IEquatable { /// @@ -113,4 +116,28 @@ public readonly struct ActorId : IEquatable return true; #endif } + + /// + /// JSON converter for . + /// + public sealed class Converter : JsonConverter + { + /// + public override ActorId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.String) + { + throw new JsonException("Expected string value for ActorId"); + } + + string? actorIdString = reader.GetString() ?? throw new JsonException("ActorId cannot be null"); + return ActorId.Parse(actorIdString); + } + + /// + public override void Write(Utf8JsonWriter writer, ActorId value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.ToString()); + } + } } diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorJsonContext.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorJsonContext.cs new file mode 100644 index 0000000000..df87360eb4 --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorJsonContext.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.Extensions.AI.Agents.Runtime; + +/// +/// Source-generated JSON type information for use by all Actor abstractions. +/// +[JsonSourceGenerationOptions( + JsonSerializerDefaults.Web, + UseStringEnumConverter = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = false)] +[JsonSerializable(typeof(ActorMessage))] +[JsonSerializable(typeof(ActorRequestMessage))] +[JsonSerializable(typeof(ActorResponseMessage))] +[JsonSerializable(typeof(ActorWriteOperation))] +[JsonSerializable(typeof(SetValueOperation))] +[JsonSerializable(typeof(RemoveKeyOperation))] +[JsonSerializable(typeof(SendRequestOperation))] +[JsonSerializable(typeof(UpdateRequestOperation))] +[JsonSerializable(typeof(ActorReadOperation))] +[JsonSerializable(typeof(ListKeysOperation))] +[JsonSerializable(typeof(GetValueOperation))] +[JsonSerializable(typeof(ActorReadResult))] +[JsonSerializable(typeof(ListKeysResult))] +[JsonSerializable(typeof(GetValueResult))] +[JsonSerializable(typeof(ActorRequest))] +[JsonSerializable(typeof(ActorRequestUpdate))] +[JsonSerializable(typeof(ActorResponse))] +[JsonSerializable(typeof(ActorId))] +[JsonSerializable(typeof(RequestStatus))] +[JsonSerializable(typeof(ActorWriteOperationBatch))] +[JsonSerializable(typeof(ActorReadOperationBatch))] +[JsonSerializable(typeof(ReadResponse))] +[JsonSerializable(typeof(WriteResponse))] +[JsonSerializable(typeof(ActorType))] +[JsonSerializable(typeof(JsonElement))] +internal sealed partial class ActorJsonContext : JsonSerializerContext; diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorMessage.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorMessage.cs new file mode 100644 index 0000000000..c81a9ff244 --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorMessage.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.Extensions.AI.Agents.Runtime; + +/// +/// Base class for all actor messages that can be sent between actors. +/// +/// +/// This abstract class serves as the foundation for all actor message types. +/// Each concrete implementation represents a specific type of message, +/// such as request messages or response messages. +/// +//[JsonConverter(typeof(Converter))] +[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")] +[JsonDerivedType(typeof(ActorRequestMessage), "request")] +[JsonDerivedType(typeof(ActorResponseMessage), "response")] +public abstract class ActorMessage +{ + /// Prevent external derivations. + private protected ActorMessage() + { + } + + /// + /// Gets the type of the message. + /// + [JsonIgnore] + public abstract ActorMessageType Type { get; } + + /// + /// Additional properties that can be used to extend the message with custom data. + /// + [JsonExtensionData] + public Dictionary? ExtensionData { get; set; } +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorMessageType.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorMessageType.cs new file mode 100644 index 0000000000..46b3c689a5 --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorMessageType.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Extensions.AI.Agents.Runtime; + +/// +/// Specifies the type of actor message. +/// +public enum ActorMessageType +{ + /// + /// Represents a request message sent to an actor. + /// + [JsonStringEnumMemberName("request")] + Request, + + /// + /// Represents a response message sent from an actor. + /// + [JsonStringEnumMemberName("response")] + Response +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorMessageWriteOperation.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorMessageWriteOperation.cs new file mode 100644 index 0000000000..5e365927eb --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorMessageWriteOperation.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Extensions.AI.Agents.Runtime; + +/// +/// Base class for write operations that modify an actor's messaging (inbox/outbox). +/// +public abstract class ActorMessageWriteOperation : ActorWriteOperation +{ + /// Prevent external derivations. + private protected ActorMessageWriteOperation() + { + } +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorReadOperation.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorReadOperation.cs new file mode 100644 index 0000000000..1375fe639c --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorReadOperation.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; + +namespace Microsoft.Extensions.AI.Agents.Runtime; + +/// +/// Base class for all actor read operations that can query actor state or messaging. +/// +/// +/// This abstract class serves as the foundation for all actor read operation types. +/// Each concrete implementation represents a specific type of read operation, +/// such as querying actor state or messaging information. +/// +[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")] +[JsonDerivedType(typeof(ListKeysOperation), "list_keys")] +[JsonDerivedType(typeof(GetValueOperation), "get_value")] +public abstract class ActorReadOperation +{ + /// Prevent external derivations. + private protected ActorReadOperation() + { + } + + /// + /// Gets the type of the read operation. + /// + [JsonIgnore] + public abstract ActorReadOperationType Type { get; } +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorReadOperationBatch.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorReadOperationBatch.cs new file mode 100644 index 0000000000..6a44e35095 --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorReadOperationBatch.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Microsoft.Extensions.AI.Agents.Runtime; + +/// +/// Represents a batch of read operations to be performed on an actor. +/// +/// The collection of read operations to perform. +public class ActorReadOperationBatch(IReadOnlyList operations) +{ + /// + /// Gets the collection of read operations to perform. + /// + [JsonPropertyName("operations")] + public IReadOnlyList Operations { get; } = operations; +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorReadOperationType.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorReadOperationType.cs new file mode 100644 index 0000000000..17e2a45ebe --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorReadOperationType.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Extensions.AI.Agents.Runtime; + +/// +/// Specifies the type of actor read operation. +/// +public enum ActorReadOperationType +{ + /// + /// Represents a list keys operation. + /// + [JsonStringEnumMemberName("list_keys")] + ListKeys, + + /// + /// Represents a get value operation. + /// + [JsonStringEnumMemberName("get_value")] + GetValue +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorReadResult.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorReadResult.cs new file mode 100644 index 0000000000..e83ce179f4 --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorReadResult.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; + +namespace Microsoft.Extensions.AI.Agents.Runtime; + +/// +/// Base class for all actor read operation results. +/// +/// +/// This abstract class serves as the foundation for all actor read operation result types. +/// Each concrete implementation represents a specific type of read operation result, +/// such as listing keys or retrieving values from an actor's state. +/// +[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")] +[JsonDerivedType(typeof(ListKeysResult), "list_keys")] +[JsonDerivedType(typeof(GetValueOperation), "get_value")] +public abstract class ActorReadResult +{ + /// Prevent external derivations. + private protected ActorReadResult() + { + } + + /// + /// Gets the type of the read result operation. + /// + [JsonIgnore] + public abstract ActorReadResultType Type { get; } +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorReadResultType.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorReadResultType.cs new file mode 100644 index 0000000000..1fb79a7dba --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorReadResultType.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Extensions.AI.Agents.Runtime; + +/// +/// Specifies the type of actor read result operation. +/// +public enum ActorReadResultType +{ + /// + /// Represents a list keys operation result. + /// + [JsonStringEnumMemberName("list_keys")] + ListKeys, + + /// + /// Represents a get value operation result. + /// + [JsonStringEnumMemberName("get_value")] + GetValue +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorRequest.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorRequest.cs new file mode 100644 index 0000000000..fa019e1cdd --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorRequest.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.Extensions.AI.Agents.Runtime; + +/// +/// Represents a request to be sent to an actor. +/// +public class ActorRequest(ActorId actorId, string messageId, string method, JsonElement @params) +{ + /// + /// Gets or sets the identifier of the target actor. + /// + [JsonPropertyName("actorId")] + public ActorId ActorId { get; } = actorId; + + /// + /// Gets or sets the unique identifier for this request. + /// + [JsonPropertyName("messageId")] + public string MessageId { get; } = messageId; + + /// + /// Gets or sets the method name to invoke on the actor. + /// + [JsonPropertyName("method")] + public string Method { get; } = method; + + /// + /// Gets or sets the parameters for the method invocation. + /// + [JsonPropertyName("params")] + public JsonElement Params { get; } = @params; +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorRequestMessage.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorRequestMessage.cs new file mode 100644 index 0000000000..fb05194c8c --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorRequestMessage.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.Extensions.AI.Agents.Runtime; + +/// +/// Base class for request messages sent to actors. +/// +public sealed class ActorRequestMessage(string MessageId) : ActorMessage +{ + /// + public override ActorMessageType Type => ActorMessageType.Request; + + /// + /// Gets or sets the actor ID of the sender. + /// + [JsonPropertyName("sender")] + public ActorId? SenderId { get; init; } + + /// + /// Gets or sets the unique identifier for the request. + /// + [JsonPropertyName("messageId")] + public string MessageId { get; } = MessageId; + + /// + /// Name of the method to invoke. + /// + [JsonPropertyName("method")] + public string? Method { get; init; } + + /// + /// Optional parameters for the method invocation. + /// + [JsonPropertyName("params")] + public JsonElement Params { get; init; } +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorRequestUpdate.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorRequestUpdate.cs new file mode 100644 index 0000000000..77e8c301c6 --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorRequestUpdate.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.Extensions.AI.Agents.Runtime; + +// External (client) interface. + +/// +/// Represents an update to an actor request's status and data. +/// +public class ActorRequestUpdate(RequestStatus status, JsonElement data) +{ + /// + /// Gets the updated status of the request. + /// + [JsonPropertyName("status")] + public RequestStatus Status { get; } = status; + + /// + /// Gets the updated data associated with the request. + /// + [JsonPropertyName("data")] + public JsonElement Data { get; } = data; +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorResponse.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorResponse.cs new file mode 100644 index 0000000000..7a97807faa --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorResponse.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.Extensions.AI.Agents.Runtime; + +/// +/// Represents a response handle for an actor request, providing access to the result and status updates. +/// +public class ActorResponse +{ + /// + /// Gets the identifier of the actor that is processing the request. + /// + [JsonPropertyName("actorId")] + public ActorId ActorId { get; init; } + + /// + /// Gets the unique identifier of the message/request. + /// + [JsonPropertyName("messageId")] + public string? MessageId { get; init; } + + /// + /// Gets the response data from the actor. + /// + [JsonPropertyName("data")] + public JsonElement Data { get; init; } + + /// + /// Gets or sets the current status of the request. + /// + [JsonPropertyName("status")] + public RequestStatus Status { get; init; } +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorResponseHandle.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorResponseHandle.cs new file mode 100644 index 0000000000..2508bd28eb --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorResponseHandle.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.AI.Agents.Runtime; + +/// +/// Represents a handle to an actor response, allowing retrieval of the response data and status updates. +/// +public abstract class ActorResponseHandle +{ + /// + /// Attempts to get the response from the request if it is immediately available. + /// + /// When this method returns , contains the actor response; otherwise, . + /// if the response is immediately available; otherwise, . + /// + /// This method does not block and returns immediately. If the request is still pending or processing, + /// this method returns . + /// Use to wait asynchronously for the response to become available. + /// + public abstract bool TryGetResponse([NotNullWhen(true)] out ActorResponse? response); + + /// + /// Gets the response from the completed request. + /// + /// A token to cancel the wait operation. + /// A task that completes when the request is finished. + public abstract ValueTask GetResponseAsync(CancellationToken cancellationToken); + + /// + /// Cancels the request if it is still pending. + /// + /// A task representing the cancellation operation. + public abstract ValueTask CancelAsync(CancellationToken cancellationToken); + + /// + /// Watches for status and data updates to the request. + /// + /// A token to cancel the watch operation. + /// An asynchronous enumerable of request updates. + public abstract IAsyncEnumerable WatchUpdatesAsync(CancellationToken cancellationToken); +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorResponseMessage.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorResponseMessage.cs new file mode 100644 index 0000000000..68510d38e5 --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorResponseMessage.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.Extensions.AI.Agents.Runtime; + +/// +/// Base class for response messages sent from actors. +/// +public sealed class ActorResponseMessage(string MessageId) : ActorMessage +{ + /// + public override ActorMessageType Type => ActorMessageType.Response; + + /// + /// Gets or sets the actor ID of the sender. + /// + [JsonPropertyName("senderId")] + public ActorId SenderId { get; init; } + + /// + /// Gets or sets the unique identifier for the request. + /// + [JsonPropertyName("messageId")] + public string MessageId { get; } = MessageId; + + /// + /// Gets or sets the status of the request. + /// + [JsonPropertyName("status")] + public RequestStatus Status { get; init; } + + /// + /// Gets or sets the response data (result or error information). + /// + [JsonPropertyName("data")] + public JsonElement Data { get; init; } +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorStateReadOperation.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorStateReadOperation.cs new file mode 100644 index 0000000000..43e830631a --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorStateReadOperation.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Extensions.AI.Agents.Runtime; + +/// +/// Base class for read operations that query an actor's internal state. +/// +public abstract class ActorStateReadOperation : ActorReadOperation +{ + /// Prevent external derivations. + private protected ActorStateReadOperation() + { + } +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorStateWriteOperation.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorStateWriteOperation.cs new file mode 100644 index 0000000000..f0ec2dcbd8 --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorStateWriteOperation.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Extensions.AI.Agents.Runtime; + +/// +/// Base class for write operations that modify an actor's internal state. +/// +/// +/// This abstract class serves as the foundation for all actor state write operation types. +/// Each concrete implementation represents a specific type of state modification operation, +/// such as setting or removing key-value pairs in an actor's state. +/// +public abstract class ActorStateWriteOperation : ActorWriteOperation +{ + /// Prevent external derivations. + private protected ActorStateWriteOperation() + { + } +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorType.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorType.cs index 6f101b46f4..619e018083 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorType.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorType.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Text.Json; +using System.Text.Json.Serialization; using System.Text.RegularExpressions; namespace Microsoft.Extensions.AI.Agents.Runtime; @@ -8,6 +10,7 @@ namespace Microsoft.Extensions.AI.Agents.Runtime; /// /// Represents the type of an actor. /// +[JsonConverter(typeof(Converter))] public readonly partial struct ActorType : IEquatable { /// @@ -60,9 +63,33 @@ public readonly partial struct ActorType : IEquatable type is not null && TypeRegex().IsMatch(type); #if NET - [GeneratedRegex("^[a-zA-Z_][a-zA-Z_0-9]*$")] + [GeneratedRegex("^[a-zA-Z_][a-zA-Z_:0-9]*$")] private static partial Regex TypeRegex(); #else - private static Regex TypeRegex() => new("^[a-zA-Z_][a-zA-Z_0-9]*$", RegexOptions.Compiled); + private static Regex TypeRegex() => new("^[a-zA-Z_][a-zA-Z_:0-9:]*$", RegexOptions.Compiled); #endif + + /// + /// JSON converter for . + /// + public sealed class Converter : JsonConverter + { + /// + public override ActorType Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.String) + { + throw new JsonException("Expected string value for ActorType"); + } + + string? actorTypeString = reader.GetString() ?? throw new JsonException("ActorType cannot be null"); + return new ActorType(actorTypeString); + } + + /// + public override void Write(Utf8JsonWriter writer, ActorType value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.Name); + } + } } diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorWriteOperation.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorWriteOperation.cs new file mode 100644 index 0000000000..101b3e3c30 --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorWriteOperation.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Extensions.AI.Agents.Runtime; + +/// +/// Base class for all actor write operations that can modify actor state or messaging. +/// +/// +/// This abstract class serves as the foundation for all actor write operation types. +/// Each concrete implementation represents a specific type of write operation, +/// such as modifying actor state or sending messages. +/// +[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")] +[JsonDerivedType(typeof(SetValueOperation), "set_value")] +[JsonDerivedType(typeof(RemoveKeyOperation), "remove_key")] +[JsonDerivedType(typeof(UpdateRequestOperation), "update_request")] +[JsonDerivedType(typeof(SendRequestOperation), "send_request")] +public abstract class ActorWriteOperation +{ + /// Prevent external derivations. + private protected ActorWriteOperation() + { + } + + /// + /// Gets the type of the write operation. + /// + [JsonIgnore] + public abstract ActorWriteOperationType Type { get; } +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorWriteOperationBatch.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorWriteOperationBatch.cs new file mode 100644 index 0000000000..7f56e958ad --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorWriteOperationBatch.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Microsoft.Extensions.AI.Agents.Runtime; + +/// +/// Represents a batch of write operations to be performed atomically on an actor. +/// +/// The ETag for optimistic concurrency control. +/// The collection of write operations to perform. +public class ActorWriteOperationBatch(string eTag, IReadOnlyCollection operations) +{ + /// + /// Gets the collection of write operations to perform. + /// + [JsonPropertyName("operations")] + public IReadOnlyCollection Operations { get; } = operations; + + /// + /// Gets the ETag for optimistic concurrency control. + /// + [JsonPropertyName("etag")] + public string ETag { get; } = eTag; +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorWriteOperationType.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorWriteOperationType.cs new file mode 100644 index 0000000000..05e5240500 --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorWriteOperationType.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Extensions.AI.Agents.Runtime; + +/// +/// Specifies the type of actor write operation. +/// +public enum ActorWriteOperationType +{ + /// + /// Represents a set key-value operation. + /// + [JsonStringEnumMemberName("set_value")] + SetValue, + + /// + /// Represents a remove key operation. + /// + [JsonStringEnumMemberName("remove_key")] + RemoveKey, + + /// + /// Represents a send request operation. + /// + [JsonStringEnumMemberName("send_request")] + SendRequest, + + /// + /// Represents an update request operation. + /// + [JsonStringEnumMemberName("update_request")] + UpdateRequest +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/GetValueOperation.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/GetValueOperation.cs new file mode 100644 index 0000000000..66b4f09e64 --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/GetValueOperation.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Extensions.AI.Agents.Runtime; + +/// +/// Represents a request to read a value from the actor's state by its key. +/// +/// The key corresponding to the value to read from the actor's state. +public class GetValueOperation(string key) : ActorStateReadOperation +{ + /// + /// Gets the key corresponding to the value to read from the actor's state. + /// + [JsonPropertyName("key")] + public string Key { get; } = key; + + /// + /// Gets the type of the read operation. + /// + public override ActorReadOperationType Type => ActorReadOperationType.GetValue; +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/GetValueResult.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/GetValueResult.cs new file mode 100644 index 0000000000..024a45f441 --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/GetValueResult.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.Extensions.AI.Agents.Runtime; + +/// +/// Represents the result of a get value operation containing the retrieved value. +/// +/// The value retrieved from the actor's state, or null if not found. +public class GetValueResult(JsonElement? value) : ActorReadResult +{ + /// + /// Gets the value retrieved from the actor's state. + /// + [JsonPropertyName("value")] + public JsonElement? Value { get; } = value; + + /// + /// Gets the type of the read result operation. + /// + public override ActorReadResultType Type => ActorReadResultType.GetValue; +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/IActor.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/IActor.cs new file mode 100644 index 0000000000..9ba0babf10 --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/IActor.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.AI.Agents.Runtime; + +// Implemented by the Agent Framework (eg, Agent, Orchestration, Process, etc) +/// +/// Represents an actor in the actor system that can process messages and maintain state. +/// +public interface IActor : IAsyncDisposable +{ + /// + /// Runs the actor. + /// When the value returned from this method completes, the actor is considered stopped. + /// IActor is expected to call IActorContext.WatchMessagesAsync() to receive messages. + /// + /// A token to cancel the start operation. + /// A task representing the start operation. + ValueTask RunAsync(CancellationToken cancellationToken); +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/IActorClient.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/IActorClient.cs new file mode 100644 index 0000000000..aa51782dc0 --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/IActorClient.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.AI.Agents.Runtime; + +/// +/// Interface for sending requests to actors and managing responses. +/// +public interface IActorClient +{ + /// + /// Submits a request to an actor and gets a handle for the response. + /// This method is idempotent: if the request is already in progress, it will return the existing response. + /// + /// The request to send to the actor. + /// A token to cancel the operation. + /// A task representing the actor response handle. + ValueTask SendRequestAsync(ActorRequest request, CancellationToken cancellationToken); + + /// + /// Gets an already-running request by its identifier. + /// + /// The identifier of the actor processing the request. + /// The unique identifier of the request message. + /// A token to cancel the operation. + /// A task representing the actor response handle. + ValueTask GetResponseAsync(ActorId actorId, string messageId, CancellationToken cancellationToken); +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/IActorRuntimeBuilder.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/IActorRuntimeBuilder.cs new file mode 100644 index 0000000000..cc1a310854 --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/IActorRuntimeBuilder.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Extensions.AI.Agents.Runtime; + +/// +/// Builder interface for configuring actor types in the runtime. +/// +public interface IActorRuntimeBuilder +{ + /// + /// Registers an actor type with its factory method. + /// + /// The actor type to register. + /// The factory method to create instances of the actor. + void AddActorType(ActorType type, Func activator); +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/IActorRuntimeContext.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/IActorRuntimeContext.cs new file mode 100644 index 0000000000..43b87cdcba --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/IActorRuntimeContext.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.AI.Agents.Runtime; + +/// +/// Provides the runtime context for an actor, enabling it to interact with the actor system. +/// +public interface IActorRuntimeContext +{ + /// + /// Gets the identifier of the actor. + /// + ActorId ActorId { get; } + + /// + /// Watches for incoming requests and responses in the actor's inbox and outbox. + /// + /// A token to cancel the watch operation. + /// An asynchronous enumerable of actor notifications. + IAsyncEnumerable WatchMessagesAsync(CancellationToken cancellationToken = default); + + /// + /// Performs a batch of write operations atomically. + /// + /// The batch of write operations to perform. + /// A token to cancel the operation. + /// A task representing the write response. + ValueTask WriteAsync(ActorWriteOperationBatch operations, CancellationToken cancellationToken = default); + + /// + /// Performs a batch of read operations. + /// + /// The batch of read operations to perform. + /// A token to cancel the operation. + /// A task representing the read response. + ValueTask ReadAsync(ActorReadOperationBatch operations, CancellationToken cancellationToken = default); + + /// + /// Reports progress updates for streaming responses. + /// The messageId must correspond to a non-terminated request in the actor's inbox (Status is Pending). + /// + /// The identifier of the message being updated. + /// The sequence number for ordering progress updates. + /// The progress data. + void OnProgressUpdate(string messageId, int sequenceNumber, JsonElement data); +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/IActorStateStorage.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/IActorStateStorage.cs new file mode 100644 index 0000000000..1cd51cf074 --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/IActorStateStorage.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.AI.Agents.Runtime; + +/// +/// Interface for actor state storage operations, providing persistence for actor state data. +/// +public interface IActorStateStorage +{ + /// + /// Writes state changes to the actor's persistent storage. + /// + /// The identifier of the actor whose state is being modified. + /// The collection of write operations to perform. + /// The expected ETag for optimistic concurrency control. + /// A token to cancel the operation. + /// A task representing the write response with success status and updated ETag. + ValueTask WriteStateAsync(ActorId actorId, IReadOnlyCollection operations, string etag, CancellationToken cancellationToken = default); + + /// + /// Reads state data from the actor's persistent storage. + /// + /// The identifier of the actor whose state is being read. + /// The collection of read operations to perform. + /// A token to cancel the operation. + /// A task representing the read response with results and current ETag. + ValueTask ReadStateAsync(ActorId actorId, IReadOnlyCollection operations, CancellationToken cancellationToken = default); +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/InMemoryActorStateStorage.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/InMemoryActorStateStorage.cs new file mode 100644 index 0000000000..2063420ab3 --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/InMemoryActorStateStorage.cs @@ -0,0 +1,384 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.AI.Agents.Runtime; + +/// +/// Provides an in-memory implementation of for testing and development scenarios. +/// +/// +/// +/// This implementation stores all actor state in memory using concurrent dictionaries for thread safety. +/// State is not persisted across application restarts and is lost when the application terminates. +/// +/// +/// The implementation provides optimistic concurrency control using ETags. Each write operation must +/// provide the current ETag, and the operation will fail if the ETag has changed since the last read. +/// This ensures that concurrent modifications to the same actor state are handled correctly. +/// +/// +/// Supported operations: +/// +/// - Sets a key-value pair in the actor's state +/// - Removes a key from the actor's state +/// - Retrieves a value by key from the actor's state +/// - Lists keys in the actor's state with optional prefix filtering +/// +/// +/// +/// This implementation is suitable for: +/// +/// Unit testing scenarios +/// Development and prototyping +/// Single-process applications where persistence is not required +/// +/// +/// +/// For production scenarios requiring persistence, consider implementing a custom storage provider +/// that uses a database or other persistent storage mechanism. +/// +/// +/// +/// +/// // Create storage instance +/// var storage = new InMemoryActorStateStorage(); +/// var actorId = new ActorId("MyActor", "instance1"); +/// +/// // Write some state +/// var writeOps = new List<ActorStateWriteOperation> +/// { +/// new SetValueOperation("name", JsonSerializer.SerializeToElement("John")), +/// new SetValueOperation("age", JsonSerializer.SerializeToElement(30)) +/// }; +/// var writeResult = await storage.WriteStateAsync(actorId, writeOps, "0"); +/// +/// // Read the state back +/// var readOps = new List<ActorStateReadOperation> +/// { +/// new GetValueOperation("name"), +/// new ListKeysOperation(null), // List all keys +/// new ListKeysOperation(null, "prefix_") // List keys starting with "prefix_" +/// }; +/// var readResult = await storage.ReadStateAsync(actorId, readOps); +/// +/// +public sealed class InMemoryActorStateStorage : IActorStateStorage +{ + private static readonly ActivitySource ActivitySource = new("Microsoft.Extensions.AI.Agents.Runtime.Abstractions.InMemoryActorStateStorage"); + + private readonly ConcurrentDictionary _actorStates = new(); + private readonly object _lockObject = new(); + private long _globalETagCounter = 0; + + /// + /// Represents the internal state of an actor including its key-value pairs and ETag. + /// + private sealed class ActorState + { + public ConcurrentDictionary Data { get; } = new(); + public string ETag { get; set; } = "0"; + } + + /// + public ValueTask WriteStateAsync(ActorId actorId, IReadOnlyCollection operations, string etag, CancellationToken cancellationToken = default) + { + using var activity = ActivitySource.StartActivity("actor.state write"); + + if (operations is null) + { + throw new ArgumentNullException(nameof(operations)); + } + + if (etag is null) + { + throw new ArgumentNullException(nameof(etag)); + } + + cancellationToken.ThrowIfCancellationRequested(); + + // Set telemetry attributes + SetActorAttributes(activity, actorId); + SetStateAttributes(activity, "write", operations.Count, etag); + + try + { + lock (this._lockObject) + { + var actorState = this._actorStates.GetOrAdd(actorId, _ => new ActorState()); + + // Check ETag for optimistic concurrency control + if (actorState.ETag != etag) + { + activity?.SetTag("state.success", false); + activity?.SetTag("error.type", "etag_mismatch"); + activity?.SetStatus(ActivityStatusCode.Error, "ETag mismatch - concurrent modification detected"); + + // Return failure with current ETag + return new ValueTask(new WriteResponse(actorState.ETag, success: false)); + } + + // Apply all operations + var operationTypes = new List(); + foreach (var operation in operations) + { + switch (operation) + { + case SetValueOperation setValue: + actorState.Data[setValue.Key] = setValue.Value; + operationTypes.Add("set"); + break; + + case RemoveKeyOperation removeKey: + actorState.Data.TryRemove(removeKey.Key, out _); + operationTypes.Add("remove"); + break; + + default: + var errorMessage = $"Unsupported write operation type: {operation.GetType().Name}"; + var exception = new InvalidOperationException(errorMessage); + SetErrorAttributes(activity, exception); + throw exception; + } + } + + // Update ETag + var newETag = Interlocked.Increment(ref this._globalETagCounter).ToString(); + actorState.ETag = newETag; + + // Set success attributes + SetOperationStatus(activity, true); + activity?.SetTag("state.success", true); + activity?.SetTag("state.new_etag", newETag); + activity?.SetTag("state.operations", string.Join(",", operationTypes)); + + return new ValueTask(new WriteResponse(newETag, success: true)); + } + } + catch (Exception ex) + { + SetErrorAttributes(activity, ex); + throw; + } + } + + /// + public ValueTask ReadStateAsync(ActorId actorId, IReadOnlyCollection operations, CancellationToken cancellationToken = default) + { + using var activity = ActivitySource.StartActivity("actor.state read"); + + if (operations is null) + { + throw new ArgumentNullException(nameof(operations)); + } + + cancellationToken.ThrowIfCancellationRequested(); + + // Set telemetry attributes + SetActorAttributes(activity, actorId); + SetStateAttributes(activity, "read", operations.Count); + + try + { + var actorState = this._actorStates.GetOrAdd(actorId, _ => new ActorState()); + var results = new List(); + var operationTypes = new List(); + + foreach (var operation in operations) + { + switch (operation) + { + case GetValueOperation getValue: + var hasValue = actorState.Data.TryGetValue(getValue.Key, out var value); + results.Add(new GetValueResult(hasValue ? value : null)); + operationTypes.Add($"get:{getValue.Key}"); + break; + + case ListKeysOperation listKeys: + var keys = actorState.Data.Keys.ToList(); + + // Filter keys by prefix if provided + if (!string.IsNullOrEmpty(listKeys.KeyPrefix)) + { + keys = [.. keys.Where(key => key.StartsWith(listKeys.KeyPrefix, StringComparison.Ordinal))]; + } + + // Handle pagination if continuation token is provided + if (!string.IsNullOrEmpty(listKeys.ContinuationToken)) + { + // For this simple implementation, we'll parse the continuation token as an index + if (int.TryParse(listKeys.ContinuationToken, out int startIndex) && startIndex < keys.Count) + { + keys = [.. keys.Skip(startIndex)]; + } + else + { + keys = []; + } + } + + // For simplicity, we'll return all keys without pagination + // In a real implementation, you might want to implement proper pagination + results.Add(new ListKeysResult(keys.AsReadOnly(), continuationToken: null)); + operationTypes.Add($"list:{listKeys.KeyPrefix ?? "*"}"); + break; + + default: + var errorMessage = $"Unsupported read operation type: {operation.GetType().Name}"; + var exception = new InvalidOperationException(errorMessage); + SetErrorAttributes(activity, exception); + throw exception; + } + } + + // Set success attributes + SetOperationStatus(activity, true); + activity?.SetTag("state.etag", actorState.ETag); + activity?.SetTag("state.operations", string.Join(",", operationTypes)); + activity?.SetTag("state.success", true); + + return new ValueTask(new ReadResponse(actorState.ETag, results.AsReadOnly())); + } + catch (Exception ex) + { + SetErrorAttributes(activity, ex); + throw; + } + } + + /// + /// Clears all stored actor state. This method is primarily intended for testing scenarios. + /// + public void Clear() + { + lock (this._lockObject) + { + this._actorStates.Clear(); + Interlocked.Exchange(ref this._globalETagCounter, 0); + } + } + + /// + /// Gets the current count of actors that have state stored. + /// + /// The number of actors with stored state. + public int ActorCount => this._actorStates.Count; + + /// + /// Gets the current count of keys stored for a specific actor. + /// + /// The actor identifier. + /// The number of keys stored for the specified actor, or 0 if the actor has no state. + public int GetKeyCount(ActorId actorId) + { + return this._actorStates.TryGetValue(actorId, out var state) ? state.Data.Count : 0; + } + + /// + /// Gets the current ETag for a specific actor. + /// + /// The actor identifier. + /// The current ETag for the specified actor, or "0" if the actor has no state. + public string GetETag(ActorId actorId) + { + return this._actorStates.TryGetValue(actorId, out var state) ? state.ETag : "0"; + } + + /// + /// Sets actor attributes on an activity. + /// + /// The activity to set attributes on. + /// The actor ID. + private static void SetActorAttributes(Activity? activity, ActorId actorId) + { + if (activity == null) + { + return; + } + + activity.SetTag("actor.id", actorId.ToString()); + activity.SetTag("actor.type", actorId.Type.Name); + } + + /// + /// Sets state operation attributes on an activity. + /// + /// The activity to set attributes on. + /// The type of state operation. + /// Optional count of operations. + /// Optional ETag value. + private static void SetStateAttributes(Activity? activity, string operationType, int? operationCount = null, string? etag = null) + { + if (activity == null) + { + return; + } + + activity.SetTag("state.operation.type", operationType); + + if (operationCount.HasValue) + { + activity.SetTag("state.operation.count", operationCount.Value); + } + + if (!string.IsNullOrEmpty(etag)) + { + activity.SetTag("state.etag", etag); + } + } + + /// + /// Sets success/failure status on an activity. + /// + /// The activity to set status on. + /// Whether the operation was successful. + /// Optional error message for failures. + private static void SetOperationStatus(Activity? activity, bool success, string? errorMessage = null) + { + if (activity == null) + { + return; + } + + if (success) + { + activity.SetStatus(ActivityStatusCode.Ok); + } + else + { + activity.SetStatus(ActivityStatusCode.Error, errorMessage); + } + } + + /// + /// Sets error attributes on an activity. + /// + /// The activity to set error attributes on. + /// The exception that occurred. + private static void SetErrorAttributes(Activity? activity, Exception exception) + { + if (activity == null) + { + return; + } + + activity.SetTag("error.type", exception.GetType().Name); + activity.SetTag("error.message", exception.Message); + activity.SetStatus(ActivityStatusCode.Error, exception.Message); + + // Add exception event + activity.AddEvent(new ActivityEvent("exception", DateTimeOffset.UtcNow, new ActivityTagsCollection + { + ["error.type"] = exception.GetType().Name, + ["error.message"] = exception.Message, + ["error.stack_trace"] = exception.StackTrace + })); + } +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/JsonSerializerExtensions.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/JsonSerializerExtensions.cs new file mode 100644 index 0000000000..5f01bcc57d --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/JsonSerializerExtensions.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; + +namespace Microsoft.Extensions.AI.Agents.Runtime; + +/// +/// Provides extension methods for JSON serialization with source generation support. +/// +internal static class JsonSerializerExtensions +{ + /// + /// Gets the JsonTypeInfo for a type, preferring the one from options if available, + /// otherwise falling back to the source-generated context. + /// + /// The type to get JsonTypeInfo for. + /// The JsonSerializerOptions to check first. + /// The fallback JsonSerializerContext to use if not found in options. + /// The JsonTypeInfo for the requested type. + public static JsonTypeInfo GetTypeInfo(this JsonSerializerOptions options, JsonSerializerContext fallbackContext) + { + // Try to get from the options first (if a context is configured) + if (options.TypeInfoResolver?.GetTypeInfo(typeof(T), options) is JsonTypeInfo typeInfo) + { + return typeInfo; + } + + // Fall back to the provided source-generated context + return (JsonTypeInfo)fallbackContext.GetTypeInfo(typeof(T))!; + } +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ListKeysOperation.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ListKeysOperation.cs new file mode 100644 index 0000000000..981a66501d --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ListKeysOperation.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Extensions.AI.Agents.Runtime; + +/// +/// Represents an operation to list keys from an actor's state, with optional pagination support. +/// +/// Optional token for pagination to continue listing from a previous operation. +/// Optional prefix to filter keys. Only keys starting with this prefix will be returned. +public class ListKeysOperation(string? continuationToken, string? keyPrefix = null) : ActorStateReadOperation +{ + /// + /// Gets the continuation token for pagination. + /// + [JsonPropertyName("continuationToken")] + public string? ContinuationToken { get; } = continuationToken; + + /// + /// Gets the key prefix for filtering. Only keys starting with this prefix will be returned. + /// + [JsonPropertyName("keyPrefix")] + public string? KeyPrefix { get; } = keyPrefix; + + /// + /// Gets the type of the read operation. + /// + public override ActorReadOperationType Type => ActorReadOperationType.ListKeys; +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ListKeysResult.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ListKeysResult.cs new file mode 100644 index 0000000000..a177aeec59 --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ListKeysResult.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Microsoft.Extensions.AI.Agents.Runtime; + +/// +/// Represents the result of a list keys operation containing the found keys and optional continuation token. +/// +/// The collection of keys found in the actor's state. +/// Optional token for pagination to retrieve additional keys. +public class ListKeysResult(IReadOnlyCollection keys, string? continuationToken) : ActorReadResult +{ + /// + /// Gets the collection of keys found in the actor's state. + /// + [JsonPropertyName("keys")] + public IReadOnlyCollection Keys { get; } = keys; + + /// + /// Gets the continuation token for pagination. + /// + [JsonPropertyName("continuationToken")] + public string? ContinuationToken { get; } = continuationToken; + + /// + /// Gets the type of the read result operation. + /// + public override ActorReadResultType Type => ActorReadResultType.ListKeys; +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/Microsoft.Extensions.AI.Agents.Runtime.Abstractions.csproj b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/Microsoft.Extensions.AI.Agents.Runtime.Abstractions.csproj index 6941c59277..da893dbb4c 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/Microsoft.Extensions.AI.Agents.Runtime.Abstractions.csproj +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/Microsoft.Extensions.AI.Agents.Runtime.Abstractions.csproj @@ -5,12 +5,14 @@ $(ProjectsDebugTargetFrameworks) $(NoWarn);IDE1006;IDE0130 alpha + Microsoft.Extensions.AI.Agents.Runtime true true true + true @@ -23,6 +25,10 @@ + + + + diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ReadResponse.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ReadResponse.cs new file mode 100644 index 0000000000..6d7c6d74e1 --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ReadResponse.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Microsoft.Extensions.AI.Agents.Runtime; + +/// +/// The response of a read request for an actor. +/// +/// The actor's last-known ETag value. +/// The ordered collection of results. +public class ReadResponse(string eTag, IReadOnlyList results) +{ + /// + /// Gets the version of the state update. + /// + [JsonPropertyName("etag")] + public string ETag { get; } = eTag; + + /// + /// Gets the ordered collection of read operation results. + /// + [JsonPropertyName("results")] + public IReadOnlyList Results { get; } = results; +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/RemoveKeyOperation.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/RemoveKeyOperation.cs new file mode 100644 index 0000000000..722c6214e5 --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/RemoveKeyOperation.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Extensions.AI.Agents.Runtime; + +/// +/// Represents an operation to remove a key from an actor's state. +/// +/// The key to remove from the actor's state. +public sealed class RemoveKeyOperation(string Key) : ActorStateWriteOperation +{ + /// + /// Gets the key for the state operation. + /// + [JsonPropertyName("key")] + public string Key { get; } = Key; + + /// + /// Gets the type of the write operation. + /// + public override ActorWriteOperationType Type => ActorWriteOperationType.RemoveKey; +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/RequestStatus.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/RequestStatus.cs new file mode 100644 index 0000000000..b8d8366042 --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/RequestStatus.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Extensions.AI.Agents.Runtime; + +/// +/// Represents the status of a request in the actor system. +/// +public enum RequestStatus +{ + /// + /// The request is pending and has not yet been processed. + /// + [JsonStringEnumMemberName("pending")] + Pending, + + /// + /// The request has been completed successfully. + /// + [JsonStringEnumMemberName("completed")] + Completed, + + /// + /// The request has failed. + /// + [JsonStringEnumMemberName("failed")] + Failed, + + /// + /// The request was not found, possibly due to it being deleted or never existing. + /// + [JsonStringEnumMemberName("not_found")] + NotFound, +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/SendRequestOperation.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/SendRequestOperation.cs new file mode 100644 index 0000000000..80e7d50b70 --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/SendRequestOperation.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Extensions.AI.Agents.Runtime; + +/// +/// Represents an operation to send a request message to another actor. +/// +/// The request message to send. +public sealed class SendRequestOperation(ActorRequestMessage Message) : ActorMessageWriteOperation +{ + /// + /// Gets the message to send. + /// + [JsonPropertyName("message")] + public ActorRequestMessage Message { get; } = Message; + + /// + /// Gets the type of the write operation. + /// + public override ActorWriteOperationType Type => ActorWriteOperationType.SendRequest; +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/SetValueOperation.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/SetValueOperation.cs new file mode 100644 index 0000000000..6868915841 --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/SetValueOperation.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.Extensions.AI.Agents.Runtime; + +/// +/// Represents an operation to set a key-value pair in an actor's state. +/// +/// The key to set in the actor's state. +/// The value to associate with the key. +public sealed class SetValueOperation(string Key, JsonElement Value) : ActorStateWriteOperation +{ + /// + /// Gets the key for the state operation. + /// + [JsonPropertyName("key")] + public string Key { get; } = Key; + + /// + /// Gets the value for the state operation. + /// + [JsonPropertyName("value")] + public JsonElement Value { get; } = Value; + + /// + /// Gets the type of the write operation. + /// + public override ActorWriteOperationType Type => ActorWriteOperationType.SetValue; +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/UpdateRequestOperation.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/UpdateRequestOperation.cs new file mode 100644 index 0000000000..716976309f --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/UpdateRequestOperation.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.Extensions.AI.Agents.Runtime; + +/// +/// Represents an operation to update the status of an incoming request, possibly with a result. +/// The MessageId must correspond to a non-terminated request in the actor's inbox (Status is Pending). +/// +/// The identifier of the message to update. +/// The new status for the request. +/// The data associated with the status update (e.g., result for completed requests). +public sealed class UpdateRequestOperation(string MessageId, RequestStatus Status, JsonElement Data) : ActorMessageWriteOperation +{ + /// + /// Gets the identifier of the message to update. + /// + [JsonPropertyName("messageId")] + public string MessageId { get; } = MessageId; + + /// + /// Gets the new status for the request. + /// + [JsonPropertyName("status")] + public RequestStatus Status { get; } = Status; + + /// + /// Gets the data associated with the status update. + /// + [JsonPropertyName("data")] + public JsonElement Data { get; } = Data; + + /// + /// Gets the type of the write operation. + /// + public override ActorWriteOperationType Type => ActorWriteOperationType.UpdateRequest; +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/WriteResponse.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/WriteResponse.cs new file mode 100644 index 0000000000..072ae1ed1f --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/WriteResponse.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Extensions.AI.Agents.Runtime; + +/// +/// Represents the response of a write request for an actor. +/// +/// The actor's updated ETag value after the write operation. +/// Whether the write operation was successful. +public class WriteResponse(string eTag, bool success) +{ + /// + /// Gets the version of the state update. + /// + [JsonPropertyName("etag")] + public string ETag { get; } = eTag; + + /// + /// Whether the write operation was successful. + /// + /// + /// If false, the write operation may have failed due to a concurrency conflict or other issue. + /// In either case the property will contain the last known ETag value of the actor's state. + /// + [JsonPropertyName("success")] + public bool Success { get; } = success; +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/ActivityExtensions.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/ActivityExtensions.cs new file mode 100644 index 0000000000..36dc263a43 --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/ActivityExtensions.cs @@ -0,0 +1,409 @@ +// Copyright (c) Microsoft. All rights reserved. + +using static Microsoft.Extensions.AI.Agents.Runtime.ActorRuntimeOpenTelemetryConsts; + +namespace Microsoft.Extensions.AI.Agents.Runtime; + +/// +/// Helper methods for setting common telemetry attributes on activities. +/// +internal static class ActivityExtensions +{ + public const string ActorCreated = ActorRuntimeOpenTelemetryConsts.EventInfo.Names.ActorCreated; + public const string ActorStarted = ActorRuntimeOpenTelemetryConsts.EventInfo.Names.ActorStarted; + public const string MessageSent = ActorRuntimeOpenTelemetryConsts.EventInfo.Names.MessageSent; + public const string MessageReceived = ActorRuntimeOpenTelemetryConsts.EventInfo.Names.MessageReceived; + public const string RequestCompleted = ActorRuntimeOpenTelemetryConsts.EventInfo.Names.RequestCompleted; + + // Re-export common status values for convenience + public const string Started = "started"; + public const string Sent = "sent"; + public const string Enqueued = "enqueued"; + public const string Created = "created"; + public const string Found = "found"; + public const string HandleCreated = "handle_created"; + + /// + /// Sets common actor attributes on an activity. + /// + /// The activity to set attributes on. + /// The actor ID. + /// Optional operation name. + public static void SetActorAttributes(this System.Diagnostics.Activity? activity, ActorId actorId, string? operation = null) + { + if (activity == null) + { + return; + } + + activity.SetTag(ActorRuntimeOpenTelemetryConsts.Actor.Id, actorId.ToString()); + activity.SetTag(ActorRuntimeOpenTelemetryConsts.Actor.Type, actorId.Type.Name); + activity.SetTag(ActorRuntimeOpenTelemetryConsts.Actor.RpcSystem, ActorRuntimeOpenTelemetryConsts.Actor.SystemName); + + if (!string.IsNullOrEmpty(operation)) + { + activity.SetTag(ActorRuntimeOpenTelemetryConsts.Actor.Operation, operation); + } + } + + /// + /// Sets common message attributes on an activity. + /// + /// The activity to set attributes on. + /// The message ID. + /// Optional message type. + /// Optional message method. + public static void SetMessageAttributes(this System.Diagnostics.Activity? activity, string messageId, string? messageType = null, string? method = null) + { + if (activity == null) + { + return; + } + + activity.SetTag(Message.Id, messageId); + + if (!string.IsNullOrEmpty(messageType)) + { + activity.SetTag(Message.Type, messageType); + } + + if (!string.IsNullOrEmpty(method)) + { + activity.SetTag(Message.Method, method); + } + } + + /// + /// Sets common request attributes on an activity. + /// + /// The activity to set attributes on. + /// The request ID. + /// Optional request method. + /// Optional timeout value. + public static void SetRequestAttributes(this System.Diagnostics.Activity? activity, string requestId, string? method = null, System.TimeSpan? timeout = null) + { + if (activity == null) + { + return; + } + + activity.SetTag(Request.Id, requestId); + + if (!string.IsNullOrEmpty(method)) + { + activity.SetTag(Request.Method, method); + } + + if (timeout.HasValue) + { + activity.SetTag(Request.Timeout, timeout.Value.TotalMilliseconds); + } + } + + /// + /// Sets common state operation attributes on an activity. + /// + /// The activity to set attributes on. + /// The type of state operation. + /// Optional count of operations. + /// Optional ETag value. + public static void SetStateAttributes(this System.Diagnostics.Activity? activity, string operationType, int? operationCount = null, string? etag = null) + { + if (activity == null) + { + return; + } + + activity.SetTag(State.OperationType, operationType); + + if (operationCount.HasValue) + { + activity.SetTag(State.OperationCount, operationCount.Value); + } + + if (!string.IsNullOrEmpty(etag)) + { + activity.SetTag(State.ETag, etag); + } + } + + /// + /// Sets success/failure status on an activity. + /// + /// The activity to set status on. + /// Whether the operation was successful. + /// Optional error message for failures. + public static void SetOperationStatus(this System.Diagnostics.Activity? activity, bool success, string? errorMessage = null) + { + if (activity == null) + { + return; + } + + if (success) + { + activity.SetStatus(System.Diagnostics.ActivityStatusCode.Ok); + } + else + { + activity.SetStatus(System.Diagnostics.ActivityStatusCode.Error, errorMessage); + } + } + + /// + /// Sets error attributes on an activity. + /// + /// The activity to set error attributes on. + /// The exception that occurred. + /// Optional custom error type. + public static void SetErrorAttributes(this System.Diagnostics.Activity? activity, System.Exception exception, string? errorType = null) + { + if (activity == null) + { + return; + } + + activity.SetTag(ErrorInfo.Type, errorType ?? exception.GetType().Name); + activity.SetTag(ErrorInfo.Message, exception.Message); + activity.SetStatus(System.Diagnostics.ActivityStatusCode.Error, exception.Message); + + // Add exception event + activity.AddEvent(new System.Diagnostics.ActivityEvent("exception", System.DateTimeOffset.UtcNow, new System.Diagnostics.ActivityTagsCollection + { + [ErrorInfo.Type] = errorType ?? exception.GetType().Name, + [ErrorInfo.Message] = exception.Message, + [ErrorInfo.StackTrace] = exception.StackTrace + })); + } + + /// + /// Sets RPC-style attributes for actor operations. + /// + /// The activity to set attributes on. + /// The RPC service name. + /// The RPC method name. + public static void SetRpcAttributes(this System.Diagnostics.Activity? activity, string service, string method) + { + if (activity == null) + { + return; + } + + activity.SetTag(Actor.RpcSystem, Actor.SystemName); + activity.SetTag(Actor.RpcService, service); + activity.SetTag(Actor.RpcMethod, method); + } + + /// + /// Sets up complete telemetry for actor retrieval/creation operations. + /// + /// The activity to set attributes on. + /// The actor ID. + /// Whether the actor already exists. + /// Whether the actor was started. + public static void SetupActorOperation(this System.Diagnostics.Activity? activity, ActorId actorId, bool? exists = null, bool? started = null) + { + if (activity == null) + { + return; + } + + SetActorAttributes(activity, actorId); + SetRpcAttributes(activity, "ActorRuntime", "GetOrCreateActor"); + + if (exists.HasValue) + { + activity.SetTag(Actor.Exists, exists.Value); + } + + if (started.HasValue) + { + activity.SetTag(Actor.Started, started.Value); + } + } + + /// + /// Sets up complete telemetry for message operations. + /// + /// The activity to set attributes on. + /// The actor ID. + /// The message ID. + /// Optional message type. + /// Optional message method. + /// Optional message status. + public static void SetupMessageOperation(this System.Diagnostics.Activity? activity, ActorId actorId, string messageId, string? messageType = null, string? method = null, string? status = null) + { + if (activity == null) + { + return; + } + + SetActorAttributes(activity, actorId); + SetMessageAttributes(activity, messageId, messageType, method); + + if (!string.IsNullOrEmpty(status)) + { + activity.SetTag(Message.Status, status); + } + } + + /// + /// Sets up complete telemetry for request operations. + /// + /// The activity to set attributes on. + /// The actor ID. + /// The request ID. + /// Optional request method. + /// The RPC service name. + /// The RPC method name. + /// Optional timeout value. + public static void SetupRequestOperation(this System.Diagnostics.Activity? activity, ActorId actorId, string requestId, string? method = null, string service = "ActorClient", string rpcMethod = "SendRequest", System.TimeSpan? timeout = null) + { + if (activity == null) + { + return; + } + + SetActorAttributes(activity, actorId); + SetRequestAttributes(activity, requestId, method, timeout); + SetRpcAttributes(activity, service, rpcMethod); + } + + /// + /// Sets up complete telemetry for state operations. + /// + /// The activity to set attributes on. + /// The actor ID. + /// The type of state operation. + /// Optional count of operations. + /// Optional ETag value. + public static void SetupStateOperation(this System.Diagnostics.Activity? activity, ActorId actorId, string operationType, int? operationCount = null, string? etag = null) + { + if (activity == null) + { + return; + } + + SetActorAttributes(activity, actorId); + SetStateAttributes(activity, operationType, operationCount, etag); + } + + /// + /// Records successful completion of an operation with optional additional attributes. + /// + /// The activity to update. + /// Optional additional tags to set. + public static void RecordSuccess(this System.Diagnostics.Activity? activity, params (string key, object? value)[] additionalTags) + { + if (activity == null) + { + return; + } + + SetOperationStatus(activity, true); + + foreach (var (key, value) in additionalTags) + { + activity.SetTag(key, value); + } + } + + /// + /// Records failure of an operation with error details. + /// + /// The activity to update. + /// The exception that occurred. + /// Optional custom error type. + /// Optional additional tags to set. + public static void RecordFailure(this System.Diagnostics.Activity? activity, System.Exception exception, string? errorType = null, params (string key, object? value)[] additionalTags) + { + if (activity == null) + { + return; + } + + SetErrorAttributes(activity, exception, errorType); + + foreach (var (key, value) in additionalTags) + { + activity.SetTag(key, value); + } + } + + /// + /// Adds an event with common actor context. + /// + /// The activity to add the event to. + /// The name of the event. + /// The actor ID. + /// Optional additional event data. + public static void AddActorEvent(this System.Diagnostics.Activity? activity, string eventName, ActorId actorId, params (string key, object? value)[] additionalData) + { + if (activity == null) + { + return; + } + + var tags = new System.Diagnostics.ActivityTagsCollection + { + [Actor.Id] = actorId.ToString(), + [Actor.Type] = actorId.Type.Name + }; + + foreach (var (key, value) in additionalData) + { + tags[key] = value; + } + + activity.AddEvent(new System.Diagnostics.ActivityEvent(eventName, System.DateTimeOffset.UtcNow, tags)); + } + + /// + /// Records successful completion and adds an event in a single terse call. + /// + /// The activity to update. + /// The name of the event to add. + /// The actor ID for the event. + /// Status tags to set on the activity. + /// Additional event data. + public static void CompleteWithEvent(this System.Diagnostics.Activity? activity, string eventName, ActorId actorId, (string key, object? value)[] statusTags, params (string key, object? value)[] eventData) + { + if (activity == null) + { + return; + } + + RecordSuccess(activity, statusTags); + AddActorEvent(activity, eventName, actorId, eventData); + } + + /// + /// Complete with event - ultra-terse single-line calls. + /// + public static void Complete(this System.Diagnostics.Activity? activity, string @event, ActorId actor, string status, params (string, object?)[] data) => + CompleteWithEvent(activity, @event, actor, [(Request.Status, status)], data); + + /// + /// Complete with multiple status tags and event. + /// + public static void Complete(this System.Diagnostics.Activity? activity, string @event, ActorId actor, (string, object?)[] status, params (string, object?)[] data) => + CompleteWithEvent(activity, @event, actor, status, data); + + /// + /// Record success with single status. + /// + public static void Success(this System.Diagnostics.Activity? activity, string status) => + RecordSuccess(activity, (Request.Status, status)); + + /// + /// Add actor event. + /// + public static void Event(this System.Diagnostics.Activity? activity, string @event, ActorId actor, params (string, object?)[] data) => + AddActorEvent(activity, @event, actor, data); + + /// + /// Record failure. + /// + public static void Fail(this System.Diagnostics.Activity? activity, System.Exception exception, string? status = null) => + RecordFailure(activity, exception, null, status != null ? (Request.Status, status) : default); +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/ActorRuntimeBuilder.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/ActorRuntimeBuilder.cs new file mode 100644 index 0000000000..6caa4d4539 --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/ActorRuntimeBuilder.cs @@ -0,0 +1,96 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Microsoft.Extensions.AI.Agents.Runtime; + +/// +/// Internal implementation of that manages actor type registrations +/// and their associated factory methods for the actor runtime system. +/// +internal sealed class ActorRuntimeBuilder : IActorRuntimeBuilder +{ + private readonly IHostApplicationBuilder _builder; + + /// + /// Gets the collection of registered actor types and their corresponding factory methods. + /// + /// + /// A dictionary where keys are instances and values are factory functions + /// that create instances given an and . + /// + public Dictionary> ActorFactories { get; } = new(); + + /// + /// Gets or creates an instance for the specified host application builder. + /// If an instance already exists in the service collection, it returns the existing instance. + /// Otherwise, it creates a new instance and registers it as a singleton service. + /// + /// The host application builder to associate with the actor runtime builder. + /// + /// An instance that can be used to configure actor types. + /// + /// Thrown when is null. + public static ActorRuntimeBuilder GetOrAdd(IHostApplicationBuilder builder) + { + Microsoft.Shared.Diagnostics.Throw.IfNull(builder); + var services = builder.Services; + var descriptor = services.FirstOrDefault(s => s.ImplementationInstance is ActorRuntimeBuilder); + if (descriptor?.ImplementationInstance is not ActorRuntimeBuilder instance) + { + instance = new ActorRuntimeBuilder(builder); + services.Add(ServiceDescriptor.Singleton(instance)); + instance.ConfigureServices(services); + } + + return instance; + } + + /// + /// Initializes a new instance of the class. + /// + /// The host application builder to associate with this actor runtime builder. + private ActorRuntimeBuilder(IHostApplicationBuilder builder) + { + this._builder = builder; + } + + /// + /// Registers an actor type with its factory method in the actor runtime. + /// + /// The actor type to register. + /// + /// The factory method that creates instances of the actor. This function receives an + /// for dependency injection and an + /// for the actor's runtime context, and returns an instance. + /// + /// + /// Thrown when an actor type with the same name is already registered. + /// + /// + /// Each actor type can only be registered once. Attempting to register the same actor type + /// multiple times will result in an exception being thrown by the underlying dictionary. + /// + public void AddActorType(ActorType type, Func activator) + { + this.ActorFactories.Add(type, activator); + } + + private void ConfigureServices(IServiceCollection services) + { + services.AddSingleton(this); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(sp => + { + var jsonSerializerOptions = sp.GetService() ?? new(); + var actorStateStorage = sp.GetRequiredService(); + return new InProcessActorRuntime(sp, this.ActorFactories, actorStateStorage, jsonSerializerOptions); + }); + } +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/ActorRuntimeHostingExtensions.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/ActorRuntimeHostingExtensions.cs new file mode 100644 index 0000000000..88f120c9c2 --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/ActorRuntimeHostingExtensions.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.Extensions.Hosting; + +namespace Microsoft.Extensions.AI.Agents.Runtime; + +/// +/// Provides extension methods for configuring actor runtime services in a host application. +/// +public static class ActorRuntimeHostingExtensions +{ + /// + /// Adds actor runtime services to the specified host application builder. + /// + /// The to configure. + /// An that can be used to further configure the actor runtime. + /// Thrown when is null. + public static IActorRuntimeBuilder AddActorRuntime(this IHostApplicationBuilder builder) + { + return ActorRuntimeBuilder.GetOrAdd(builder); + } +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/ActorRuntimeJsonContext.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/ActorRuntimeJsonContext.cs new file mode 100644 index 0000000000..833d6324e5 --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/ActorRuntimeJsonContext.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.Extensions.AI.Agents.Runtime; + +[JsonSourceGenerationOptions( + JsonSerializerDefaults.Web, + UseStringEnumConverter = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = false)] +[JsonSerializable(typeof(string))] +internal sealed partial class ActorRuntimeJsonContext : JsonSerializerContext +{ +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/ActorRuntimeOpenTelemetryConsts.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/ActorRuntimeOpenTelemetryConsts.cs new file mode 100644 index 0000000000..4040807b2c --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/ActorRuntimeOpenTelemetryConsts.cs @@ -0,0 +1,782 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Extensions.AI.Agents.Runtime; + +/// +/// Provides constants used by actor runtime telemetry services following OpenTelemetry semantic conventions. +/// Extends the base agent telemetry with runtime-specific attributes and operations. +/// +internal static class ActorRuntimeOpenTelemetryConsts +{ + /// + /// The default source name for actor runtime telemetry. + /// + public const string DefaultSourceName = "Microsoft.Extensions.AI.Agents.Runtime"; + + /// + /// The default source name for in-process actor runtime telemetry. + /// + public const string InProcessSourceName = "Microsoft.Extensions.AI.Agents.Runtime.InProcess"; + + /// + /// The unit for count measurements. + /// + public const string CountUnit = "count"; + + /// + /// The unit for byte measurements. + /// + public const string ByteUnit = "byte"; + + /// + /// Constants for runtime operation names following OpenTelemetry semantic conventions. + /// These operations align with RPC and GenAI conventions where applicable. + /// + public static class Operations + { + /// + /// Actor creation operation. + /// + public const string CreateActor = "create_actor"; + + /// + /// Actor retrieval operation. + /// + public const string GetActor = "get_actor"; + + /// + /// Actor invocation operation (aligns with GenAI agent invoke conventions). + /// + public const string InvokeActor = "invoke_actor"; + + /// + /// Actor start operation. + /// + public const string StartActor = "start_actor"; + + /// + /// Actor stop operation. + /// + public const string StopActor = "stop_actor"; + + /// + /// Actor dispose operation. + /// + public const string DisposeActor = "dispose_actor"; + + /// + /// Message send operation. + /// + public const string SendMessage = "send_message"; + + /// + /// Message receive operation. + /// + public const string ReceiveMessage = "receive_message"; + + /// + /// Message process operation. + /// + public const string ProcessMessage = "process_message"; + + /// + /// Request send operation (follows RPC client pattern). + /// + public const string SendRequest = "send_request"; + + /// + /// Request receive operation (follows RPC server pattern). + /// + public const string ReceiveRequest = "receive_request"; + + /// + /// Request process operation. + /// + public const string ProcessRequest = "process_request"; + + /// + /// Response send operation. + /// + public const string SendResponse = "send_response"; + + /// + /// Response receive operation. + /// + public const string ReceiveResponse = "receive_response"; + + /// + /// Progress update operation. + /// + public const string ProgressUpdate = "progress_update"; + + /// + /// State read operation. + /// + public const string StateRead = "state_read"; + + /// + /// State write operation. + /// + public const string StateWrite = "state_write"; + + /// + /// Actor runtime initialization operation. + /// + public const string InitializeRuntime = "initialize_runtime"; + + /// + /// Actor runtime shutdown operation. + /// + public const string ShutdownRuntime = "shutdown_runtime"; + } + + /// + /// Constants for span naming patterns following OpenTelemetry semantic conventions. + /// Span names should be low-cardinality and follow the pattern: {namespace} {operation_name} [{target}] + /// + public static class SpanNames + { + /// + /// Base pattern for actor operations: "actor {operation}" + /// + public const string ActorOperationPattern = "actor {0}"; + + /// + /// Pattern for actor operations with specific actor type: "actor {operation} {actor_type}" + /// + public const string ActorOperationWithTypePattern = "actor {0} {1}"; + + /// + /// Pattern for message operations: "actor.message {operation}" + /// + public const string MessageOperationPattern = "actor.message {0}"; + + /// + /// Pattern for request operations: "actor.request {operation}" + /// + public const string RequestOperationPattern = "actor.request {0}"; + + /// + /// Pattern for state operations: "actor.state {operation}" + /// + public const string StateOperationPattern = "actor.state {0}"; + + /// + /// Pattern for runtime operations: "actor.runtime {operation}" + /// + public const string RuntimeOperationPattern = "actor.runtime {0}"; + + /// + /// Formats a span name for actor operations. + /// + /// The operation name + /// Formatted span name + public static string FormatActorOperation(string operation) => $"actor {operation}"; + + /// + /// Formats a span name for actor operations with actor type. + /// + /// The operation name + /// The actor type + /// Formatted span name + public static string FormatActorOperationWithType(string operation, string actorType) => $"actor {operation} {actorType}"; + + /// + /// Formats a span name for message operations. + /// + /// The operation name + /// Formatted span name + public static string FormatMessageOperation(string operation) => $"actor.message {operation}"; + + /// + /// Formats a span name for request operations. + /// + /// The operation name + /// Formatted span name + public static string FormatRequestOperation(string operation) => $"actor.request {operation}"; + + /// + /// Formats a span name for state operations. + /// + /// The operation name + /// Formatted span name + public static string FormatStateOperation(string operation) => $"actor.state {operation}"; + + /// + /// Formats a span name for runtime operations. + /// + /// The operation name + /// Formatted span name + public static string FormatRuntimeOperation(string operation) => $"actor.runtime {operation}"; + } + + /// + /// Constants for actor-related telemetry attributes. + /// + public static class Actor + { + /// + /// The attribute name for the actor ID. + /// + public const string Id = "actor.id"; + + /// + /// The attribute name for the actor type. + /// + public const string Type = "actor.type"; + + /// + /// The attribute name for the actor key. + /// + public const string Key = "actor.key"; + + /// + /// The attribute name for the actor operation. + /// + public const string Operation = "actor.operation"; + + /// + /// The attribute name for whether the actor exists. + /// + public const string Exists = "actor.exists"; + + /// + /// The attribute name for whether the actor was started. + /// + public const string Started = "actor.started"; + + /// + /// The attribute name for the actor runtime type. + /// + public const string RuntimeType = "actor.runtime.type"; + + /// + /// The attribute name for the actor state. + /// + public const string State = "actor.state"; + + /// + /// RPC system identifier for actor runtime (follows RPC semantic conventions). + /// + public const string RpcSystem = "rpc.system"; + + /// + /// RPC service name for actor runtime (follows RPC semantic conventions). + /// + public const string RpcService = "rpc.service"; + + /// + /// RPC method name for actor runtime (follows RPC semantic conventions). + /// + public const string RpcMethod = "rpc.method"; + + /// + /// The system name for actor runtime operations. + /// + public const string SystemName = "actor_runtime"; + + /// + /// Constants for actor lifecycle attributes. + /// + public static class Lifecycle + { + /// + /// The attribute name for the actor creation time. + /// + public const string CreatedAt = "actor.lifecycle.created_at"; + + /// + /// The attribute name for the actor start time. + /// + public const string StartedAt = "actor.lifecycle.started_at"; + + /// + /// The attribute name for the actor stop time. + /// + public const string StoppedAt = "actor.lifecycle.stopped_at"; + + /// + /// The attribute name for the actor uptime. + /// + public const string Uptime = "actor.lifecycle.uptime"; + } + + /// + /// Constants for actor context attributes. + /// + public static class Context + { + /// + /// The attribute name for the actor context type. + /// + public const string Type = "actor.context.type"; + + /// + /// The attribute name for the actor context status. + /// + public const string Status = "actor.context.status"; + + /// + /// The attribute name for the actor context error. + /// + public const string Error = "actor.context.error"; + } + + /// + /// Constants for actor performance metrics. + /// + public static class Performance + { + /// + /// The attribute name for messages processed count. + /// + public const string MessagesProcessed = "actor.performance.messages_processed"; + + /// + /// The attribute name for requests processed count. + /// + public const string RequestsProcessed = "actor.performance.requests_processed"; + + /// + /// The attribute name for processing time. + /// + public const string ProcessingTime = "actor.performance.processing_time"; + + /// + /// The attribute name for queue size. + /// + public const string QueueSize = "actor.performance.queue_size"; + } + } + + /// + /// Constants for message-related telemetry attributes. + /// + public static class Message + { + /// + /// The attribute name for the message ID. + /// + public const string Id = "message.id"; + + /// + /// The attribute name for the message type. + /// + public const string Type = "message.type"; + + /// + /// The attribute name for the message method. + /// + public const string Method = "message.method"; + + /// + /// The attribute name for the message size in bytes. + /// + public const string Size = "message.size"; + + /// + /// The attribute name for the message timestamp. + /// + public const string Timestamp = "message.timestamp"; + + /// + /// The attribute name for the message sender. + /// + public const string Sender = "message.sender"; + + /// + /// The attribute name for the message recipient. + /// + public const string Recipient = "message.recipient"; + + /// + /// The attribute name for the message status. + /// + public const string Status = "message.status"; + + /// + /// The attribute name for the message sequence number. + /// + public const string SequenceNumber = "message.sequence_number"; + + /// + /// Constants for message processing attributes. + /// + public static class Processing + { + /// + /// The attribute name for processing start time. + /// + public const string StartTime = "message.processing.start_time"; + + /// + /// The attribute name for processing end time. + /// + public const string EndTime = "message.processing.end_time"; + + /// + /// The attribute name for processing duration. + /// + public const string Duration = "message.processing.duration"; + + /// + /// The attribute name for processing status. + /// + public const string Status = "message.processing.status"; + + /// + /// The attribute name for processing error. + /// + public const string Error = "message.processing.error"; + } + } + + /// + /// Constants for request-related telemetry attributes. + /// + public static class Request + { + /// + /// The attribute name for the request ID. + /// + public const string Id = "request.id"; + + /// + /// The attribute name for the request method. + /// + public const string Method = "request.method"; + + /// + /// The attribute name for the request status. + /// + public const string Status = "request.status"; + + /// + /// The attribute name for the request timeout. + /// + public const string Timeout = "request.timeout"; + + /// + /// The attribute name for whether the request was cancelled. + /// + public const string Cancelled = "request.cancelled"; + + /// + /// The attribute name for the request retry count. + /// + public const string RetryCount = "request.retry_count"; + } + + /// + /// Constants for response-related telemetry attributes. + /// + public static class Response + { + /// + /// The attribute name for the response ID. + /// + public const string Id = "response.id"; + + /// + /// The attribute name for the response status. + /// + public const string Status = "response.status"; + + /// + /// The attribute name for the response size. + /// + public const string Size = "response.size"; + + /// + /// The attribute name for the response type. + /// + public const string Type = "response.type"; + } + + /// + /// Constants for state-related telemetry attributes. + /// + public static class State + { + /// + /// The attribute name for the state operation type. + /// + public const string OperationType = "state.operation.type"; + + /// + /// The attribute name for the state operation count. + /// + public const string OperationCount = "state.operation.count"; + + /// + /// The attribute name for the state result count. + /// + public const string ResultCount = "state.result.count"; + + /// + /// The attribute name for the state operation success. + /// + public const string Success = "state.success"; + + /// + /// The attribute name for the state ETag. + /// + public const string ETag = "state.etag"; + + /// + /// The attribute name for the state size. + /// + public const string Size = "state.size"; + + /// + /// The attribute name for the state key. + /// + public const string Key = "state.key"; + } + + /// + /// Constants for runtime client metrics. + /// + public static class Client + { + /// + /// Constants for operation duration metrics. + /// + public static class OperationDuration + { + /// + /// The description for the operation duration metric. + /// + public const string Description = "Measures the duration of actor runtime operations"; + + /// + /// The name for the operation duration metric. + /// + public const string Name = "actor.runtime.client.operation.duration"; + + /// + /// The explicit bucket boundaries for the operation duration histogram. + /// + public static readonly double[] ExplicitBucketBoundaries = [0.001, 0.005, 0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 20.0, 50.0]; + } + + /// + /// Constants for message count metrics. + /// + public static class MessageCount + { + /// + /// The description for the message count metric. + /// + public const string Description = "Measures the number of messages processed by actors"; + + /// + /// The name for the message count metric. + /// + public const string Name = "actor.runtime.client.message.count"; + } + + /// + /// Constants for request count metrics. + /// + public static class RequestCount + { + /// + /// The description for the request count metric. + /// + public const string Description = "Measures the number of requests processed by actors"; + + /// + /// The name for the request count metric. + /// + public const string Name = "actor.runtime.client.request.count"; + } + + /// + /// Constants for actor count metrics. + /// + public static class ActorCount + { + /// + /// The description for the actor count metric. + /// + public const string Description = "Measures the number of active actors"; + + /// + /// The name for the actor count metric. + /// + public const string Name = "actor.runtime.client.actor.count"; + } + + /// + /// Constants for queue size metrics. + /// + public static class QueueSize + { + /// + /// The description for the queue size metric. + /// + public const string Description = "Measures the size of actor message queues"; + + /// + /// The name for the queue size metric. + /// + public const string Name = "actor.runtime.client.queue.size"; + + /// + /// The explicit bucket boundaries for the queue size histogram. + /// + public static readonly int[] ExplicitBucketBoundaries = [0, 1, 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000]; + } + + /// + /// Constants for state operation metrics. + /// + public static class StateOperations + { + /// + /// The description for the state operations metric. + /// + public const string Description = "Measures the number of state operations"; + + /// + /// The name for the state operations metric. + /// + public const string Name = "actor.runtime.client.state.operations"; + } + } + + /// + /// Constants for error attributes. + /// + public static class ErrorInfo + { + /// + /// The attribute name for the error type (follows OpenTelemetry error conventions). + /// + public const string Type = "error.type"; + + /// + /// The attribute name for the error message. + /// + public const string Message = "error.message"; + + /// + /// The attribute name for the error stack trace. + /// + public const string StackTrace = "error.stack_trace"; + + /// + /// Well-known error type for unknown errors. + /// + public const string TypeOther = "_OTHER"; + + /// + /// Well-known error types for actor runtime operations. + /// + public static class Types + { + /// + /// Actor not found error. + /// + public const string ActorNotFound = "actor_not_found"; + + /// + /// Actor already exists error. + /// + public const string ActorAlreadyExists = "actor_already_exists"; + + /// + /// Message delivery failure. + /// + public const string MessageDeliveryFailure = "message_delivery_failure"; + + /// + /// Request timeout error. + /// + public const string RequestTimeout = "request_timeout"; + + /// + /// State operation failure. + /// + public const string StateOperationFailure = "state_operation_failure"; + + /// + /// Runtime initialization failure. + /// + public const string RuntimeInitializationFailure = "runtime_initialization_failure"; + } + } + + /// + /// Constants for event attributes and well-known event names. + /// + public static class EventInfo + { + /// + /// The attribute name for the event name. + /// + public const string Name = "event.name"; + + /// + /// The attribute name for the event data. + /// + public const string Data = "event.data"; + + /// + /// The attribute name for the event timestamp. + /// + public const string Timestamp = "event.timestamp"; + + /// + /// Well-known event names for actor runtime operations. + /// + public static class Names + { + /// + /// Actor created event. + /// + public const string ActorCreated = "actor.created"; + + /// + /// Actor started event. + /// + public const string ActorStarted = "actor.started"; + + /// + /// Actor stopped event. + /// + public const string ActorStopped = "actor.stopped"; + + /// + /// Message sent event. + /// + public const string MessageSent = "actor.message.sent"; + + /// + /// Message received event. + /// + public const string MessageReceived = "actor.message.received"; + + /// + /// Request completed event. + /// + public const string RequestCompleted = "actor.request.completed"; + + /// + /// State updated event. + /// + public const string StateUpdated = "actor.state.updated"; + + /// + /// Runtime initialized event. + /// + public const string RuntimeInitialized = "actor.runtime.initialized"; + + /// + /// Runtime shutdown event. + /// + public const string RuntimeShutdown = "actor.runtime.shutdown"; + } + } +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/InProcessActorContext.Log.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/InProcessActorContext.Log.cs new file mode 100644 index 0000000000..1c23a88c02 --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/InProcessActorContext.Log.cs @@ -0,0 +1,131 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.Logging; + +namespace Microsoft.Extensions.AI.Agents.Runtime; + +/// +/// High-performance logging messages using LoggerMessage source generator for InProcessActorContext. +/// +internal static partial class Log +{ + // Actor context lifecycle logging + [LoggerMessage( + Level = LogLevel.Information, + Message = "Actor context created: ActorId={ActorId}")] + public static partial void ActorContextCreated(ILogger logger, string actorId); + + [LoggerMessage( + Level = LogLevel.Information, + Message = "Actor context starting: ActorId={ActorId}")] + public static partial void ActorContextStarting(ILogger logger, string actorId); + + [LoggerMessage( + Level = LogLevel.Information, + Message = "Actor context started: ActorId={ActorId}")] + public static partial void ActorContextStarted(ILogger logger, string actorId); + + [LoggerMessage( + Level = LogLevel.Information, + Message = "Actor context disposing: ActorId={ActorId}")] + public static partial void ActorContextDisposing(ILogger logger, string actorId); + + [LoggerMessage( + Level = LogLevel.Information, + Message = "Actor context disposed: ActorId={ActorId}")] + public static partial void ActorContextDisposed(ILogger logger, string actorId); + + // Message handling logging + [LoggerMessage( + Level = LogLevel.Debug, + Message = "Message enqueued: ActorId={ActorId}, MessageId={MessageId}, Type={MessageType}")] + public static partial void MessageEnqueued(ILogger logger, string actorId, string messageId, string messageType); + + [LoggerMessage( + Level = LogLevel.Debug, + Message = "Message yielded: ActorId={ActorId}, MessageId={MessageId}, Type={MessageType}, Count={MessageCount}")] + public static partial void MessageYielded(ILogger logger, string actorId, string messageId, string messageType, int messageCount); + + [LoggerMessage( + Level = LogLevel.Debug, + Message = "Watch messages started: ActorId={ActorId}")] + public static partial void WatchMessagesStarted(ILogger logger, string actorId); + + [LoggerMessage( + Level = LogLevel.Debug, + Message = "Watch messages completed: ActorId={ActorId}, TotalMessages={MessageCount}")] + public static partial void WatchMessagesCompleted(ILogger logger, string actorId, int messageCount); + + // Request handling logging + [LoggerMessage( + Level = LogLevel.Debug, + Message = "Send request started: ActorId={ActorId}, MessageId={MessageId}")] + public static partial void SendRequestStarted(ILogger logger, string actorId, string messageId); + + [LoggerMessage( + Level = LogLevel.Debug, + Message = "Request message created: ActorId={ActorId}, MessageId={MessageId}, Method={Method}")] + public static partial void RequestMessageCreated(ILogger logger, string actorId, string messageId, string method); + + [LoggerMessage( + Level = LogLevel.Debug, + Message = "Request message found in inbox: ActorId={ActorId}, MessageId={MessageId}")] + public static partial void RequestMessageFound(ILogger logger, string actorId, string messageId); + + [LoggerMessage( + Level = LogLevel.Debug, + Message = "Response handle created: ActorId={ActorId}, MessageId={MessageId}")] + public static partial void ResponseHandleCreated(ILogger logger, string actorId, string messageId); + + // Progress update logging + [LoggerMessage( + Level = LogLevel.Debug, + Message = "Progress update received: ActorId={ActorId}, MessageId={MessageId}, SequenceNumber={SequenceNumber}")] + public static partial void ProgressUpdateReceived(ILogger logger, string actorId, string messageId, int sequenceNumber); + + [LoggerMessage( + Level = LogLevel.Debug, + Message = "Progress update published: ActorId={ActorId}, MessageId={MessageId}")] + public static partial void ProgressUpdatePublished(ILogger logger, string actorId, string messageId); + + [LoggerMessage( + Level = LogLevel.Error, + Message = "Progress update failed: ActorId={ActorId}, MessageId={MessageId}, Reason={Reason}")] + public static partial void ProgressUpdateFailed(ILogger logger, string actorId, string messageId, string reason); + + // Storage operation logging + [LoggerMessage( + Level = LogLevel.Debug, + Message = "Read operation started: ActorId={ActorId}, OperationCount={OperationCount}")] + public static partial void ReadOperationStarted(ILogger logger, string actorId, int operationCount); + + [LoggerMessage( + Level = LogLevel.Debug, + Message = "Read operation completed: ActorId={ActorId}, ResultCount={ResultCount}")] + public static partial void ReadOperationCompleted(ILogger logger, string actorId, int resultCount); + + [LoggerMessage( + Level = LogLevel.Debug, + Message = "Write operation started: ActorId={ActorId}, OperationCount={OperationCount}")] + public static partial void WriteOperationStarted(ILogger logger, string actorId, int operationCount); + + [LoggerMessage( + Level = LogLevel.Debug, + Message = "Write operation completed: ActorId={ActorId}, Success={Success}")] + public static partial void WriteOperationCompleted(ILogger logger, string actorId, bool success); + + [LoggerMessage( + Level = LogLevel.Debug, + Message = "Send request operation encountered: ActorId={ActorId}")] + public static partial void SendRequestOperationEncountered(ILogger logger, string actorId); + + [LoggerMessage( + Level = LogLevel.Debug, + Message = "Update request operation processing: ActorId={ActorId}, MessageId={MessageId}")] + public static partial void UpdateRequestOperationProcessing(ILogger logger, string actorId, string messageId); + + [LoggerMessage( + Level = LogLevel.Debug, + Message = "Operation processing completed: ActorId={ActorId}, ProcessedCount={ProcessedCount}")] + public static partial void OperationProcessingCompleted(ILogger logger, string actorId, int processedCount); +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/InProcessActorContext.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/InProcessActorContext.cs new file mode 100644 index 0000000000..5735f1ad88 --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/InProcessActorContext.cs @@ -0,0 +1,426 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using static Microsoft.Extensions.AI.Agents.Runtime.ActivityExtensions; +using Tel = Microsoft.Extensions.AI.Agents.Runtime.ActorRuntimeOpenTelemetryConsts; + +namespace Microsoft.Extensions.AI.Agents.Runtime; + +internal sealed class InProcessActorContext : IActorRuntimeContext, IAsyncDisposable, IDisposable +{ + private static readonly ActivitySource ActivitySource = new(ActorRuntimeOpenTelemetryConsts.InProcessSourceName); + + private readonly CancellationTokenSource _cts = new(); + private readonly Channel _pendingMessages = Channel.CreateUnbounded(); + private readonly object _lock = new(); + private readonly Dictionary _inbox = []; + private readonly InProcessActorRuntime _runtime; + private readonly IActor _actorInstance; + private readonly ILogger _logger; + private Task? _actorRunTask; + + public InProcessActorContext( + ActorId ActorId, + InProcessActorRuntime runtime, + Func actorFactory) + { + this._runtime = runtime; + this.ActorId = ActorId; + this._logger = runtime.Services.GetRequiredService>(); + this._actorInstance = actorFactory(runtime.Services, this); + + Log.ActorContextCreated(this._logger, this.ActorId.ToString()); + } + + public ActorId ActorId { get; } + + private IActorStateStorage Storage => this._runtime.Storage; + + public void Start() + { + using var activity = ActivitySource.StartActivity( + ActorRuntimeOpenTelemetryConsts.SpanNames.FormatActorOperation(ActorRuntimeOpenTelemetryConsts.Operations.StartActor)); + + activity.SetActorAttributes(this.ActorId, "start"); + + try + { + Log.ActorContextStarting(this._logger, this.ActorId.ToString()); + this._actorRunTask = this._actorInstance.RunAsync(this._cts.Token).AsTask(); + Log.ActorContextStarted(this._logger, this.ActorId.ToString()); + + activity.Complete(ActorStarted, this.ActorId, [(Tel.Actor.Started, true)]); + } + catch (Exception ex) + { + activity.Fail(ex); + throw; + } + } + + public void EnqueueMessage(ActorMessage message) + { + using var activity = ActivitySource.StartActivity( + ActorRuntimeOpenTelemetryConsts.SpanNames.FormatMessageOperation(ActorRuntimeOpenTelemetryConsts.Operations.ReceiveMessage)); + + var messageId = message switch + { + ActorRequestMessage requestMessage => requestMessage.MessageId, + ActorResponseMessage responseMessage => responseMessage.MessageId, + _ => "unknown" + }; + + // Set message tracing attributes + activity.SetActorAttributes(this.ActorId); + activity.SetMessageAttributes(messageId, message.Type.ToString()); + + try + { + Log.MessageEnqueued(this._logger, this.ActorId.ToString(), messageId, message.Type.ToString()); + this._pendingMessages.Writer.TryWrite(message); + + activity.Complete(MessageReceived, this.ActorId, Enqueued, + (Tel.Message.Id, messageId), (Tel.Message.Type, message.Type.ToString())); + } + catch (Exception ex) + { + activity.RecordFailure(ex, null, (ActorRuntimeOpenTelemetryConsts.Message.Status, "failed")); + throw; + } + } + + public ActorResponseHandle SendRequest(ActorRequest request) + { + using var activity = ActivitySource.StartActivity( + ActorRuntimeOpenTelemetryConsts.SpanNames.FormatRequestOperation(ActorRuntimeOpenTelemetryConsts.Operations.ProcessRequest)); + + activity.SetupRequestOperation(this.ActorId, request.MessageId, request.Method, "ActorContext", "SendRequest"); + + Log.SendRequestStarted(this._logger, this.ActorId.ToString(), request.MessageId); + + try + { + lock (this._lock) + { + string requestStatus; + if (!this._inbox.TryGetValue(request.MessageId, out var entry)) + { + var requestMessage = new ActorRequestMessage(request.MessageId) + { + Method = request.Method, + Params = request.Params + }; + + entry = this._inbox[request.MessageId] = new(requestMessage); + this._pendingMessages.Writer.TryWrite(requestMessage); + Log.RequestMessageCreated(this._logger, this.ActorId.ToString(), request.MessageId, request.Method); + requestStatus = "created"; + } + else + { + Log.RequestMessageFound(this._logger, this.ActorId.ToString(), request.MessageId); + requestStatus = "found"; + } + + var handle = new InProcessActorResponseHandle(this, entry); + Log.ResponseHandleCreated(this._logger, this.ActorId.ToString(), request.MessageId); + + activity.Complete(RequestCompleted, this.ActorId, [(Tel.Request.Status, requestStatus), (Tel.Response.Status, HandleCreated)], + (Tel.Message.Id, request.MessageId), (Tel.Message.Method, request.Method)); + + return handle; + } + } + catch (Exception ex) + { + activity.RecordFailure(ex, null, (ActorRuntimeOpenTelemetryConsts.Request.Status, "failed")); + throw; + } + } + + public void OnProgressUpdate(string messageId, int sequenceNumber, JsonElement data) + { + using var activity = ActivitySource.StartActivity( + ActorRuntimeOpenTelemetryConsts.SpanNames.FormatActorOperation(ActorRuntimeOpenTelemetryConsts.Operations.ProgressUpdate)); + + activity.SetActorAttributes(this.ActorId); + activity.SetMessageAttributes(messageId); + activity?.SetTag(ActorRuntimeOpenTelemetryConsts.Message.SequenceNumber, sequenceNumber); + + try + { + Log.ProgressUpdateReceived(this._logger, this.ActorId.ToString(), messageId, sequenceNumber); + var update = new UpdateRequestOperation(messageId, RequestStatus.Pending, data); + this.PostRequestUpdate(update); + + activity.RecordSuccess((ActorRuntimeOpenTelemetryConsts.Message.Status, "processed")); + } + catch (Exception ex) + { + activity.RecordFailure(ex); + throw; + } + } + + private void PostRequestUpdate(UpdateRequestOperation update) + { + lock (this._lock) + { + if (!this._inbox.TryGetValue(update.MessageId, out var entry)) + { + Log.ProgressUpdateFailed(this._logger, this.ActorId.ToString(), update.MessageId, "Message not found in inbox"); + throw new InvalidOperationException($"Message with id '{update.MessageId}' not found while publishing update."); + } + + entry.PostUpdate(update); + if (update.Status is RequestStatus.Completed or RequestStatus.Failed) + { + entry.SetResponse(new ActorResponseMessage(update.MessageId) + { + SenderId = this.ActorId, + Status = update.Status, + Data = update.Data + }); + } + + Log.ProgressUpdatePublished(this._logger, this.ActorId.ToString(), update.MessageId); + } + } + + public async ValueTask ReadAsync(ActorReadOperationBatch operations, CancellationToken cancellationToken = default) + { + Log.ReadOperationStarted(this._logger, this.ActorId.ToString(), operations.Operations.Count); + var result = await this.Storage.ReadStateAsync( + this.ActorId, + [.. operations.Operations.OfType()], + cancellationToken).ConfigureAwait(false); + Log.ReadOperationCompleted(this._logger, this.ActorId.ToString(), result.Results.Count); + return result; + } + + public async IAsyncEnumerable WatchMessagesAsync([EnumeratorCancellation] CancellationToken cancellationToken = default) + { + Log.WatchMessagesStarted(this._logger, this.ActorId.ToString()); + + // TODO: Yield all pending requests - this likely requires reading the inbox from storage. + // TODO: Yield all responses + // TODO: Yield all updates + var messageCount = 0; + await foreach (var message in this._pendingMessages.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false)) + { + messageCount++; + var messageId = message switch + { + ActorRequestMessage requestMessage => requestMessage.MessageId, + ActorResponseMessage responseMessage => responseMessage.MessageId, + _ => "unknown" + }; + Log.MessageYielded(this._logger, this.ActorId.ToString(), messageId, message.Type.ToString(), messageCount); + yield return message; + } + + Log.WatchMessagesCompleted(this._logger, this.ActorId.ToString(), messageCount); + } + + public async ValueTask WriteAsync(ActorWriteOperationBatch operations, CancellationToken cancellationToken = default) + { + Log.WriteOperationStarted(this._logger, this.ActorId.ToString(), operations.Operations.Count); + + // TODO: Turn send & update message operations into storage writes to outbox + + var result = await this.Storage.WriteStateAsync( + this.ActorId, + [.. operations.Operations.OfType()], + operations.ETag, + cancellationToken).ConfigureAwait(false); + + Log.WriteOperationCompleted(this._logger, this.ActorId.ToString(), result.Success); + + // Check if result success and schedule durable task to pump outbox if needed. + if (result.Success) + { + var processedOperations = 0; + foreach (var operation in operations.Operations) + { + if (operation is SendRequestOperation sendRequestOperation) + { + Log.SendRequestOperationEncountered(this._logger, this.ActorId.ToString()); + // Get the target actor from the runtime. + // Enqueue the request on the actor's inbox. + throw new NotImplementedException(); + } + else if (operation is UpdateRequestOperation updateRequestOperation) + { + Log.UpdateRequestOperationProcessing(this._logger, this.ActorId.ToString(), updateRequestOperation.MessageId); + // Find the request in this actor's inbox. + // Get the SenderId from the request. + // Get the sending actor from the runtime. + // Enqueue the request on the actor's inbox. + this.PostRequestUpdate(updateRequestOperation); + processedOperations++; + } + } + Log.OperationProcessingCompleted(this._logger, this.ActorId.ToString(), processedOperations); + } + + return result; + } + + public async ValueTask DisposeAsync() + { + Log.ActorContextDisposing(this._logger, this.ActorId.ToString()); + + this._cts.Dispose(); + await this._actorInstance.DisposeAsync().ConfigureAwait(false); + if (this._actorRunTask is { } actorRunTask) + { + await actorRunTask.ConfigureAwait(false); + } + + Log.ActorContextDisposed(this._logger, this.ActorId.ToString()); + } + + public void Dispose() + { + Log.ActorContextDisposing(this._logger, this.ActorId.ToString()); + + this._cts.Dispose(); +#pragma warning disable CA2012 // Use ValueTasks correctly +#pragma warning disable VSTHRD002 // Avoid problematic synchronous waits + if (this._actorInstance is IDisposable actorInstanceDisposable) + { + actorInstanceDisposable.Dispose(); + } + else + { + this._actorInstance.DisposeAsync().GetAwaiter().GetResult(); + } + + this._actorRunTask?.GetAwaiter().GetResult(); +#pragma warning restore VSTHRD002 // Avoid problematic synchronous waits +#pragma warning restore CA2012 // Use ValueTasks correctly + + Log.ActorContextDisposed(this._logger, this.ActorId.ToString()); + } + + private sealed class ActorInboxEntry(ActorRequestMessage Request) + { + private readonly TaskCompletionSource _responseTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly Channel _updates = Channel.CreateUnbounded(); + public CancellationTokenSource Cts { get; } = new(); + public ActorRequestMessage Request { get; } = Request; + public Task Response => this._responseTcs.Task; + + public IAsyncEnumerable WatchUpdatesAsync(CancellationToken cancellationToken) + => this._updates.Reader.ReadAllAsync(cancellationToken); + + public void PostUpdate(UpdateRequestOperation update) + { + if (!this._updates.Writer.TryWrite(update)) + { + throw new InvalidOperationException("Failed to write update to the channel."); + } + } + + public void SetResponse(ActorResponseMessage response) + { + if (!this._responseTcs.TrySetResult(response)) + { + throw new InvalidOperationException("Response has already been set."); + } + + this._updates.Writer.TryComplete(); + } + } + + private sealed class InProcessActorResponseHandle(InProcessActorContext context, ActorInboxEntry entry) : ActorResponseHandle + { +#if NET8_0_OR_GREATER + public override async ValueTask CancelAsync(CancellationToken cancellationToken) + { + await entry.Cts.CancelAsync().ConfigureAwait(false); + } +#else + public override ValueTask CancelAsync(CancellationToken cancellationToken) + { + entry.Cts.Cancel(); + return default; + } +#endif + + public override async ValueTask GetResponseAsync(CancellationToken cancellationToken) + { + ActorResponse response; +#pragma warning disable CA1031 // Do not catch general exception types + try + { + var responseMessage = await entry.Response +#if NET8_0_OR_GREATER + .WaitAsync(cancellationToken) +#endif + .ConfigureAwait(false); + response = new ActorResponse + { + ActorId = context.ActorId, + MessageId = entry.Request.MessageId, + Data = responseMessage.Data, + Status = responseMessage.Status, + }; + } + catch (Exception exception) + { + response = new ActorResponse + { + ActorId = context.ActorId, + MessageId = entry.Request.MessageId, + Data = JsonSerializer.SerializeToElement($"Error: {exception.Message}", context._runtime.JsonSerializerOptions.GetTypeInfo(ActorRuntimeJsonContext.Default)), + Status = RequestStatus.Failed, + }; + } +#pragma warning restore CA1031 // Do not catch general exception types + + return response; + } + + public override bool TryGetResponse([NotNullWhen(true)] out ActorResponse? response) + { + if (entry.Response.Status is TaskStatus.RanToCompletion) + { +#pragma warning disable VSTHRD002 // Avoid problematic synchronous waits + var responseMessage = entry.Response.GetAwaiter().GetResult(); +#pragma warning restore VSTHRD002 // Avoid problematic synchronous waits + response = new ActorResponse + { + ActorId = context.ActorId, + MessageId = entry.Request.MessageId, + Data = responseMessage.Data, + Status = responseMessage.Status, + }; + + return true; + } + + response = null; + return false; + } + + public override async IAsyncEnumerable WatchUpdatesAsync([EnumeratorCancellation] CancellationToken cancellationToken) + { + await foreach (var update in entry.WatchUpdatesAsync(cancellationToken).ConfigureAwait(false)) + { + yield return new ActorRequestUpdate(update.Status, update.Data); + } + } + } +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/InProcessActorRuntime.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/InProcessActorRuntime.cs new file mode 100644 index 0000000000..360c3ae7fa --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/InProcessActorRuntime.cs @@ -0,0 +1,211 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.Metrics; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using static Microsoft.Extensions.AI.Agents.Runtime.ActivityExtensions; +using Tel = Microsoft.Extensions.AI.Agents.Runtime.ActorRuntimeOpenTelemetryConsts; + +namespace Microsoft.Extensions.AI.Agents.Runtime; + +internal sealed class InProcessActorRuntime( + IServiceProvider serviceProvider, + IReadOnlyDictionary> actorFactories, + IActorStateStorage storage, + JsonSerializerOptions jsonSerializerOptions) +{ + private static readonly ActivitySource ActivitySource = new(ActorRuntimeOpenTelemetryConsts.InProcessSourceName); + private static readonly Meter Meter = new(ActorRuntimeOpenTelemetryConsts.InProcessSourceName); + + // Metrics following OpenTelemetry semantic conventions + private static readonly Counter ActorCreatedCounter = Meter.CreateCounter( + ActorRuntimeOpenTelemetryConsts.Client.ActorCount.Name, + ActorRuntimeOpenTelemetryConsts.CountUnit, + ActorRuntimeOpenTelemetryConsts.Client.ActorCount.Description); + + private static readonly Histogram OperationDurationHistogram = Meter.CreateHistogram( + ActorRuntimeOpenTelemetryConsts.Client.OperationDuration.Name, + "s", + ActorRuntimeOpenTelemetryConsts.Client.OperationDuration.Description); + + private readonly object _createActorLock = new(); + private readonly IReadOnlyDictionary> _actorFactories = actorFactories; + private readonly ConcurrentDictionary _actors = []; + + public IActorStateStorage Storage { get; } = storage; + public JsonSerializerOptions JsonSerializerOptions { get; } = jsonSerializerOptions; + public IServiceProvider Services { get; } = serviceProvider; + + internal InProcessActorContext GetOrCreateActor(ActorId actorId) + { + var stopwatch = Stopwatch.StartNew(); + + // Create span following OpenTelemetry conventions for RPC operations + using var activity = ActivitySource.StartActivity( + ActorRuntimeOpenTelemetryConsts.SpanNames.FormatActorOperation(ActorRuntimeOpenTelemetryConsts.Operations.GetActor)); + + try + { + if (this._actors.TryGetValue(actorId, out var context)) + { + activity.SetupActorOperation(actorId, exists: true); + activity.Event(ActorStarted, actorId); + return context; + } + + if (!this._actorFactories.TryGetValue(actorId.Type, out var factory)) + { + var errorMessage = $"No factory registered for actor type '{actorId.Type}'"; + var exception = new InvalidOperationException(errorMessage); + + activity.SetupActorOperation(actorId, exists: false); + activity.RecordFailure(exception, ActorRuntimeOpenTelemetryConsts.ErrorInfo.Types.ActorNotFound); + throw exception; + } + + if (!this._actors.TryGetValue(actorId, out var actorContext)) + { +#if NETSTANDARD + InProcessActorContext ValueFactory(ActorId actorId) + { + var self = this; + + return CreateActorInstance(actorId, self, factory); + } + + actorContext = this._actors.GetOrAdd(actorId, ValueFactory); +#else + static InProcessActorContext ValueFactory( + ActorId actorId, + (InProcessActorRuntime, Func) state) + { + var (self, factory) = state; + return CreateActorInstance(actorId, self, factory); + } + + actorContext = this._actors.GetOrAdd(actorId, ValueFactory, (this, factory)); +#endif + } + + activity.SetupActorOperation(actorId, exists: false); + activity.RecordSuccess(); + return actorContext; + } + catch (Exception ex) + { + activity.RecordFailure(ex); + throw; + } + finally + { + // Record operation duration metric + var duration = stopwatch.Elapsed.TotalSeconds; + OperationDurationHistogram.Record(duration, + new KeyValuePair(ActorRuntimeOpenTelemetryConsts.Actor.Operation, ActorRuntimeOpenTelemetryConsts.Operations.GetActor), + new KeyValuePair(ActorRuntimeOpenTelemetryConsts.Actor.Type, actorId.Type.Name)); + } + } + + private static InProcessActorContext CreateActorInstance(ActorId actorId, InProcessActorRuntime self, Func factory) + { + lock (self._createActorLock) + { + // Create nested span for actor creation + var createActivity = ActivitySource.StartActivity( + ActorRuntimeOpenTelemetryConsts.SpanNames.FormatActorOperation(ActorRuntimeOpenTelemetryConsts.Operations.CreateActor)); + InProcessActorContext? instance = null; + try + { + createActivity.SetupActorOperation(actorId); + + instance = new InProcessActorContext(actorId, self, factory); + instance.Start(); + + createActivity.Complete(ActorCreated, actorId, [(Tel.Actor.Started, true)]); + + // Record metrics for successful actor creation + ActorCreatedCounter.Add(1, new KeyValuePair(ActorRuntimeOpenTelemetryConsts.Actor.Type, actorId.Type.Name)); + return instance; + } + catch (Exception ex) + { + instance?.Dispose(); + createActivity.RecordFailure(ex); + throw; + } + } + } +} + +internal sealed class InProcessActorClient(InProcessActorRuntime runtime) : IActorClient +{ + private static readonly ActivitySource ActivitySource = new(ActorRuntimeOpenTelemetryConsts.InProcessSourceName); + private static readonly Meter ClientMeter = new(ActorRuntimeOpenTelemetryConsts.InProcessSourceName); + private static readonly Counter RequestCounter = ClientMeter.CreateCounter( + ActorRuntimeOpenTelemetryConsts.Client.RequestCount.Name, + ActorRuntimeOpenTelemetryConsts.CountUnit, + ActorRuntimeOpenTelemetryConsts.Client.RequestCount.Description); + private static readonly Histogram ClientOperationDurationHistogram = ClientMeter.CreateHistogram( + ActorRuntimeOpenTelemetryConsts.Client.OperationDuration.Name, + "s", + ActorRuntimeOpenTelemetryConsts.Client.OperationDuration.Description); + + private readonly InProcessActorRuntime _runtime = runtime; + + public ValueTask GetResponseAsync(ActorId actorId, string messageId, CancellationToken cancellationToken) + { + // Create span for get response operation + using var activity = ActivitySource.StartActivity( + ActorRuntimeOpenTelemetryConsts.SpanNames.FormatRequestOperation(ActorRuntimeOpenTelemetryConsts.Operations.ReceiveResponse)); + + activity.SetupRequestOperation(actorId, messageId, service: "ActorClient", rpcMethod: "GetResponse"); + + throw new NotImplementedException("GetResponseAsync is not yet implemented"); + } + + public ValueTask SendRequestAsync(ActorRequest request, CancellationToken cancellationToken) + { + var stopwatch = Stopwatch.StartNew(); + + // Create span for send request operation following RPC client conventions + using var activity = ActivitySource.StartActivity( + ActorRuntimeOpenTelemetryConsts.SpanNames.FormatRequestOperation(ActorRuntimeOpenTelemetryConsts.Operations.SendRequest)); + + try + { + activity.SetupRequestOperation(request.ActorId, request.MessageId, request.Method); + + // Ensure the message is enqueued on the actor's inbox, getting a response handle for it. + var actorId = request.ActorId; + var actorContext = this._runtime.GetOrCreateActor(actorId); + var response = actorContext.SendRequest(request); + + activity.Complete(MessageSent, actorId, Sent, (Tel.Message.Id, request.MessageId)); + + // Record request metric + RequestCounter.Add(1, + new KeyValuePair(Tel.Actor.Type, actorId.Type.Name), + new KeyValuePair(Tel.Message.Method, request.Method)); + + return new(response); + } + catch (Exception ex) + { + activity.RecordFailure(ex, null, (ActorRuntimeOpenTelemetryConsts.Request.Status, "failed")); + throw; + } + finally + { + // Record operation duration + var duration = stopwatch.Elapsed.TotalSeconds; + ClientOperationDurationHistogram.Record(duration, + new KeyValuePair(ActorRuntimeOpenTelemetryConsts.Actor.Operation, ActorRuntimeOpenTelemetryConsts.Operations.SendRequest), + new KeyValuePair(ActorRuntimeOpenTelemetryConsts.Actor.Type, request.ActorId.Type.Name)); + } + } +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/JsonSerializerExtensions.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/JsonSerializerExtensions.cs new file mode 100644 index 0000000000..5f01bcc57d --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/JsonSerializerExtensions.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; + +namespace Microsoft.Extensions.AI.Agents.Runtime; + +/// +/// Provides extension methods for JSON serialization with source generation support. +/// +internal static class JsonSerializerExtensions +{ + /// + /// Gets the JsonTypeInfo for a type, preferring the one from options if available, + /// otherwise falling back to the source-generated context. + /// + /// The type to get JsonTypeInfo for. + /// The JsonSerializerOptions to check first. + /// The fallback JsonSerializerContext to use if not found in options. + /// The JsonTypeInfo for the requested type. + public static JsonTypeInfo GetTypeInfo(this JsonSerializerOptions options, JsonSerializerContext fallbackContext) + { + // Try to get from the options first (if a context is configured) + if (options.TypeInfoResolver?.GetTypeInfo(typeof(T), options) is JsonTypeInfo typeInfo) + { + return typeInfo; + } + + // Fall back to the provided source-generated context + return (JsonTypeInfo)fallbackContext.GetTypeInfo(typeof(T))!; + } +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/Microsoft.Extensions.AI.Agents.Runtime.csproj b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/Microsoft.Extensions.AI.Agents.Runtime.csproj new file mode 100644 index 0000000000..4e0f534dc2 --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/Microsoft.Extensions.AI.Agents.Runtime.csproj @@ -0,0 +1,40 @@ + + + + $(ProjectsTargetFrameworks) + $(ProjectsDebugTargetFrameworks) + $(NoWarn);IDE1006;IDE0130 + alpha + + + + true + true + true + true + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents/AgentsJsonContext.cs b/dotnet/src/Microsoft.Extensions.AI.Agents/AgentsJsonContext.cs new file mode 100644 index 0000000000..d222a34966 --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents/AgentsJsonContext.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.Extensions.AI.Agents; + +/// +/// Source-generated JSON type information for use by all Agents implementations. +/// +[JsonSourceGenerationOptions( + JsonSerializerDefaults.Web, + UseStringEnumConverter = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = false)] +[JsonSerializable(typeof(ChatMessage))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(ChatClientAgentThread))] +internal sealed partial class AgentsJsonContext : JsonSerializerContext; diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents/ChatCompletion/ChatClientAgentThread.cs b/dotnet/src/Microsoft.Extensions.AI.Agents/ChatCompletion/ChatClientAgentThread.cs index 5192b84088..80f822b91b 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents/ChatCompletion/ChatClientAgentThread.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents/ChatCompletion/ChatClientAgentThread.cs @@ -1,8 +1,12 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Collections.Generic; +using System.ComponentModel; using System.Diagnostics; using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; using Microsoft.Shared.Diagnostics; @@ -12,6 +16,7 @@ namespace Microsoft.Extensions.AI.Agents; /// /// Chat client agent thread. /// +[JsonConverter(typeof(Converter))] public sealed class ChatClientAgentThread : AgentThread, IMessagesRetrievableThread { private readonly List _chatMessages = []; @@ -89,4 +94,92 @@ public sealed class ChatClientAgentThread : AgentThread, IMessagesRetrievableThr return Task.CompletedTask; } + + /// + /// Provides a for objects. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class Converter : JsonConverter + { + /// + public override ChatClientAgentThread? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.StartObject) + { + throw new JsonException("Expected StartObject token"); + } + + using var doc = JsonDocument.ParseValue(ref reader); + var root = doc.RootElement; + + // Extract properties from JSON + string? id = null; + if (root.TryGetProperty("id", out var idProperty)) + { + id = idProperty.GetString(); + } + + List? messages = null; + if (root.TryGetProperty("messages", out var messagesProperty)) + { + if (messagesProperty.ValueKind == JsonValueKind.Array) + { + messages = []; + foreach (var messageElement in messagesProperty.EnumerateArray()) + { + var message = messageElement.Deserialize(options.GetTypeInfo(AgentsJsonContext.Default)); + if (message != null) + { + messages.Add(message); + } + } + } + } + + // Create the appropriate instance based on available data + // StorageLocation will be set automatically by the constructors + ChatClientAgentThread thread; + if (messages?.Count > 0) + { + thread = new ChatClientAgentThread(messages); + } + else if (!string.IsNullOrWhiteSpace(id)) + { + thread = new ChatClientAgentThread(id); + } + else + { + thread = new ChatClientAgentThread(); + } + + // Override Id if it was explicitly set in JSON (for cases where messages exist but ID is also provided) + if (id != null) + { + thread.Id = id; + } + + return thread; + } + + /// + public override void Write(Utf8JsonWriter writer, ChatClientAgentThread value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + + // Write base properties + if (value.Id != null) + { + writer.WriteString("id", value.Id); + } + + // Write messages if in memory storage (StorageLocation is determined by presence of messages vs ID) + if (value.StorageLocation == ChatClientAgentThreadType.InMemoryMessages) + { + writer.WritePropertyName("messages"); + JsonSerializer.Serialize(writer, value._chatMessages, options.GetTypeInfo>(AgentsJsonContext.Default)); + } + + writer.WriteEndObject(); + } + } } diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents/JsonSerializerExtensions.cs b/dotnet/src/Microsoft.Extensions.AI.Agents/JsonSerializerExtensions.cs new file mode 100644 index 0000000000..232c10b1ce --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents/JsonSerializerExtensions.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; + +namespace Microsoft.Extensions.AI.Agents; + +/// +/// Provides extension methods for JSON serialization with source generation support. +/// +internal static class JsonSerializerExtensions +{ + /// + /// Gets the JsonTypeInfo for a type, preferring the one from options if available, + /// otherwise falling back to the source-generated context. + /// + /// The type to get JsonTypeInfo for. + /// The JsonSerializerOptions to check first. + /// The fallback JsonSerializerContext to use if not found in options. + /// The JsonTypeInfo for the requested type. + public static JsonTypeInfo GetTypeInfo(this JsonSerializerOptions options, JsonSerializerContext fallbackContext) + { + // Try to get from the options first (if a context is configured) + if (options.TypeInfoResolver?.GetTypeInfo(typeof(T), options) is JsonTypeInfo typeInfo) + { + return typeInfo; + } + + // Fall back to the provided source-generated context + return (JsonTypeInfo)fallbackContext.GetTypeInfo(typeof(T))!; + } +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents/Microsoft.Extensions.AI.Agents.csproj b/dotnet/src/Microsoft.Extensions.AI.Agents/Microsoft.Extensions.AI.Agents.csproj index a2a0a5c720..b7f37ba4b4 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents/Microsoft.Extensions.AI.Agents.csproj +++ b/dotnet/src/Microsoft.Extensions.AI.Agents/Microsoft.Extensions.AI.Agents.csproj @@ -9,6 +9,7 @@ true true + true diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.Runtime.Abstractions.UnitTests/InMemoryActorStateStorageTests.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.Runtime.Abstractions.UnitTests/InMemoryActorStateStorageTests.cs new file mode 100644 index 0000000000..923c1b71b0 --- /dev/null +++ b/dotnet/tests/Microsoft.Extensions.AI.Agents.Runtime.Abstractions.UnitTests/InMemoryActorStateStorageTests.cs @@ -0,0 +1,514 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.AI.Agents.Runtime.Abstractions.UnitTests; + +/// +/// Unit tests for the class. +/// +[System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE1006:Naming Styles", Justification = "Test naming convention")] +public sealed class InMemoryActorStateStorageTests +{ + private readonly InMemoryActorStateStorage _storage = new(); + private readonly ActorId _testActorId = new("TestActor", "test-instance"); + private readonly ActorId _anotherActorId = new("AnotherActor", "another-instance"); + + [Fact] + public async Task WriteStateAsync_WithSetValueOperation_ShouldStoreValue() + { + // Arrange + var key = "testKey"; + var value = JsonSerializer.SerializeToElement("testValue"); + var operations = new List + { + new SetValueOperation(key, value) + }; + + // Act + var result = await this._storage.WriteStateAsync(this._testActorId, operations, "0", CancellationToken.None); + + // Assert + Assert.True(result.Success); + Assert.NotEqual("0", result.ETag); + Assert.Equal(1, this._storage.GetKeyCount(this._testActorId)); + } + + [Fact] + public async Task WriteStateAsync_WithRemoveKeyOperation_ShouldRemoveValue() + { + // Arrange + var key = "testKey"; + var value = JsonSerializer.SerializeToElement("testValue"); + + // First set a value + var setOperations = new List + { + new SetValueOperation(key, value) + }; + var setResult = await this._storage.WriteStateAsync(this._testActorId, setOperations, "0", CancellationToken.None); + + // Now remove the value + var removeOperations = new List + { + new RemoveKeyOperation(key) + }; + + // Act + var result = await this._storage.WriteStateAsync(this._testActorId, removeOperations, setResult.ETag, CancellationToken.None); + + // Assert + Assert.True(result.Success); + Assert.NotEqual(setResult.ETag, result.ETag); + Assert.Equal(0, this._storage.GetKeyCount(this._testActorId)); + } + + [Fact] + public async Task WriteStateAsync_WithIncorrectETag_ShouldReturnFailure() + { + // Arrange + var key = "testKey"; + var value = JsonSerializer.SerializeToElement("testValue"); + var operations = new List + { + new SetValueOperation(key, value) + }; + + // Act + var result = await this._storage.WriteStateAsync(this._testActorId, operations, "incorrect-etag", CancellationToken.None); + + // Assert + Assert.False(result.Success); + Assert.Equal("0", result.ETag); // Should return current ETag + Assert.Equal(0, this._storage.GetKeyCount(this._testActorId)); + } + + [Fact] + public async Task ReadStateAsync_WithGetValueOperation_ShouldReturnValue() + { + // Arrange + var key = "testKey"; + var value = JsonSerializer.SerializeToElement("testValue"); + var writeOperations = new List + { + new SetValueOperation(key, value) + }; + await this._storage.WriteStateAsync(this._testActorId, writeOperations, "0", CancellationToken.None); + + var readOperations = new List + { + new GetValueOperation(key) + }; + + // Act + var result = await this._storage.ReadStateAsync(this._testActorId, readOperations, CancellationToken.None); + + // Assert + Assert.Single(result.Results); + var getValue = result.Results[0] as GetValueResult; + Assert.NotNull(getValue); + Assert.NotNull(getValue.Value); + Assert.Equal("testValue", getValue.Value?.GetString()); + } + + [Fact] + public async Task ReadStateAsync_WithGetValueOperationForNonExistentKey_ShouldReturnNull() + { + // Arrange + var readOperations = new List + { + new GetValueOperation("nonExistentKey") + }; + + // Act + var result = await this._storage.ReadStateAsync(this._testActorId, readOperations, CancellationToken.None); + + // Assert + Assert.Single(result.Results); + var getValue = result.Results[0] as GetValueResult; + Assert.NotNull(getValue); + Assert.Null(getValue.Value); + } + + [Fact] + public async Task ReadStateAsync_WithListKeysOperation_ShouldReturnAllKeys() + { + // Arrange + var key1 = "key1"; + var key2 = "key2"; + var value1 = JsonSerializer.SerializeToElement("value1"); + var value2 = JsonSerializer.SerializeToElement("value2"); + + var writeOperations = new List + { + new SetValueOperation(key1, value1), + new SetValueOperation(key2, value2) + }; + await this._storage.WriteStateAsync(this._testActorId, writeOperations, "0", CancellationToken.None); + + var readOperations = new List + { + new ListKeysOperation(continuationToken: null) + }; + + // Act + var result = await this._storage.ReadStateAsync(this._testActorId, readOperations, CancellationToken.None); + + // Assert + Assert.Single(result.Results); + var listKeys = result.Results[0] as ListKeysResult; + Assert.NotNull(listKeys); + Assert.Equal(2, listKeys.Keys.Count); + Assert.Contains(key1, listKeys.Keys); + Assert.Contains(key2, listKeys.Keys); + Assert.Null(listKeys.ContinuationToken); + } + + [Fact] + public async Task ReadStateAsync_WithListKeysOperationForEmptyActor_ShouldReturnEmptyList() + { + // Arrange + var readOperations = new List + { + new ListKeysOperation(continuationToken: null) + }; + + // Act + var result = await this._storage.ReadStateAsync(this._testActorId, readOperations, CancellationToken.None); + + // Assert + Assert.Single(result.Results); + var listKeys = result.Results[0] as ListKeysResult; + Assert.NotNull(listKeys); + Assert.Empty(listKeys.Keys); + Assert.Null(listKeys.ContinuationToken); + } + + [Fact] + public async Task ReadStateAsync_WithListKeysOperationAndKeyPrefix_ShouldReturnFilteredKeys() + { + // Arrange + var prefixKey1 = "prefix_key1"; + var prefixKey2 = "prefix_key2"; + var otherKey = "other_key"; + var value1 = JsonSerializer.SerializeToElement("value1"); + var value2 = JsonSerializer.SerializeToElement("value2"); + var value3 = JsonSerializer.SerializeToElement("value3"); + + var writeOperations = new List + { + new SetValueOperation(prefixKey1, value1), + new SetValueOperation(prefixKey2, value2), + new SetValueOperation(otherKey, value3) + }; + await this._storage.WriteStateAsync(this._testActorId, writeOperations, "0", CancellationToken.None); + + var readOperations = new List + { + new ListKeysOperation(continuationToken: null, keyPrefix: "prefix_") + }; + + // Act + var result = await this._storage.ReadStateAsync(this._testActorId, readOperations, CancellationToken.None); + + // Assert + Assert.Single(result.Results); + var listKeys = result.Results[0] as ListKeysResult; + Assert.NotNull(listKeys); + Assert.Equal(2, listKeys.Keys.Count); + Assert.Contains(prefixKey1, listKeys.Keys); + Assert.Contains(prefixKey2, listKeys.Keys); + Assert.DoesNotContain(otherKey, listKeys.Keys); + Assert.Null(listKeys.ContinuationToken); + } + + [Fact] + public async Task ReadStateAsync_WithListKeysOperationAndNonMatchingKeyPrefix_ShouldReturnEmptyList() + { + // Arrange + var key1 = "key1"; + var key2 = "key2"; + var value1 = JsonSerializer.SerializeToElement("value1"); + var value2 = JsonSerializer.SerializeToElement("value2"); + + var writeOperations = new List + { + new SetValueOperation(key1, value1), + new SetValueOperation(key2, value2) + }; + await this._storage.WriteStateAsync(this._testActorId, writeOperations, "0", CancellationToken.None); + + var readOperations = new List + { + new ListKeysOperation(continuationToken: null, keyPrefix: "prefix_") + }; + + // Act + var result = await this._storage.ReadStateAsync(this._testActorId, readOperations, CancellationToken.None); + + // Assert + Assert.Single(result.Results); + var listKeys = result.Results[0] as ListKeysResult; + Assert.NotNull(listKeys); + Assert.Empty(listKeys.Keys); + Assert.Null(listKeys.ContinuationToken); + } + + [Fact] + public async Task MultipleOperations_ShouldBeProcessedInOrder() + { + // Arrange + var key1 = "key1"; + var key2 = "key2"; + var value1 = JsonSerializer.SerializeToElement("value1"); + var value2 = JsonSerializer.SerializeToElement("value2"); + + var operations = new List + { + new SetValueOperation(key1, value1), + new SetValueOperation(key2, value2), + new RemoveKeyOperation(key1) + }; + + // Act + var result = await this._storage.WriteStateAsync(this._testActorId, operations, "0", CancellationToken.None); + + // Assert + Assert.True(result.Success); + Assert.Equal(1, this._storage.GetKeyCount(this._testActorId)); + + // Verify remaining key + var readOperations = new List + { + new GetValueOperation(key2) + }; + var readResult = await this._storage.ReadStateAsync(this._testActorId, readOperations, CancellationToken.None); + var getValue = readResult.Results[0] as GetValueResult; + Assert.NotNull(getValue); + Assert.Equal("value2", getValue.Value?.GetString()); + } + + [Fact] + public async Task DifferentActors_ShouldHaveIsolatedState() + { + // Arrange + var key = "sharedKey"; + var value1 = JsonSerializer.SerializeToElement("value1"); + var value2 = JsonSerializer.SerializeToElement("value2"); + + var operations1 = new List + { + new SetValueOperation(key, value1) + }; + var operations2 = new List + { + new SetValueOperation(key, value2) + }; + + // Act + await this._storage.WriteStateAsync(this._testActorId, operations1, "0", CancellationToken.None); + await this._storage.WriteStateAsync(this._anotherActorId, operations2, "0", CancellationToken.None); + + // Assert + Assert.Equal(2, this._storage.ActorCount); + Assert.Equal(1, this._storage.GetKeyCount(this._testActorId)); + Assert.Equal(1, this._storage.GetKeyCount(this._anotherActorId)); + + // Verify values are different + var readOperations = new List + { + new GetValueOperation(key) + }; + + var result1 = await this._storage.ReadStateAsync(this._testActorId, readOperations, CancellationToken.None); + var result2 = await this._storage.ReadStateAsync(this._anotherActorId, readOperations, CancellationToken.None); + + var getValue1 = result1.Results[0] as GetValueResult; + var getValue2 = result2.Results[0] as GetValueResult; + + Assert.NotNull(getValue1); + Assert.NotNull(getValue2); + Assert.Equal("value1", getValue1.Value?.GetString()); + Assert.Equal("value2", getValue2.Value?.GetString()); + } + + [Fact] + public async Task ConcurrentOperations_ShouldBeThreadSafe() + { + // Arrange + const int operationCount = 100; + var tasks = new List(); + + // Act + for (int i = 0; i < operationCount; i++) + { + var key = $"key{i}"; + var value = JsonSerializer.SerializeToElement($"value{i}"); + var actorId = new ActorId("TestActor", $"instance{i % 10}"); // 10 different actors + var operations = new List + { + new SetValueOperation(key, value) + }; + + tasks.Add(Task.Run(async () => + { + // Retry logic to handle concurrent updates + var success = false; + var retryCount = 0; + const int maxRetries = 10; + + while (!success && retryCount < maxRetries) + { + var currentETag = this._storage.GetETag(actorId); + var result = await this._storage.WriteStateAsync(actorId, operations, currentETag, CancellationToken.None); + success = result.Success; + retryCount++; + } + })); + } + + await Task.WhenAll(tasks); + + // Assert + Assert.Equal(10, this._storage.ActorCount); // 10 different actors + + // Verify each actor has the expected number of keys + for (int i = 0; i < 10; i++) + { + var actorId = new ActorId("TestActor", $"instance{i}"); + Assert.Equal(10, this._storage.GetKeyCount(actorId)); // Each actor should have 10 keys + } + } + + [Fact] + public async Task Clear_ShouldRemoveAllState() + { + // Arrange + var key = "testKey"; + var value = JsonSerializer.SerializeToElement("testValue"); + var operations = new List + { + new SetValueOperation(key, value) + }; + + await this._storage.WriteStateAsync(this._testActorId, operations, "0", CancellationToken.None); + Assert.Equal(1, this._storage.ActorCount); + + // Act + this._storage.Clear(); + + // Assert + Assert.Equal(0, this._storage.ActorCount); + Assert.Equal(0, this._storage.GetKeyCount(this._testActorId)); + Assert.Equal("0", this._storage.GetETag(this._testActorId)); + } + + [Fact] + public void GetETag_ForNewActor_ShouldReturnZero() + { + // Act + var etag = this._storage.GetETag(this._testActorId); + + // Assert + Assert.Equal("0", etag); + } + + [Fact] + public async Task WriteStateAsync_WithNullOperations_ShouldThrowArgumentNullException() + { + // Act & Assert + await Assert.ThrowsAsync(() => + this._storage.WriteStateAsync(this._testActorId, null!, "0", CancellationToken.None).AsTask()); + } + + [Fact] + public async Task WriteStateAsync_WithNullETag_ShouldThrowArgumentNullException() + { + // Arrange + var operations = new List(); + + // Act & Assert + await Assert.ThrowsAsync(() => + this._storage.WriteStateAsync(this._testActorId, operations, null!, CancellationToken.None).AsTask()); + } + + [Fact] + public async Task ReadStateAsync_WithNullOperations_ShouldThrowArgumentNullException() + { + // Act & Assert + await Assert.ThrowsAsync(() => + this._storage.ReadStateAsync(this._testActorId, null!, CancellationToken.None).AsTask()); + } + + [Fact] + public async Task WriteStateAsync_WithCancelledToken_ShouldThrowOperationCanceledException() + { + // Arrange + var operations = new List(); + var cancellationToken = new CancellationToken(canceled: true); + + // Act & Assert + await Assert.ThrowsAsync(() => + this._storage.WriteStateAsync(this._testActorId, operations, "0", cancellationToken).AsTask()); + } + + [Fact] + public async Task ReadStateAsync_WithCancelledToken_ShouldThrowOperationCanceledException() + { + // Arrange + var operations = new List(); + var cancellationToken = new CancellationToken(canceled: true); + + // Act & Assert + await Assert.ThrowsAsync(() => + this._storage.ReadStateAsync(this._testActorId, operations, cancellationToken).AsTask()); + } + + [Fact] + public async Task ETagProgression_ShouldIncrementMonotonically() + { + // Arrange + var key = "testKey"; + var value1 = JsonSerializer.SerializeToElement("value1"); + var value2 = JsonSerializer.SerializeToElement("value2"); + + // Act + var operations1 = new List { new SetValueOperation(key, value1) }; + var result1 = await this._storage.WriteStateAsync(this._testActorId, operations1, "0", CancellationToken.None); + + var operations2 = new List { new SetValueOperation(key, value2) }; + var result2 = await this._storage.WriteStateAsync(this._testActorId, operations2, result1.ETag, CancellationToken.None); + + // Assert + Assert.True(result1.Success); + Assert.True(result2.Success); + Assert.NotEqual("0", result1.ETag); + Assert.NotEqual(result1.ETag, result2.ETag); + + // ETags should be numeric and increasing + Assert.True(long.Parse(result1.ETag) < long.Parse(result2.ETag)); + } + + [Fact] + public void ListKeysOperation_JsonSerialization_ShouldWorkCorrectly() + { + // Arrange + var operation = new ListKeysOperation(continuationToken: "token123", keyPrefix: "prefix_"); + + // Act - Serialize to JSON + var json = JsonSerializer.Serialize(operation); + + // Deserialize back to object + var deserializedOperation = JsonSerializer.Deserialize(json); + + // Assert + Assert.NotNull(deserializedOperation); + Assert.Equal("token123", deserializedOperation.ContinuationToken); + Assert.Equal("prefix_", deserializedOperation.KeyPrefix); + Assert.Equal(ActorReadOperationType.ListKeys, deserializedOperation.Type); + } +} diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.Runtime.Abstractions.UnitTests/JsonSerializationTests.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.Runtime.Abstractions.UnitTests/JsonSerializationTests.cs new file mode 100644 index 0000000000..efd59c2987 --- /dev/null +++ b/dotnet/tests/Microsoft.Extensions.AI.Agents.Runtime.Abstractions.UnitTests/JsonSerializationTests.cs @@ -0,0 +1,335 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.Extensions.AI.Agents.Runtime.Abstractions.UnitTests; + +/// +/// Tests for JSON serialization and deserialization of all JSON-serializable types. +/// +public class JsonSerializationTests +{ + private readonly JsonSerializerOptions _options; + + public JsonSerializationTests() + { + this._options = new JsonSerializerOptions + { + WriteIndented = false, // Use compact JSON for easier testing + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Converters = { new JsonStringEnumConverter() } + }; + this._options.TypeInfoResolver = ActorJsonContext.Default; + } + + #region ActorMessage Tests + + [Fact] + public void ActorRequestMessage_SerializesAndDeserializes() + { + // Arrange + var originalMessage = new ActorRequestMessage("msg123") + { + SenderId = new ActorId("TestActor", "instance1"), + Method = "TestMethod", + Params = JsonSerializer.SerializeToElement(new { param = "value" }) + }; + + // Act - Serialize to JSON + string json = JsonSerializer.Serialize(originalMessage, this._options); + + // Assert - JSON structure + Assert.Contains("\"type\":\"request\"", json); + Assert.Contains("\"messageId\":\"msg123\"", json); + Assert.Contains("\"method\":\"TestMethod\"", json); + + // Act - Deserialize back + var deserializedMessage = JsonSerializer.Deserialize(json, this._options) as ActorRequestMessage; + + // Assert - Verify deserialization + Assert.NotNull(deserializedMessage); + Assert.Equal(originalMessage.MessageId, deserializedMessage.MessageId); + Assert.Equal(originalMessage.Method, deserializedMessage.Method); + Assert.Equal(originalMessage.SenderId?.Type.Name, deserializedMessage.SenderId?.Type.Name); + Assert.Equal(originalMessage.SenderId?.Key, deserializedMessage.SenderId?.Key); + Assert.Equal(originalMessage.Params.GetRawText(), deserializedMessage.Params.GetRawText()); + } + + #endregion + + #region ActorWriteOperation Tests + + [Fact] + public void SetValueOperation_SerializesAndDeserializes() + { + // Arrange + var originalOperation = new SetValueOperation("testKey", JsonSerializer.SerializeToElement(new { value = "testValue" })); + + // Act - Serialize to JSON + string json = JsonSerializer.Serialize(originalOperation, this._options); + + // Assert - JSON structure (uses snake_case) + Assert.Contains("\"type\":\"set_value\"", json); + Assert.Contains("\"key\":\"testKey\"", json); + Assert.Contains("\"value\":", json); + + // Act - Deserialize back + var deserializedOperation = JsonSerializer.Deserialize(json, this._options) as SetValueOperation; + + // Assert - Verify deserialization + Assert.NotNull(deserializedOperation); + Assert.Equal(originalOperation.Key, deserializedOperation.Key); + Assert.Equal(originalOperation.Value.GetRawText(), deserializedOperation.Value.GetRawText()); + } + + [Fact] + public void RemoveKeyOperation_SerializesAndDeserializes() + { + // Arrange + var originalOperation = new RemoveKeyOperation("keyToRemove"); + + // Act - Serialize to JSON + string json = JsonSerializer.Serialize(originalOperation, this._options); + + // Assert - JSON structure (uses snake_case) + Assert.Contains("\"type\":\"remove_key\"", json); + Assert.Contains("\"key\":\"keyToRemove\"", json); + + // Act - Deserialize back + var deserializedOperation = JsonSerializer.Deserialize(json, this._options) as RemoveKeyOperation; + + // Assert - Verify deserialization + Assert.NotNull(deserializedOperation); + Assert.Equal(originalOperation.Key, deserializedOperation.Key); + } + + [Fact] + public void ActorSendRequestOperation_SerializesAndDeserializes() + { + // Arrange + var request = new ActorRequestMessage("msg456") + { + SenderId = new ActorId("TargetActor", "instance1"), + Method = "ProcessData", + Params = JsonSerializer.SerializeToElement(new { data = "payload" }) + }; + var originalOperation = new SendRequestOperation(request); + + // Act - Serialize to JSON + string json = JsonSerializer.Serialize(originalOperation, this._options); + + // Assert - JSON structure (uses snake_case) + Assert.Contains("\"type\":\"send_request\"", json); + Assert.Contains("\"message\":", json); + Assert.Contains("\"messageId\":\"msg456\"", json); + + // Act - Deserialize back + var deserializedOperation = JsonSerializer.Deserialize(json, this._options) as SendRequestOperation; + + // Assert - Verify deserialization + Assert.NotNull(deserializedOperation); + Assert.Equal(originalOperation.Message.MessageId, deserializedOperation.Message.MessageId); + Assert.Equal(originalOperation.Message.Method, deserializedOperation.Message.Method); + } + + [Fact] + public void ActorUpdateRequestOperation_SerializesAndDeserializes() + { + // Arrange + var originalOperation = new UpdateRequestOperation("msg789", RequestStatus.Failed, JsonSerializer.SerializeToElement(new { error = "timeout" })); + + // Act - Serialize to JSON + string json = JsonSerializer.Serialize(originalOperation, this._options); + + // Assert - JSON structure (uses snake_case) + Assert.Contains("\"type\":\"update_request\"", json); + Assert.Contains("\"messageId\":\"msg789\"", json); + Assert.Contains("\"status\":\"failed\"", json); + + // Act - Deserialize back + var deserializedOperation = JsonSerializer.Deserialize(json, this._options) as UpdateRequestOperation; + + // Assert - Verify deserialization + Assert.NotNull(deserializedOperation); + Assert.Equal(originalOperation.MessageId, deserializedOperation.MessageId); + Assert.Equal(originalOperation.Status, deserializedOperation.Status); + Assert.Equal(originalOperation.Data.GetRawText(), deserializedOperation.Data.GetRawText()); + } + + #endregion + + #region ActorReadOperation Tests + + [Fact] + public void ListKeysOperation_SerializesAndDeserializes() + { + // Arrange + var originalOperation = new ListKeysOperation("continuationToken123", "prefix_"); + + // Act - Serialize to JSON + string json = JsonSerializer.Serialize(originalOperation, this._options); + + // Assert - JSON structure (uses snake_case) + Assert.Contains("\"type\":\"list_keys\"", json); + Assert.Contains("\"continuationToken\":\"continuationToken123\"", json); + Assert.Contains("\"keyPrefix\":\"prefix_\"", json); + + // Act - Deserialize back + var deserializedOperation = JsonSerializer.Deserialize(json, this._options) as ListKeysOperation; + + // Assert - Verify deserialization + Assert.NotNull(deserializedOperation); + Assert.Equal(originalOperation.ContinuationToken, deserializedOperation.ContinuationToken); + Assert.Equal(originalOperation.KeyPrefix, deserializedOperation.KeyPrefix); + } + + [Fact] + public void GetValueOperation_SerializesAndDeserializes() + { + // Arrange + var originalOperation = new GetValueOperation("myKey"); + + // Act - Serialize to JSON + string json = JsonSerializer.Serialize(originalOperation, this._options); + + // Assert - JSON structure (uses snake_case) + Assert.Contains("\"type\":\"get_value\"", json); + Assert.Contains("\"key\":\"myKey\"", json); + + // Act - Deserialize back + var deserializedOperation = JsonSerializer.Deserialize(json, this._options) as GetValueOperation; + + // Assert - Verify deserialization + Assert.NotNull(deserializedOperation); + Assert.Equal(originalOperation.Key, deserializedOperation.Key); + } + + #endregion + + #region Enum Serialization Tests + + [Fact] + public void ActorMessageType_SerializesAsString() + { + // Test that enums serialize as strings + Assert.Equal("\"request\"", JsonSerializer.Serialize(ActorMessageType.Request, this._options)); + Assert.Equal("\"response\"", JsonSerializer.Serialize(ActorMessageType.Response, this._options)); + } + + [Fact] + public void RequestStatus_SerializesAsString() + { + // Test that enums serialize as strings + Assert.Equal("\"pending\"", JsonSerializer.Serialize(RequestStatus.Pending, this._options)); + Assert.Equal("\"completed\"", JsonSerializer.Serialize(RequestStatus.Completed, this._options)); + Assert.Equal("\"failed\"", JsonSerializer.Serialize(RequestStatus.Failed, this._options)); + Assert.Equal("\"not_found\"", JsonSerializer.Serialize(RequestStatus.NotFound, this._options)); + } + + [Fact] + public void ActorWriteOperationType_SerializesAsString() + { + // Test that enums serialize as strings (snake_case) + Assert.Equal("\"set_value\"", JsonSerializer.Serialize(ActorWriteOperationType.SetValue, this._options)); + Assert.Equal("\"remove_key\"", JsonSerializer.Serialize(ActorWriteOperationType.RemoveKey, this._options)); + Assert.Equal("\"send_request\"", JsonSerializer.Serialize(ActorWriteOperationType.SendRequest, this._options)); + Assert.Equal("\"update_request\"", JsonSerializer.Serialize(ActorWriteOperationType.UpdateRequest, this._options)); + } + + [Fact] + public void ActorReadOperationType_SerializesAsString() + { + // Test that enums serialize as strings (snake_case) + Assert.Equal("\"list_keys\"", JsonSerializer.Serialize(ActorReadOperationType.ListKeys, this._options)); + Assert.Equal("\"get_value\"", JsonSerializer.Serialize(ActorReadOperationType.GetValue, this._options)); + } + + [Fact] + public void ActorReadResultType_SerializesAsString() + { + // Test that enums serialize as strings (snake_case) + Assert.Equal("\"list_keys\"", JsonSerializer.Serialize(ActorReadResultType.ListKeys, this._options)); + Assert.Equal("\"get_value\"", JsonSerializer.Serialize(ActorReadResultType.GetValue, this._options)); + } + + #endregion + + #region ActorId Tests + + [Fact] + public void ActorId_SerializesAndDeserializes() + { + // Arrange + var actorId = new ActorId("UserActor", "user123"); + + // Act - Serialize to JSON + string json = JsonSerializer.Serialize(actorId, this._options); + + // Assert - JSON structure + Assert.Contains("UserActor/user123", json); + + // Act - Deserialize back + var deserializedActorId = JsonSerializer.Deserialize(json, this._options); + + // Assert - Verify deserialization + Assert.Equal(actorId.Type.Name, deserializedActorId.Type.Name); + Assert.Equal(actorId.Key, deserializedActorId.Key); + } + + #endregion + + #region Non-message types Tests + + [Fact] + public void ActorRequest_SerializesAndDeserializes() + { + // Arrange + var originalRequest = new ActorRequest( + new ActorId("TestActor", "instance1"), + "msg123", + "TestMethod", + JsonSerializer.SerializeToElement(new { param = "value" })); + + // Act - Serialize to JSON + string json = JsonSerializer.Serialize(originalRequest, this._options); + + // Assert - JSON structure + Assert.Contains("\"messageId\":\"msg123\"", json); + Assert.Contains("\"method\":\"TestMethod\"", json); + + // Act - Deserialize back + var deserializedRequest = JsonSerializer.Deserialize(json, this._options); + + // Assert - Verify deserialization + Assert.NotNull(deserializedRequest); + Assert.Equal(originalRequest.MessageId, deserializedRequest.MessageId); + Assert.Equal(originalRequest.Method, deserializedRequest.Method); + Assert.Equal(originalRequest.ActorId.Type.Name, deserializedRequest.ActorId.Type.Name); + Assert.Equal(originalRequest.ActorId.Key, deserializedRequest.ActorId.Key); + Assert.Equal(originalRequest.Params.GetRawText(), deserializedRequest.Params.GetRawText()); + } + + [Fact] + public void ActorRequestUpdate_SerializesAndDeserializes() + { + // Arrange + var originalUpdate = new ActorRequestUpdate(RequestStatus.Completed, JsonSerializer.SerializeToElement(new { result = "done" })); + + // Act - Serialize to JSON + string json = JsonSerializer.Serialize(originalUpdate, this._options); + + // Assert - JSON structure + Assert.Contains("\"status\":\"completed\"", json); + + // Act - Deserialize back + var deserializedUpdate = JsonSerializer.Deserialize(json, this._options); + + // Assert - Verify deserialization + Assert.NotNull(deserializedUpdate); + Assert.Equal(originalUpdate.Status, deserializedUpdate.Status); + Assert.Equal(originalUpdate.Data.GetRawText(), deserializedUpdate.Data.GetRawText()); + } + + #endregion +} diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/ChatCompletion/ChatClientAgentThreadTests.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/ChatCompletion/ChatClientAgentThreadTests.cs index 5ff4dd7a7d..587f5adf5e 100644 --- a/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/ChatCompletion/ChatClientAgentThreadTests.cs +++ b/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/ChatCompletion/ChatClientAgentThreadTests.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Moq; @@ -718,4 +719,258 @@ public class ChatClientAgentThreadTests } #endregion + + #region JSON Serialization Tests + + /// + /// Verify that can be serialized to JSON and deserialized back correctly. + /// + [Fact] + public void VerifyJsonSerializationRoundTrip_DefaultThread() + { + // Arrange + var originalThread = new ChatClientAgentThread(); + + // Act + string json = JsonSerializer.Serialize(originalThread); + var deserializedThread = JsonSerializer.Deserialize(json); + + // Assert + Assert.NotNull(deserializedThread); + Assert.Equal(originalThread.Id, deserializedThread.Id); + Assert.Equal(originalThread.StorageLocation, deserializedThread.StorageLocation); + } + + /// + /// Verify that with ID can be serialized to JSON and deserialized back correctly. + /// + [Fact] + public void VerifyJsonSerializationRoundTrip_ThreadWithId() + { + // Arrange + var originalThread = new ChatClientAgentThread("test-conversation-id"); + + // Act + string json = JsonSerializer.Serialize(originalThread); + var deserializedThread = JsonSerializer.Deserialize(json); + + // Assert + Assert.NotNull(deserializedThread); + Assert.Equal("test-conversation-id", deserializedThread.Id); + Assert.Equal(ChatClientAgentThreadType.ConversationId, deserializedThread.StorageLocation); + } + + /// + /// Verify that with messages can be serialized to JSON and deserialized back correctly. + /// + [Fact] + public async Task VerifyJsonSerializationRoundTrip_ThreadWithMessagesAsync() + { + // Arrange + var messages = new[] + { + new ChatMessage(ChatRole.User, "Hello, world!"), + new ChatMessage(ChatRole.Assistant, "Hi there! How can I help you?"), + new ChatMessage(ChatRole.User, "What's the weather like?") + }; + var originalThread = new ChatClientAgentThread(messages); + + // Act + string json = JsonSerializer.Serialize(originalThread); + var deserializedThread = JsonSerializer.Deserialize(json); + + // Assert + Assert.NotNull(deserializedThread); + Assert.Equal(originalThread.Id, deserializedThread.Id); + Assert.Equal(ChatClientAgentThreadType.InMemoryMessages, deserializedThread.StorageLocation); + + // Verify messages are preserved + var originalMessages = await originalThread.GetMessagesAsync().ToListAsync(); + var deserializedMessages = await deserializedThread.GetMessagesAsync().ToListAsync(); + + Assert.Equal(originalMessages.Count, deserializedMessages.Count); + for (int i = 0; i < originalMessages.Count; i++) + { + Assert.Equal(originalMessages[i].Role, deserializedMessages[i].Role); + Assert.Equal(originalMessages[i].Text, deserializedMessages[i].Text); + } + } + + /// + /// Verify that serialization handles null properties correctly. + /// + [Fact] + public void VerifyJsonSerialization_HandlesNullProperties() + { + // Arrange + var thread = new ChatClientAgentThread(); + + // Act + string json = JsonSerializer.Serialize(thread); + + // Assert - StorageLocation is no longer serialized independently + Assert.DoesNotContain("storageLocation", json, StringComparison.OrdinalIgnoreCase); + + // Verify deserialization handles empty JSON correctly + var deserializedThread = JsonSerializer.Deserialize(json); + Assert.NotNull(deserializedThread); + Assert.Null(deserializedThread.StorageLocation); + } + + /// + /// Verify that serialization only includes messages for InMemoryMessages storage type. + /// + [Fact] + public void VerifyJsonSerialization_OnlyIncludesMessagesForInMemoryStorage() + { + // Arrange - Create thread with conversation ID (server-side storage) + var threadWithId = new ChatClientAgentThread("test-id"); + + // Act + string json = JsonSerializer.Serialize(threadWithId); + + // Assert - Messages should not be included for ConversationId storage, and storageLocation is not serialized + Assert.DoesNotContain("\"messages\"", json, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("\"storageLocation\"", json, StringComparison.OrdinalIgnoreCase); + Assert.Contains("\"id\":\"test-id\"", json, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Verify that serialization includes messages for InMemoryMessages storage type. + /// + [Fact] + public void VerifyJsonSerialization_IncludesMessagesForInMemoryStorage() + { + // Arrange - Create thread with messages (in-memory storage) + var messages = new[] { new ChatMessage(ChatRole.User, "Test message") }; + var threadWithMessages = new ChatClientAgentThread(messages); + + // Act + string json = JsonSerializer.Serialize(threadWithMessages); + + // Assert - Messages should be included for InMemoryMessages storage, but storageLocation is not serialized + Assert.Contains("\"messages\"", json, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("\"storageLocation\"", json, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Test message", json, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Verify that deserialization handles missing properties gracefully. + /// + [Fact] + public void VerifyJsonDeserialization_HandlesMissingProperties() + { + // Arrange - JSON with minimal properties + string minimalJson = "{}"; + + // Act + var thread = JsonSerializer.Deserialize(minimalJson); + + // Assert + Assert.NotNull(thread); + Assert.Null(thread.Id); + Assert.Null(thread.StorageLocation); + } + + /// + /// Verify that deserialization handles invalid JSON gracefully. + /// + [Fact] + public void VerifyJsonDeserialization_HandlesMalformedJson() + { + // Arrange - Invalid JSON structure + string invalidJson = "{ invalid json"; + + // Act & Assert + Assert.Throws(() => JsonSerializer.Deserialize(invalidJson)); + } + + /// + /// Verify that deserialization handles invalid storage location values. + /// This test is no longer relevant since storageLocation is not independently deserialized. + /// + [Fact] + public void VerifyJsonDeserialization_HandlesInvalidStorageLocation() + { + // Arrange - JSON with ID (which will set storage location to ConversationId) + string jsonWithId = @"{""id"":""test""}"; + + // Act + var thread = JsonSerializer.Deserialize(jsonWithId); + + // Assert - Storage location is determined by presence of ID + Assert.NotNull(thread); + Assert.Equal("test", thread.Id); + Assert.Equal(ChatClientAgentThreadType.ConversationId, thread.StorageLocation); + } + + /// + /// Verify that deserialization preserves messages correctly. + /// + [Fact] + public async Task VerifyJsonDeserialization_PreservesMessagesCorrectlyAsync() + { + // Arrange - Create a thread with messages and serialize it to get the correct format + var originalMessages = new[] + { + new ChatMessage(ChatRole.User, "Hello"), + new ChatMessage(ChatRole.Assistant, "Hi there!") + }; + var originalThread = new ChatClientAgentThread(originalMessages); + + // Serialize to get the actual format, then deserialize + string json = JsonSerializer.Serialize(originalThread); + var thread = JsonSerializer.Deserialize(json); + + // Assert + Assert.NotNull(thread); + Assert.Equal(ChatClientAgentThreadType.InMemoryMessages, thread.StorageLocation); + + var messages = await thread.GetMessagesAsync().ToListAsync(); + Assert.Equal(2, messages.Count); + Assert.Equal(ChatRole.User, messages[0].Role); + Assert.Equal("Hello", messages[0].Text); + Assert.Equal(ChatRole.Assistant, messages[1].Role); + Assert.Equal("Hi there!", messages[1].Text); + } + + /// + /// Verify that serialization and deserialization works with complex message content. + /// + [Fact] + public async Task VerifyJsonSerializationRoundTrip_ComplexMessageContentAsync() + { + // Arrange - Create messages with various content types + var messages = new[] + { + new ChatMessage(ChatRole.User, "Simple text message"), + new ChatMessage(ChatRole.Assistant, [ + new TextContent("Mixed content: "), + new TextContent("multiple parts") + ]), + new ChatMessage(ChatRole.User, "Message with special characters: ñáéíóú !@#$%^&*()") + }; + var originalThread = new ChatClientAgentThread(messages); + + // Act + string json = JsonSerializer.Serialize(originalThread); + var deserializedThread = JsonSerializer.Deserialize(json); + + // Assert + Assert.NotNull(deserializedThread); + + var originalMessages = await originalThread.GetMessagesAsync().ToListAsync(); + var deserializedMessages = await deserializedThread.GetMessagesAsync().ToListAsync(); + + Assert.Equal(originalMessages.Count, deserializedMessages.Count); + + // Verify complex content is preserved + for (int i = 0; i < originalMessages.Count; i++) + { + Assert.Equal(originalMessages[i].Role, deserializedMessages[i].Role); + Assert.Equal(originalMessages[i].Text, deserializedMessages[i].Text); + } + } + + #endregion }