Initial draft of actor runtime abstractions (#197)

* Initial draft of actor runtime abstractions
This commit is contained in:
Reuben Bond
2025-07-22 13:05:58 -07:00
committed by GitHub
Unverified
parent 8f2d3da80d
commit 41d441420e
108 changed files with 7445 additions and 58 deletions
+5 -5
View File
@@ -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
+30 -18
View File
@@ -3,47 +3,59 @@
<!-- Enable central package management -->
<!-- https://learn.microsoft.com/en-us/nuget/consume-packages/Central-Package-Management -->
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
</PropertyGroup>
<ItemGroup>
<!-- Azure.* -->
<PackageVersion Include="Aspire.Azure.AI.OpenAI" Version="9.3.1-preview.1.25305.6" />
<PackageVersion Include="Aspire.Hosting.AppHost" Version="9.3.1" />
<PackageVersion Include="Aspire.Hosting.Azure.CognitiveServices" Version="9.3.1" />
<PackageVersion Include="Azure.AI.Agents.Persistent" Version="1.1.0-beta.4" />
<PackageVersion Include="Azure.AI.OpenAI" Version="2.2.0-beta.5" />
<PackageVersion Include="Azure.Identity" Version="1.14.0" />
<PackageVersion Include="Azure.Identity" Version="1.14.2" />
<PackageVersion Include="CommunityToolkit.Aspire.OllamaSharp" Version="9.6.0" />
<PackageVersion Include="Microsoft.Extensions.AI.AzureAIInference" Version="9.7.1-preview.1.25365.4" />
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="9.7.0" />
<PackageVersion Include="Microsoft.Extensions.ServiceDiscovery" Version="9.3.1" />
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0" />
<!-- System.* -->
<PackageVersion Include="System.Linq.Async" Version="6.0.1" />
<PackageVersion Include="System.Text.Json" Version="8.0.6" />
<PackageVersion Include="System.Linq.Async" Version="6.0.3" />
<PackageVersion Include="System.Text.Json" Version="9.0.7" />
<PackageVersion Include="System.Diagnostics.DiagnosticSource" Version="9.0.7" />
<PackageVersion Include="System.Threading.Channels" Version="9.0.7" />
<PackageVersion Include="System.Threading.Tasks.Extensions" Version="4.6.3" />
<!-- OpenTelemetry -->
<PackageVersion Include="OpenTelemetry" Version="1.9.0" />
<PackageVersion Include="OpenTelemetry.Exporter.Console" Version="1.9.0" />
<PackageVersion Include="OpenTelemetry.Exporter.InMemory" Version="1.9.0" />
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.9.0" />
<PackageVersion Include="OpenTelemetry" Version="1.12.0" />
<PackageVersion Include="OpenTelemetry.Exporter.Console" Version="1.12.0" />
<PackageVersion Include="OpenTelemetry.Exporter.InMemory" Version="1.12.0" />
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
<!-- Microsoft.Extensions.* -->
<PackageVersion Include="Microsoft.Bcl.HashCode" Version="6.0.0" />
<PackageVersion Include="Microsoft.Extensions.AI" Version="9.7.0" />
<PackageVersion Include="Microsoft.Extensions.AI.OpenAI" Version="9.7.0-preview.1.25356.2" />
<PackageVersion Include="Microsoft.Extensions.AI.Abstractions" Version="9.7.0" />
<PackageVersion Include="Microsoft.Extensions.AI" Version="9.7.1" />
<PackageVersion Include="Microsoft.Extensions.AI.OpenAI" Version="9.7.1-preview.1.25365.4" />
<PackageVersion Include="Microsoft.Extensions.AI.Abstractions" Version="9.7.1" />
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="9.0.7" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.7" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.7" />
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.7" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="9.0.7" />
<PackageVersion Include="Microsoft.Extensions.Configuration.UserSecrets" Version="9.0.7" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="9.0.7" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.7" />
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="8.0.1" />
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="9.0.7" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="9.0.7" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.7" />
<PackageVersion Include="Microsoft.Extensions.Logging.Testing" Version="9.0.7" />
<!-- Agent SDKs -->
<PackageVersion Include="Microsoft.Agents.CopilotStudio.Client" Version="1.1.125-beta" />
<!-- Identity -->
<PackageVersion Include="Microsoft.Identity.Client.Extensions.Msal" Version="4.67.2" />
<PackageVersion Include="Microsoft.Identity.Client.Extensions.Msal" Version="4.74.1" />
<!-- Test -->
<PackageVersion Include="FluentAssertions" Version="8.2.0" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageVersion Include="FluentAssertions" Version="8.5.0" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageVersion Include="Moq" Version="[4.18.4]" />
<PackageVersion Include="xunit" Version="2.9.2" />
<PackageVersion Include="xunit.abstractions" Version="2.0.3" />
@@ -59,12 +71,12 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageVersion Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.12.19" />
<PackageVersion Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.14.15" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageVersion Include="xunit.analyzers" Version="1.17.0" />
<PackageVersion Include="xunit.analyzers" Version="1.23.0" />
<PackageReference Include="xunit.analyzers">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+17
View File
@@ -34,6 +34,20 @@
<BuildType Solution="Publish|*" Project="Debug" />
</Project>
</Folder>
<Folder Name="/Samples/HelloHttpApi/">
<Project Path="samples/HelloHttpApi/HelloHttpApi.ApiService/HelloHttpApi.ApiService.csproj">
<BuildType Solution="Publish|*" Project="Release" />
</Project>
<Project Path="samples/HelloHttpApi/HelloHttpApi.AppHost/HelloHttpApi.AppHost.csproj" Id="17db642e-bf19-413c-bdb1-ccbccc753d7b">
<BuildType Solution="Publish|*" Project="Release" />
</Project>
<Project Path="samples/HelloHttpApi/HelloHttpApi.ServiceDefaults/HelloHttpApi.ServiceDefaults.csproj">
<BuildType Solution="Publish|*" Project="Release" />
</Project>
<Project Path="samples/HelloHttpApi/HelloHttpApi.Web/HelloHttpApi.Web.csproj">
<BuildType Solution="Publish|*" Project="Release" />
</Project>
</Folder>
<Folder Name="/Solution Items/">
<File Path=".editorconfig" />
<File Path=".gitignore" />
@@ -130,6 +144,9 @@
<Project Path="src/Microsoft.Extensions.AI.Agents.AzureAI/Microsoft.Extensions.AI.Agents.AzureAI.csproj" />
<Project Path="src/Microsoft.Extensions.AI.Agents.CopilotStudio/Microsoft.Extensions.AI.Agents.CopilotStudio.csproj" />
<Project Path="src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/Microsoft.Extensions.AI.Agents.Runtime.Abstractions.csproj" />
<Project Path="src/Microsoft.Extensions.AI.Agents.Runtime/Microsoft.Extensions.AI.Agents.Runtime.csproj" Id="35d72ad5-61e1-45cc-a9ad-fa8490dbd146">
<BuildType Solution="Publish|*" Project="Release" />
</Project>
<Project Path="src/Microsoft.Extensions.AI.Agents/Microsoft.Extensions.AI.Agents.csproj" />
</Folder>
<Folder Name="/UnitTests/">
-15
View File
@@ -4,23 +4,8 @@
<PropertyGroup>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<IsAotCompatible>false</IsAotCompatible>
<ProjectsTargetFrameworks>net472;net9.0</ProjectsTargetFrameworks>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Moq" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
<PackageReference Include="System.Linq.Async" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
<Using Include="Xunit.Abstractions" />
</ItemGroup>
</Project>
@@ -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
@@ -1,10 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>$(ProjectsTargetFrameworks)</TargetFrameworks>
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsDebugTargetFrameworks)</TargetFrameworks>
</PropertyGroup>
<PropertyGroup>
<RootNamespace>GettingStarted</RootNamespace>
<OutputType>Library</OutputType>
@@ -12,6 +7,13 @@
<NoWarn>$(NoWarn);CA1707;CA1716;IDE0009;IDE1006;OPENAI001;</NoWarn>
<ImplicitUsings>enable</ImplicitUsings>
<InjectSharedSamples>true</InjectSharedSamples>
<InjectSharedThrow>true</InjectSharedThrow>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<PropertyGroup>
<TargetFrameworks>$(ProjectsTargetFrameworks)</TargetFrameworks>
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsDebugTargetFrameworks)</TargetFrameworks>
</PropertyGroup>
<ItemGroup>
@@ -29,6 +31,12 @@
<PackageReference Include="OpenTelemetry" />
<PackageReference Include="OpenTelemetry.Exporter.Console" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" />
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Moq" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
<PackageReference Include="System.Linq.Async" />
</ItemGroup>
<ItemGroup>
@@ -42,11 +50,11 @@
<Using Include="Microsoft.Shared.SampleUtilities" />
</ItemGroup>
<PropertyGroup>
<InjectSharedThrow>true</InjectSharedThrow>
</PropertyGroup>
<ItemGroup>
<Using Include="Xunit" />
<Using Include="Xunit.Abstractions" />
</ItemGroup>
<ItemGroup>
<None Update="Resources\*">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
@@ -0,0 +1,127 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
namespace Microsoft.Extensions.AI.Agents.Runtime.Samples;
/// <summary>
/// Example demonstrating how to use the InMemoryActorStateStorage.
/// </summary>
public static class InMemoryActorStateStorageExample
{
/// <summary>
/// Demonstrates the basic usage of InMemoryActorStateStorage.
/// </summary>
/// <returns>A task representing the asynchronous operation.</returns>
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<ActorStateWriteOperation>
{
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<ActorStateReadOperation>
{
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<ActorStateWriteOperation>
{
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<ActorStateWriteOperation>
{
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<ActorStateReadOperation>
{
new ListKeysOperation(continuationToken: null)
};
var finalReadResult = await storage.ReadStateAsync(actorId, finalReadOperations).ConfigureAwait(false);
var finalKeys = finalReadResult.Results.OfType<ListKeysResult>().First();
Console.WriteLine($" Final keys: [{string.Join(", ", finalKeys.Keys)}]");
// 6. Read each value
foreach (var key in finalKeys.Keys)
{
var valueReadOperations = new List<ActorStateReadOperation>
{
new GetValueOperation(key)
};
var valueReadResult = await storage.ReadStateAsync(actorId, valueReadOperations).ConfigureAwait(false);
var getValue = valueReadResult.Results.OfType<GetValueResult>().First();
Console.WriteLine($" - {key}: {getValue.Value}");
}
Console.WriteLine();
Console.WriteLine("=== Example Complete ===");
}
}
@@ -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<Program> 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<IResult> StreamResponse(HttpContext context, ActorResponseHandle responseHandle, CancellationToken cancellationToken)
{
var requestId = context.Request.RouteValues["requestId"]?.ToString() ?? "unknown";
var logger = context.RequestServices.GetRequiredService<ILogger<Program>>();
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<IHttpResponseBodyFeature>().DisableBuffering();
}
static async Task<IResult> ProcessResponse(
string name,
string sessionId,
string requestId,
bool? stream,
HttpContext context,
ActorResponseHandle responseHandle,
ActorResponse response,
CancellationToken cancellationToken)
{
var logger = context.RequestServices.GetRequiredService<ILogger<Program>>();
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");
}
}
@@ -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;
/// <summary>
/// Source-generated JSON type information for use by all Agents implementations.
/// </summary>
[JsonSourceGenerationOptions(
JsonSerializerDefaults.Web,
UseStringEnumConverter = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false)]
[JsonSerializable(typeof(ChatMessage))]
[JsonSerializable(typeof(List<ChatMessage>))]
[JsonSerializable(typeof(ChatClientAgentThread))]
[JsonSerializable(typeof(ChatClientAgentRunRequest))]
[JsonSerializable(typeof(AgentRunResponseUpdate))]
internal sealed partial class AgentsJsonContext : JsonSerializerContext;
@@ -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<ChatClientAgentActor> 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<ChatClientAgentThread>(
(JsonTypeInfo<ChatClientAgentThread>)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<ChatMessage>? messages;
if (message.Params is { } payload)
{
var arg = payload.Deserialize<ChatClientAgentRunRequest>(
(JsonTypeInfo<ChatClientAgentRunRequest>)jsonSerializerOptions.GetTypeInfo(typeof(ChatClientAgentRunRequest)));
messages = arg?.Messages;
}
messages ??= [];
Log.ProcessingAgentRequest(logger, requestId, context.ActorId.ToString(), messages.Count);
try
{
var typeInfo = (JsonTypeInfo<AgentRunResponseUpdate>)jsonSerializerOptions.GetTypeInfo(typeof(AgentRunResponseUpdate));
var i = 0;
var updates = new List<AgentRunResponseUpdate>();
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<AgentRunResponse>)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?
}
}
}
@@ -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<ChatMessage> Messages { get; set; } = [];
}
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Microsoft.Extensions.AI.Agents.Abstractions\Microsoft.Extensions.AI.Agents.Abstractions.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Extensions.AI.Agents.Runtime.Abstractions\Microsoft.Extensions.AI.Agents.Runtime.Abstractions.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Extensions.AI.Agents.Runtime\Microsoft.Extensions.AI.Agents.Runtime.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Extensions.AI.Agents\Microsoft.Extensions.AI.Agents.csproj" />
<ProjectReference Include="..\HelloHttpApi.ServiceDefaults\HelloHttpApi.ServiceDefaults.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Aspire.Azure.AI.OpenAI" />
<PackageReference Include="Aspire.Hosting.Azure.CognitiveServices" />
<PackageReference Include="CommunityToolkit.Aspire.OllamaSharp" />
<PackageReference Include="Microsoft.Extensions.AI" />
<PackageReference Include="Microsoft.Extensions.AI.Abstractions" />
<PackageReference Include="Microsoft.Extensions.AI.AzureAIInference" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
</ItemGroup>
</Project>
@@ -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<IChatClient>() : sp.GetRequiredKeyedService<IChatClient>(chatClientKey);
return new ChatClientAgent(chatClient, instructions, name);
});
var actorBuilder = builder.AddActorRuntime();
actorBuilder.AddActorType(
new ActorType(agentKey),
(sp, ctx) => new ChatClientAgentActor(
sp.GetRequiredKeyedService<ChatClientAgent>(agentKey),
sp.GetService<JsonSerializerOptions>() ?? JsonSerializerOptions.Web,
ctx,
sp.GetRequiredService<ILogger<ChatClientAgentActor>>()));
return builder;
}
}
@@ -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";
}
@@ -0,0 +1,140 @@
// Copyright (c) Microsoft. All rights reserved.
using HelloHttpApi.ApiService;
using Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// High-performance logging messages using LoggerMessage source generator.
/// </summary>
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);
}
@@ -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;
}
@@ -0,0 +1,9 @@
// Copyright (c) Microsoft. All rights reserved.
namespace HelloHttpApi.ApiService;
public enum PingResponseStatus
{
Healthy,
HealthyBusy,
}
@@ -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();
@@ -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"
}
}
}
}
@@ -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,
}
@@ -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<IHttpClientFactory>().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<IHttpClientFactory>().CreateClient(httpKey);
return new OllamaApiClient(client, connectionInfo.SelectedModel);
});
}
}
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Trace",
"Microsoft.AspNetCore": "Warning"
}
}
}
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Trace",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<Sdk Name="Aspire.AppHost.Sdk" Version="9.3.1" />
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsAspireHost>true</IsAspireHost>
<UserSecretsId>2969a84d-8ee6-4304-8737-6e469a315aa8</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Aspire.Hosting.AppHost" />
<PackageReference Include="Aspire.Hosting.Azure.CognitiveServices" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\HelloHttpApi.ApiService\HelloHttpApi.ApiService.csproj" />
<ProjectReference Include="..\HelloHttpApi.Web\HelloHttpApi.Web.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,266 @@
// Copyright (c) Microsoft. All rights reserved.
namespace HelloHttpApi.AppHost;
public static class ModelExtensions
{
public static IResourceBuilder<AIModel> AddAIModel(this IDistributedApplicationBuilder builder, string name)
{
var model = new AIModel(name);
return builder.CreateResourceBuilder(model);
}
public static IResourceBuilder<AIModel> RunAsOpenAI(this IResourceBuilder<AIModel> builder, string modelName, IResourceBuilder<ParameterResource> apiKey)
{
if (builder.ApplicationBuilder.ExecutionContext.IsRunMode)
{
return builder.AsOpenAI(modelName, apiKey);
}
return builder;
}
public static IResourceBuilder<AIModel> PublishAsOpenAI(this IResourceBuilder<AIModel> builder, string modelName, IResourceBuilder<ParameterResource> apiKey)
{
if (builder.ApplicationBuilder.ExecutionContext.IsPublishMode)
{
return builder.AsOpenAI(modelName, apiKey);
}
return builder;
}
public static IResourceBuilder<AIModel> RunAsAzureOpenAI(this IResourceBuilder<AIModel> builder, string modelName, Action<IResourceBuilder<AzureOpenAIResource>>? configure)
{
if (builder.ApplicationBuilder.ExecutionContext.IsRunMode)
{
return builder.AsAzureOpenAI(modelName, configure);
}
return builder;
}
public static IResourceBuilder<AIModel> PublishAsAzureOpenAI(this IResourceBuilder<AIModel> builder, string modelName, Action<IResourceBuilder<AzureOpenAIResource>>? configure)
{
if (builder.ApplicationBuilder.ExecutionContext.IsPublishMode)
{
return builder.AsAzureOpenAI(modelName, configure);
}
return builder;
}
public static IResourceBuilder<AIModel> AsAzureOpenAI(this IResourceBuilder<AIModel> builder, string modelName, Action<IResourceBuilder<AzureOpenAIResource>>? 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<AIModel> RunAsAzureAIInference(this IResourceBuilder<AIModel> builder, string modelName, IResourceBuilder<ParameterResource> endpoint, IResourceBuilder<ParameterResource> apiKey)
{
if (builder.ApplicationBuilder.ExecutionContext.IsRunMode)
{
return builder.AsAzureAIInference(modelName, endpoint, apiKey);
}
return builder;
}
public static IResourceBuilder<AIModel> PublishAsAzureAIInference(this IResourceBuilder<AIModel> builder, string modelName, IResourceBuilder<ParameterResource> endpoint, IResourceBuilder<ParameterResource> apiKey)
{
if (builder.ApplicationBuilder.ExecutionContext.IsPublishMode)
{
return builder.AsAzureAIInference(modelName, endpoint, apiKey);
}
return builder;
}
public static IResourceBuilder<AIModel> AsAzureAIInference(this IResourceBuilder<AIModel> builder, string modelName, IResourceBuilder<ParameterResource> endpoint, IResourceBuilder<ParameterResource> 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<AIModel> RunAsAzureAIInference(this IResourceBuilder<AIModel> builder, string modelName, string endpoint, IResourceBuilder<ParameterResource> apiKey)
{
if (builder.ApplicationBuilder.ExecutionContext.IsRunMode)
{
return builder.AsAzureAIInference(modelName, endpoint, apiKey);
}
return builder;
}
public static IResourceBuilder<AIModel> PublishAsAzureAIInference(this IResourceBuilder<AIModel> builder, string modelName, string endpoint, IResourceBuilder<ParameterResource> apiKey)
{
if (builder.ApplicationBuilder.ExecutionContext.IsPublishMode)
{
return builder.AsAzureAIInference(modelName, endpoint, apiKey);
}
return builder;
}
public static IResourceBuilder<AIModel> AsAzureAIInference(this IResourceBuilder<AIModel> builder, string modelName, string endpoint, IResourceBuilder<ParameterResource> 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<AIModel> AsOpenAI(this IResourceBuilder<AIModel> builder, string modelName, IResourceBuilder<ParameterResource> 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<AIModel> 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}");
}
}
@@ -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<Projects.HelloHttpApi_ApiService>("apiservice")
.WithReference(chatModel);
builder.AddProject<Projects.HelloHttpApi_Web>("webfrontend")
.WithExternalHttpEndpoints()
.WithReference(apiService)
.WaitFor(apiService);
builder.Build().Run();
@@ -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"
}
}
}
}
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Trace",
"Microsoft.AspNetCore": "Warning"
}
}
}
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Trace",
"Microsoft.AspNetCore": "Warning",
"Aspire.Hosting.Dcp": "Warning"
}
}
}
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsAspireSharedProject>true</IsAspireSharedProject>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Microsoft.Extensions.Http.Resilience" />
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" />
</ItemGroup>
</Project>
@@ -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<TBuilder>(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<ServiceDiscoveryOptions>(options =>
// {
// options.AllowedSchemes = ["https"];
// });
return builder;
}
public static TBuilder ConfigureOpenTelemetry<TBuilder>(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<TBuilder>(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<TBuilder>(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;
}
}
@@ -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<AgentClient> logger)
{
private static readonly JsonSerializerOptions s_jsonOptions = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
public async IAsyncEnumerable<AgentRunResponseUpdate> 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<ChatClientAgentRunRequest>(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<AgentResponse> 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<ChatClientAgentRunRequest>(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<AgentResponse>(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<EventData>(AgentClientJsonContext.Default));
if (eventData?.Event != null)
{
var eventElement = eventData.Event.Value;
// Try to deserialize as AgentRunResponseUpdate for intermediate updates
try
{
var update = JsonSerializer.Deserialize<AgentRunResponseUpdate>(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<ChatMessage> 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; } = "";
}
/// <summary>
/// Provides extension methods for JSON serialization with source generation support.
/// </summary>
internal static class JsonSerializerExtensions
{
/// <summary>
/// Gets the JsonTypeInfo for a type, preferring the one from options if available,
/// otherwise falling back to the source-generated context.
/// </summary>
/// <typeparam name="T">The type to get JsonTypeInfo for.</typeparam>
/// <param name="options">The JsonSerializerOptions to check first.</param>
/// <param name="fallbackContext">The fallback JsonSerializerContext to use if not found in options.</param>
/// <returns>The JsonTypeInfo for the requested type.</returns>
public static JsonTypeInfo<T> GetTypeInfo<T>(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<T> typeInfo)
{
return typeInfo;
}
// Fall back to the provided source-generated context
return (JsonTypeInfo<T>)fallbackContext.GetTypeInfo(typeof(T))!;
}
}
/// <summary>
/// Source-generated JSON type information for use by AgentClient.
/// </summary>
[JsonSourceGenerationOptions(
JsonSerializerDefaults.Web,
UseStringEnumConverter = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false)]
[JsonSerializable(typeof(ChatClientAgentRunRequest))]
[JsonSerializable(typeof(ChatMessage))]
[JsonSerializable(typeof(List<ChatMessage>))]
[JsonSerializable(typeof(EventData))]
[JsonSerializable(typeof(AgentRunResponseUpdate))]
[JsonSerializable(typeof(AgentResponse))]
[JsonSerializable(typeof(JsonElement))]
internal sealed partial class AgentClientJsonContext : JsonSerializerContext;
@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
<link rel="stylesheet" href="lib/bootstrap/dist/css/bootstrap.min.css" />
<link rel="stylesheet" href="app.css" />
<link rel="stylesheet" href="HelloHttpApi.Web.styles.css" />
<link rel="icon" type="image/png" href="favicon.png" />
<HeadOutlet />
</head>
<body>
<Routes />
<script src="_framework/blazor.web.js"></script>
</body>
</html>
@@ -0,0 +1,23 @@
@inherits LayoutComponentBase
<div class="page">
<div class="sidebar">
<NavMenu />
</div>
<main>
<div class="top-row px-4">
<a href="https://learn.microsoft.com/aspnet/core/" target="_blank">About</a>
</div>
<article class="content px-4">
@Body
</article>
</main>
</div>
<div id="blazor-error-ui">
An unhandled error has occurred.
<a href="" class="reload">Reload</a>
<a class="dismiss">🗙</a>
</div>
@@ -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;
}
@@ -0,0 +1,29 @@
<div class="top-row ps-3 navbar navbar-dark">
<div class="container-fluid">
<a class="navbar-brand" href="">HelloHttpApi</a>
</div>
</div>
<input type="checkbox" title="Navigation menu" class="navbar-toggler" />
<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()">
<nav class="nav flex-column">
<div class="nav-item px-3">
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
<span class="bi bi-house-door-fill" aria-hidden="true"></span> Home
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="counter">
<span class="bi bi-plus-square-fill" aria-hidden="true"></span> Counter
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="pirate-talk">
<span class="bi bi-chat-dots-fill" aria-hidden="true"></span> Pirate Talk
</NavLink>
</div>
</nav>
</div>
@@ -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;
}
}
@@ -0,0 +1,19 @@
@page "/counter"
@rendermode InteractiveServer
<PageTitle>Counter</PageTitle>
<h1>Counter</h1>
<p role="status">Current count: @currentCount</p>
<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>
@code {
private int currentCount = 0;
private void IncrementCount()
{
currentCount++;
}
}
@@ -0,0 +1,38 @@
@page "/Error"
@using System.Diagnostics
<PageTitle>Error</PageTitle>
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
@if (ShowRequestId)
{
<p>
<strong>Request ID:</strong> <code>@requestId</code>
</p>
}
<h3>Development Mode</h3>
<p>
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
and restarting the app.
</p>
@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;
}
}
@@ -0,0 +1,7 @@
@page "/"
<PageTitle>Home</PageTitle>
<h1>Hello, world!</h1>
Welcome to your new app.
@@ -0,0 +1,208 @@
@page "/pirate-talk"
@attribute [StreamRendering(true)]
@inject AgentClient AgentClient
@inject IJSRuntime JSRuntime
@inject ILogger<PirateTalk> Logger
@rendermode InteractiveServer
@using System.Text
@using System.Text.Json
@using Microsoft.Extensions.AI
@using Microsoft.Extensions.AI.Agents
<PageTitle>Pirate Talk</PageTitle>
<h1>🏴‍☠️ Pirate Talk</h1>
<p>Chat with a pirate agent! Send a message and get a response in pirate speak.</p>
<div class="chat-container">
<div class="chat-messages" id="chat-messages" style="height: 400px; overflow-y: auto; border: 1px solid #ccc; padding: 10px; margin-bottom: 10px; background-color: #f8f9fa;">
@foreach (var message in chatMessages)
{
<div class="message @(message.IsUser ? "user-message" : "pirate-message")" style="margin-bottom: 10px; padding: 8px; border-radius: 8px; @(message.IsUser ? "background-color: #007bff; color: white; text-align: right;" : "background-color: #e9ecef;")">
<strong>@(message.IsUser ? "You" : "🏴‍☠️ Pirate"):</strong>
<div style="margin-top: 4px;">@message.Content</div>
</div>
}
@if (isStreaming && currentStreamedMessage.Length > 0)
{
<div class="message pirate-message streaming" style="margin-bottom: 10px; padding: 8px; border-radius: 8px; background-color: #e9ecef;">
<strong>🏴‍☠️ Pirate:</strong>
<div style="margin-top: 4px;">@currentStreamedMessage<span class="typing-indicator">▋</span></div>
</div>
}
</div>
<div class="input-group">
<input @bind="currentMessage" @onkeypress="HandleKeyPress" class="form-control" placeholder="Type your message here..." disabled="@isStreaming" />
<button @onclick="SendMessage" class="btn btn-primary" disabled="@(isStreaming || string.IsNullOrWhiteSpace(currentMessage))">
@if (isStreaming)
{
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span>Sending...</span>
}
else
{
<span>Send</span>
}
</button>
</div>
</div>
<style>
.typing-indicator {
animation: blink 1s infinite;
}
@@keyframes blink {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0; }
}
.chat-container {
max-width: 800px;
}
.user-message {
margin-left: 20%;
}
.pirate-message {
margin-right: 20%;
}
</style>
@code {
private string currentMessage = "";
private bool isStreaming = false;
private string currentStreamedMessage = "";
private List<ChatMessage> 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; }
}
}
@@ -0,0 +1,6 @@
<Router AppAssembly="typeof(Program).Assembly">
<Found Context="routeData">
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
<FocusOnNavigate RouteData="routeData" Selector="h1" />
</Found>
</Router>
@@ -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
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\HelloHttpApi.ServiceDefaults\HelloHttpApi.ServiceDefaults.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Extensions.AI.Agents.Abstractions\Microsoft.Extensions.AI.Agents.Abstractions.csproj" />
</ItemGroup>
</Project>
@@ -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<AgentClient>(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<App>()
.AddInteractiveServerRenderMode();
app.MapDefaultEndpoints();
app.Run();
@@ -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"
}
}
}
}
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Trace",
"Microsoft.AspNetCore": "Warning"
}
}
}
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Trace",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
@@ -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;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -2,6 +2,8 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Microsoft.Extensions.AI.Agents.Runtime;
@@ -9,6 +11,7 @@ namespace Microsoft.Extensions.AI.Agents.Runtime;
/// Provides a unique identifier for an actor instance within an agent runtime,
/// serving as the "address" of the actor instance for receiving messages.
/// </summary>
[JsonConverter(typeof(Converter))]
public readonly struct ActorId : IEquatable<ActorId>
{
/// <summary>
@@ -113,4 +116,28 @@ public readonly struct ActorId : IEquatable<ActorId>
return true;
#endif
}
/// <summary>
/// JSON converter for <see cref="ActorId"/>.
/// </summary>
public sealed class Converter : JsonConverter<ActorId>
{
/// <inheritdoc/>
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);
}
/// <inheritdoc/>
public override void Write(Utf8JsonWriter writer, ActorId value, JsonSerializerOptions options)
{
writer.WriteStringValue(value.ToString());
}
}
}
@@ -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;
/// <summary>
/// Source-generated JSON type information for use by all Actor abstractions.
/// </summary>
[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;
@@ -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;
/// <summary>
/// Base class for all actor messages that can be sent between actors.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
//[JsonConverter(typeof(Converter))]
[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")]
[JsonDerivedType(typeof(ActorRequestMessage), "request")]
[JsonDerivedType(typeof(ActorResponseMessage), "response")]
public abstract class ActorMessage
{
/// <summary>Prevent external derivations.</summary>
private protected ActorMessage()
{
}
/// <summary>
/// Gets the type of the message.
/// </summary>
[JsonIgnore]
public abstract ActorMessageType Type { get; }
/// <summary>
/// Additional properties that can be used to extend the message with custom data.
/// </summary>
[JsonExtensionData]
public Dictionary<string, JsonElement>? ExtensionData { get; set; }
}
@@ -0,0 +1,23 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Specifies the type of actor message.
/// </summary>
public enum ActorMessageType
{
/// <summary>
/// Represents a request message sent to an actor.
/// </summary>
[JsonStringEnumMemberName("request")]
Request,
/// <summary>
/// Represents a response message sent from an actor.
/// </summary>
[JsonStringEnumMemberName("response")]
Response
}
@@ -0,0 +1,14 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Base class for write operations that modify an actor's messaging (inbox/outbox).
/// </summary>
public abstract class ActorMessageWriteOperation : ActorWriteOperation
{
/// <summary>Prevent external derivations.</summary>
private protected ActorMessageWriteOperation()
{
}
}
@@ -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;
/// <summary>
/// Base class for all actor read operations that can query actor state or messaging.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")]
[JsonDerivedType(typeof(ListKeysOperation), "list_keys")]
[JsonDerivedType(typeof(GetValueOperation), "get_value")]
public abstract class ActorReadOperation
{
/// <summary>Prevent external derivations.</summary>
private protected ActorReadOperation()
{
}
/// <summary>
/// Gets the type of the read operation.
/// </summary>
[JsonIgnore]
public abstract ActorReadOperationType Type { get; }
}
@@ -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;
/// <summary>
/// Represents a batch of read operations to be performed on an actor.
/// </summary>
/// <param name="operations">The collection of read operations to perform.</param>
public class ActorReadOperationBatch(IReadOnlyList<ActorReadOperation> operations)
{
/// <summary>
/// Gets the collection of read operations to perform.
/// </summary>
[JsonPropertyName("operations")]
public IReadOnlyList<ActorReadOperation> Operations { get; } = operations;
}
@@ -0,0 +1,23 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Specifies the type of actor read operation.
/// </summary>
public enum ActorReadOperationType
{
/// <summary>
/// Represents a list keys operation.
/// </summary>
[JsonStringEnumMemberName("list_keys")]
ListKeys,
/// <summary>
/// Represents a get value operation.
/// </summary>
[JsonStringEnumMemberName("get_value")]
GetValue
}
@@ -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;
/// <summary>
/// Base class for all actor read operation results.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")]
[JsonDerivedType(typeof(ListKeysResult), "list_keys")]
[JsonDerivedType(typeof(GetValueOperation), "get_value")]
public abstract class ActorReadResult
{
/// <summary>Prevent external derivations.</summary>
private protected ActorReadResult()
{
}
/// <summary>
/// Gets the type of the read result operation.
/// </summary>
[JsonIgnore]
public abstract ActorReadResultType Type { get; }
}
@@ -0,0 +1,23 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Specifies the type of actor read result operation.
/// </summary>
public enum ActorReadResultType
{
/// <summary>
/// Represents a list keys operation result.
/// </summary>
[JsonStringEnumMemberName("list_keys")]
ListKeys,
/// <summary>
/// Represents a get value operation result.
/// </summary>
[JsonStringEnumMemberName("get_value")]
GetValue
}
@@ -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;
/// <summary>
/// Represents a request to be sent to an actor.
/// </summary>
public class ActorRequest(ActorId actorId, string messageId, string method, JsonElement @params)
{
/// <summary>
/// Gets or sets the identifier of the target actor.
/// </summary>
[JsonPropertyName("actorId")]
public ActorId ActorId { get; } = actorId;
/// <summary>
/// Gets or sets the unique identifier for this request.
/// </summary>
[JsonPropertyName("messageId")]
public string MessageId { get; } = messageId;
/// <summary>
/// Gets or sets the method name to invoke on the actor.
/// </summary>
[JsonPropertyName("method")]
public string Method { get; } = method;
/// <summary>
/// Gets or sets the parameters for the method invocation.
/// </summary>
[JsonPropertyName("params")]
public JsonElement Params { get; } = @params;
}
@@ -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;
/// <summary>
/// Base class for request messages sent to actors.
/// </summary>
public sealed class ActorRequestMessage(string MessageId) : ActorMessage
{
/// <inheritdoc/>
public override ActorMessageType Type => ActorMessageType.Request;
/// <summary>
/// Gets or sets the actor ID of the sender.
/// </summary>
[JsonPropertyName("sender")]
public ActorId? SenderId { get; init; }
/// <summary>
/// Gets or sets the unique identifier for the request.
/// </summary>
[JsonPropertyName("messageId")]
public string MessageId { get; } = MessageId;
/// <summary>
/// Name of the method to invoke.
/// </summary>
[JsonPropertyName("method")]
public string? Method { get; init; }
/// <summary>
/// Optional parameters for the method invocation.
/// </summary>
[JsonPropertyName("params")]
public JsonElement Params { get; init; }
}
@@ -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.
/// <summary>
/// Represents an update to an actor request's status and data.
/// </summary>
public class ActorRequestUpdate(RequestStatus status, JsonElement data)
{
/// <summary>
/// Gets the updated status of the request.
/// </summary>
[JsonPropertyName("status")]
public RequestStatus Status { get; } = status;
/// <summary>
/// Gets the updated data associated with the request.
/// </summary>
[JsonPropertyName("data")]
public JsonElement Data { get; } = data;
}
@@ -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;
/// <summary>
/// Represents a response handle for an actor request, providing access to the result and status updates.
/// </summary>
public class ActorResponse
{
/// <summary>
/// Gets the identifier of the actor that is processing the request.
/// </summary>
[JsonPropertyName("actorId")]
public ActorId ActorId { get; init; }
/// <summary>
/// Gets the unique identifier of the message/request.
/// </summary>
[JsonPropertyName("messageId")]
public string? MessageId { get; init; }
/// <summary>
/// Gets the response data from the actor.
/// </summary>
[JsonPropertyName("data")]
public JsonElement Data { get; init; }
/// <summary>
/// Gets or sets the current status of the request.
/// </summary>
[JsonPropertyName("status")]
public RequestStatus Status { get; init; }
}
@@ -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;
/// <summary>
/// Represents a handle to an actor response, allowing retrieval of the response data and status updates.
/// </summary>
public abstract class ActorResponseHandle
{
/// <summary>
/// Attempts to get the response from the request if it is immediately available.
/// </summary>
/// <param name="response">When this method returns <see langword="true"/>, contains the actor response; otherwise, <see langword="null"/>.</param>
/// <returns><see langword="true"/> if the response is immediately available; otherwise, <see langword="false"/>.</returns>
/// <remarks>
/// This method does not block and returns immediately. If the request is still pending or processing,
/// this method returns <see langword="false"/>.
/// Use <see cref="GetResponseAsync(CancellationToken)"/> to wait asynchronously for the response to become available.
/// </remarks>
public abstract bool TryGetResponse([NotNullWhen(true)] out ActorResponse? response);
/// <summary>
/// Gets the response from the completed request.
/// </summary>
/// <param name="cancellationToken">A token to cancel the wait operation.</param>
/// <returns>A task that completes when the request is finished.</returns>
public abstract ValueTask<ActorResponse> GetResponseAsync(CancellationToken cancellationToken);
/// <summary>
/// Cancels the request if it is still pending.
/// </summary>
/// <returns>A task representing the cancellation operation.</returns>
public abstract ValueTask CancelAsync(CancellationToken cancellationToken);
/// <summary>
/// Watches for status and data updates to the request.
/// </summary>
/// <param name="cancellationToken">A token to cancel the watch operation.</param>
/// <returns>An asynchronous enumerable of request updates.</returns>
public abstract IAsyncEnumerable<ActorRequestUpdate> WatchUpdatesAsync(CancellationToken cancellationToken);
}
@@ -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;
/// <summary>
/// Base class for response messages sent from actors.
/// </summary>
public sealed class ActorResponseMessage(string MessageId) : ActorMessage
{
/// <inheritdoc/>
public override ActorMessageType Type => ActorMessageType.Response;
/// <summary>
/// Gets or sets the actor ID of the sender.
/// </summary>
[JsonPropertyName("senderId")]
public ActorId SenderId { get; init; }
/// <summary>
/// Gets or sets the unique identifier for the request.
/// </summary>
[JsonPropertyName("messageId")]
public string MessageId { get; } = MessageId;
/// <summary>
/// Gets or sets the status of the request.
/// </summary>
[JsonPropertyName("status")]
public RequestStatus Status { get; init; }
/// <summary>
/// Gets or sets the response data (result or error information).
/// </summary>
[JsonPropertyName("data")]
public JsonElement Data { get; init; }
}
@@ -0,0 +1,14 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Base class for read operations that query an actor's internal state.
/// </summary>
public abstract class ActorStateReadOperation : ActorReadOperation
{
/// <summary>Prevent external derivations.</summary>
private protected ActorStateReadOperation()
{
}
}
@@ -0,0 +1,19 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Base class for write operations that modify an actor's internal state.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public abstract class ActorStateWriteOperation : ActorWriteOperation
{
/// <summary>Prevent external derivations.</summary>
private protected ActorStateWriteOperation()
{
}
}
@@ -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;
/// <summary>
/// Represents the type of an actor.
/// </summary>
[JsonConverter(typeof(Converter))]
public readonly partial struct ActorType : IEquatable<ActorType>
{
/// <summary>
@@ -60,9 +63,33 @@ public readonly partial struct ActorType : IEquatable<ActorType>
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
/// <summary>
/// JSON converter for <see cref="ActorType"/>.
/// </summary>
public sealed class Converter : JsonConverter<ActorType>
{
/// <inheritdoc/>
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);
}
/// <inheritdoc/>
public override void Write(Utf8JsonWriter writer, ActorType value, JsonSerializerOptions options)
{
writer.WriteStringValue(value.Name);
}
}
}
@@ -0,0 +1,32 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Base class for all actor write operations that can modify actor state or messaging.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[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
{
/// <summary>Prevent external derivations.</summary>
private protected ActorWriteOperation()
{
}
/// <summary>
/// Gets the type of the write operation.
/// </summary>
[JsonIgnore]
public abstract ActorWriteOperationType Type { get; }
}
@@ -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;
/// <summary>
/// Represents a batch of write operations to be performed atomically on an actor.
/// </summary>
/// <param name="eTag">The ETag for optimistic concurrency control.</param>
/// <param name="operations">The collection of write operations to perform.</param>
public class ActorWriteOperationBatch(string eTag, IReadOnlyCollection<ActorWriteOperation> operations)
{
/// <summary>
/// Gets the collection of write operations to perform.
/// </summary>
[JsonPropertyName("operations")]
public IReadOnlyCollection<ActorWriteOperation> Operations { get; } = operations;
/// <summary>
/// Gets the ETag for optimistic concurrency control.
/// </summary>
[JsonPropertyName("etag")]
public string ETag { get; } = eTag;
}
@@ -0,0 +1,35 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Specifies the type of actor write operation.
/// </summary>
public enum ActorWriteOperationType
{
/// <summary>
/// Represents a set key-value operation.
/// </summary>
[JsonStringEnumMemberName("set_value")]
SetValue,
/// <summary>
/// Represents a remove key operation.
/// </summary>
[JsonStringEnumMemberName("remove_key")]
RemoveKey,
/// <summary>
/// Represents a send request operation.
/// </summary>
[JsonStringEnumMemberName("send_request")]
SendRequest,
/// <summary>
/// Represents an update request operation.
/// </summary>
[JsonStringEnumMemberName("update_request")]
UpdateRequest
}
@@ -0,0 +1,23 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Represents a request to read a value from the actor's state by its key.
/// </summary>
/// <param name="key">The key corresponding to the value to read from the actor's state.</param>
public class GetValueOperation(string key) : ActorStateReadOperation
{
/// <summary>
/// Gets the key corresponding to the value to read from the actor's state.
/// </summary>
[JsonPropertyName("key")]
public string Key { get; } = key;
/// <summary>
/// Gets the type of the read operation.
/// </summary>
public override ActorReadOperationType Type => ActorReadOperationType.GetValue;
}
@@ -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;
/// <summary>
/// Represents the result of a get value operation containing the retrieved value.
/// </summary>
/// <param name="value">The value retrieved from the actor's state, or null if not found.</param>
public class GetValueResult(JsonElement? value) : ActorReadResult
{
/// <summary>
/// Gets the value retrieved from the actor's state.
/// </summary>
[JsonPropertyName("value")]
public JsonElement? Value { get; } = value;
/// <summary>
/// Gets the type of the read result operation.
/// </summary>
public override ActorReadResultType Type => ActorReadResultType.GetValue;
}
@@ -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)
/// <summary>
/// Represents an actor in the actor system that can process messages and maintain state.
/// </summary>
public interface IActor : IAsyncDisposable
{
/// <summary>
/// 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.
/// </summary>
/// <param name="cancellationToken">A token to cancel the start operation.</param>
/// <returns>A task representing the start operation.</returns>
ValueTask RunAsync(CancellationToken cancellationToken);
}
@@ -0,0 +1,30 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Interface for sending requests to actors and managing responses.
/// </summary>
public interface IActorClient
{
/// <summary>
/// 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.
/// </summary>
/// <param name="request">The request to send to the actor.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A task representing the actor response handle.</returns>
ValueTask<ActorResponseHandle> SendRequestAsync(ActorRequest request, CancellationToken cancellationToken);
/// <summary>
/// Gets an already-running request by its identifier.
/// </summary>
/// <param name="actorId">The identifier of the actor processing the request.</param>
/// <param name="messageId">The unique identifier of the request message.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A task representing the actor response handle.</returns>
ValueTask<ActorResponseHandle> GetResponseAsync(ActorId actorId, string messageId, CancellationToken cancellationToken);
}
@@ -0,0 +1,18 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Builder interface for configuring actor types in the runtime.
/// </summary>
public interface IActorRuntimeBuilder
{
/// <summary>
/// Registers an actor type with its factory method.
/// </summary>
/// <param name="type">The actor type to register.</param>
/// <param name="activator">The factory method to create instances of the actor.</param>
void AddActorType(ActorType type, Func<IServiceProvider, IActorRuntimeContext, IActor> activator);
}
@@ -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;
/// <summary>
/// Provides the runtime context for an actor, enabling it to interact with the actor system.
/// </summary>
public interface IActorRuntimeContext
{
/// <summary>
/// Gets the identifier of the actor.
/// </summary>
ActorId ActorId { get; }
/// <summary>
/// Watches for incoming requests and responses in the actor's inbox and outbox.
/// </summary>
/// <param name="cancellationToken">A token to cancel the watch operation.</param>
/// <returns>An asynchronous enumerable of actor notifications.</returns>
IAsyncEnumerable<ActorMessage> WatchMessagesAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Performs a batch of write operations atomically.
/// </summary>
/// <param name="operations">The batch of write operations to perform.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A task representing the write response.</returns>
ValueTask<WriteResponse> WriteAsync(ActorWriteOperationBatch operations, CancellationToken cancellationToken = default);
/// <summary>
/// Performs a batch of read operations.
/// </summary>
/// <param name="operations">The batch of read operations to perform.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A task representing the read response.</returns>
ValueTask<ReadResponse> ReadAsync(ActorReadOperationBatch operations, CancellationToken cancellationToken = default);
/// <summary>
/// Reports progress updates for streaming responses.
/// The messageId must correspond to a non-terminated request in the actor's inbox (Status is Pending).
/// </summary>
/// <param name="messageId">The identifier of the message being updated.</param>
/// <param name="sequenceNumber">The sequence number for ordering progress updates.</param>
/// <param name="data">The progress data.</param>
void OnProgressUpdate(string messageId, int sequenceNumber, JsonElement data);
}
@@ -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;
/// <summary>
/// Interface for actor state storage operations, providing persistence for actor state data.
/// </summary>
public interface IActorStateStorage
{
/// <summary>
/// Writes state changes to the actor's persistent storage.
/// </summary>
/// <param name="actorId">The identifier of the actor whose state is being modified.</param>
/// <param name="operations">The collection of write operations to perform.</param>
/// <param name="etag">The expected ETag for optimistic concurrency control.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A task representing the write response with success status and updated ETag.</returns>
ValueTask<WriteResponse> WriteStateAsync(ActorId actorId, IReadOnlyCollection<ActorStateWriteOperation> operations, string etag, CancellationToken cancellationToken = default);
/// <summary>
/// Reads state data from the actor's persistent storage.
/// </summary>
/// <param name="actorId">The identifier of the actor whose state is being read.</param>
/// <param name="operations">The collection of read operations to perform.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A task representing the read response with results and current ETag.</returns>
ValueTask<ReadResponse> ReadStateAsync(ActorId actorId, IReadOnlyCollection<ActorStateReadOperation> operations, CancellationToken cancellationToken = default);
}
@@ -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;
/// <summary>
/// Provides an in-memory implementation of <see cref="IActorStateStorage"/> for testing and development scenarios.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// <para>
/// Supported operations:
/// <list type="bullet">
/// <item><description><see cref="SetValueOperation"/> - Sets a key-value pair in the actor's state</description></item>
/// <item><description><see cref="RemoveKeyOperation"/> - Removes a key from the actor's state</description></item>
/// <item><description><see cref="GetValueOperation"/> - Retrieves a value by key from the actor's state</description></item>
/// <item><description><see cref="ListKeysOperation"/> - Lists keys in the actor's state with optional prefix filtering</description></item>
/// </list>
/// </para>
/// <para>
/// This implementation is suitable for:
/// <list type="bullet">
/// <item><description>Unit testing scenarios</description></item>
/// <item><description>Development and prototyping</description></item>
/// <item><description>Single-process applications where persistence is not required</description></item>
/// </list>
/// </para>
/// <para>
/// For production scenarios requiring persistence, consider implementing a custom storage provider
/// that uses a database or other persistent storage mechanism.
/// </para>
/// </remarks>
/// <example>
/// <code>
/// // Create storage instance
/// var storage = new InMemoryActorStateStorage();
/// var actorId = new ActorId("MyActor", "instance1");
///
/// // Write some state
/// var writeOps = new List&lt;ActorStateWriteOperation&gt;
/// {
/// 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&lt;ActorStateReadOperation&gt;
/// {
/// 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);
/// </code>
/// </example>
public sealed class InMemoryActorStateStorage : IActorStateStorage
{
private static readonly ActivitySource ActivitySource = new("Microsoft.Extensions.AI.Agents.Runtime.Abstractions.InMemoryActorStateStorage");
private readonly ConcurrentDictionary<ActorId, ActorState> _actorStates = new();
private readonly object _lockObject = new();
private long _globalETagCounter = 0;
/// <summary>
/// Represents the internal state of an actor including its key-value pairs and ETag.
/// </summary>
private sealed class ActorState
{
public ConcurrentDictionary<string, JsonElement> Data { get; } = new();
public string ETag { get; set; } = "0";
}
/// <inheritdoc/>
public ValueTask<WriteResponse> WriteStateAsync(ActorId actorId, IReadOnlyCollection<ActorStateWriteOperation> 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<WriteResponse>(new WriteResponse(actorState.ETag, success: false));
}
// Apply all operations
var operationTypes = new List<string>();
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<WriteResponse>(new WriteResponse(newETag, success: true));
}
}
catch (Exception ex)
{
SetErrorAttributes(activity, ex);
throw;
}
}
/// <inheritdoc/>
public ValueTask<ReadResponse> ReadStateAsync(ActorId actorId, IReadOnlyCollection<ActorStateReadOperation> 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<ActorReadResult>();
var operationTypes = new List<string>();
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<ReadResponse>(new ReadResponse(actorState.ETag, results.AsReadOnly()));
}
catch (Exception ex)
{
SetErrorAttributes(activity, ex);
throw;
}
}
/// <summary>
/// Clears all stored actor state. This method is primarily intended for testing scenarios.
/// </summary>
public void Clear()
{
lock (this._lockObject)
{
this._actorStates.Clear();
Interlocked.Exchange(ref this._globalETagCounter, 0);
}
}
/// <summary>
/// Gets the current count of actors that have state stored.
/// </summary>
/// <returns>The number of actors with stored state.</returns>
public int ActorCount => this._actorStates.Count;
/// <summary>
/// Gets the current count of keys stored for a specific actor.
/// </summary>
/// <param name="actorId">The actor identifier.</param>
/// <returns>The number of keys stored for the specified actor, or 0 if the actor has no state.</returns>
public int GetKeyCount(ActorId actorId)
{
return this._actorStates.TryGetValue(actorId, out var state) ? state.Data.Count : 0;
}
/// <summary>
/// Gets the current ETag for a specific actor.
/// </summary>
/// <param name="actorId">The actor identifier.</param>
/// <returns>The current ETag for the specified actor, or "0" if the actor has no state.</returns>
public string GetETag(ActorId actorId)
{
return this._actorStates.TryGetValue(actorId, out var state) ? state.ETag : "0";
}
/// <summary>
/// Sets actor attributes on an activity.
/// </summary>
/// <param name="activity">The activity to set attributes on.</param>
/// <param name="actorId">The actor ID.</param>
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);
}
/// <summary>
/// Sets state operation attributes on an activity.
/// </summary>
/// <param name="activity">The activity to set attributes on.</param>
/// <param name="operationType">The type of state operation.</param>
/// <param name="operationCount">Optional count of operations.</param>
/// <param name="etag">Optional ETag value.</param>
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);
}
}
/// <summary>
/// Sets success/failure status on an activity.
/// </summary>
/// <param name="activity">The activity to set status on.</param>
/// <param name="success">Whether the operation was successful.</param>
/// <param name="errorMessage">Optional error message for failures.</param>
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);
}
}
/// <summary>
/// Sets error attributes on an activity.
/// </summary>
/// <param name="activity">The activity to set error attributes on.</param>
/// <param name="exception">The exception that occurred.</param>
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
}));
}
}
@@ -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;
/// <summary>
/// Provides extension methods for JSON serialization with source generation support.
/// </summary>
internal static class JsonSerializerExtensions
{
/// <summary>
/// Gets the JsonTypeInfo for a type, preferring the one from options if available,
/// otherwise falling back to the source-generated context.
/// </summary>
/// <typeparam name="T">The type to get JsonTypeInfo for.</typeparam>
/// <param name="options">The JsonSerializerOptions to check first.</param>
/// <param name="fallbackContext">The fallback JsonSerializerContext to use if not found in options.</param>
/// <returns>The JsonTypeInfo for the requested type.</returns>
public static JsonTypeInfo<T> GetTypeInfo<T>(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<T> typeInfo)
{
return typeInfo;
}
// Fall back to the provided source-generated context
return (JsonTypeInfo<T>)fallbackContext.GetTypeInfo(typeof(T))!;
}
}
@@ -0,0 +1,30 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Represents an operation to list keys from an actor's state, with optional pagination support.
/// </summary>
/// <param name="continuationToken">Optional token for pagination to continue listing from a previous operation.</param>
/// <param name="keyPrefix">Optional prefix to filter keys. Only keys starting with this prefix will be returned.</param>
public class ListKeysOperation(string? continuationToken, string? keyPrefix = null) : ActorStateReadOperation
{
/// <summary>
/// Gets the continuation token for pagination.
/// </summary>
[JsonPropertyName("continuationToken")]
public string? ContinuationToken { get; } = continuationToken;
/// <summary>
/// Gets the key prefix for filtering. Only keys starting with this prefix will be returned.
/// </summary>
[JsonPropertyName("keyPrefix")]
public string? KeyPrefix { get; } = keyPrefix;
/// <summary>
/// Gets the type of the read operation.
/// </summary>
public override ActorReadOperationType Type => ActorReadOperationType.ListKeys;
}
@@ -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;
/// <summary>
/// Represents the result of a list keys operation containing the found keys and optional continuation token.
/// </summary>
/// <param name="keys">The collection of keys found in the actor's state.</param>
/// <param name="continuationToken">Optional token for pagination to retrieve additional keys.</param>
public class ListKeysResult(IReadOnlyCollection<string> keys, string? continuationToken) : ActorReadResult
{
/// <summary>
/// Gets the collection of keys found in the actor's state.
/// </summary>
[JsonPropertyName("keys")]
public IReadOnlyCollection<string> Keys { get; } = keys;
/// <summary>
/// Gets the continuation token for pagination.
/// </summary>
[JsonPropertyName("continuationToken")]
public string? ContinuationToken { get; } = continuationToken;
/// <summary>
/// Gets the type of the read result operation.
/// </summary>
public override ActorReadResultType Type => ActorReadResultType.ListKeys;
}
@@ -5,12 +5,14 @@
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsDebugTargetFrameworks)</TargetFrameworks>
<NoWarn>$(NoWarn);IDE1006;IDE0130</NoWarn>
<VersionSuffix>alpha</VersionSuffix>
<RootNamespace>Microsoft.Extensions.AI.Agents.Runtime</RootNamespace>
</PropertyGroup>
<PropertyGroup>
<InjectSharedThrow>true</InjectSharedThrow>
<InjectDiagnosticAttributesOnLegacy>true</InjectDiagnosticAttributesOnLegacy>
<InjectTrimAttributesOnLegacy>true</InjectTrimAttributesOnLegacy>
<InjectIsExternalInitOnLegacy>true</InjectIsExternalInitOnLegacy>
</PropertyGroup>
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
@@ -23,6 +25,10 @@
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
<PackageReference Include="System.Text.Json" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFrameworkIdentifier)' != '.NETCoreApp'">
<PackageReference Include="Microsoft.Bcl.HashCode" />
<PackageReference Include="System.Text.Json" />
@@ -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;
/// <summary>
/// The response of a read request for an actor.
/// </summary>
/// <param name="eTag">The actor's last-known ETag value.</param>
/// <param name="results">The ordered collection of results.</param>
public class ReadResponse(string eTag, IReadOnlyList<ActorReadResult> results)
{
/// <summary>
/// Gets the version of the state update.
/// </summary>
[JsonPropertyName("etag")]
public string ETag { get; } = eTag;
/// <summary>
/// Gets the ordered collection of read operation results.
/// </summary>
[JsonPropertyName("results")]
public IReadOnlyList<ActorReadResult> Results { get; } = results;
}
@@ -0,0 +1,23 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Represents an operation to remove a key from an actor's state.
/// </summary>
/// <param name="Key">The key to remove from the actor's state.</param>
public sealed class RemoveKeyOperation(string Key) : ActorStateWriteOperation
{
/// <summary>
/// Gets the key for the state operation.
/// </summary>
[JsonPropertyName("key")]
public string Key { get; } = Key;
/// <summary>
/// Gets the type of the write operation.
/// </summary>
public override ActorWriteOperationType Type => ActorWriteOperationType.RemoveKey;
}
@@ -0,0 +1,35 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Represents the status of a request in the actor system.
/// </summary>
public enum RequestStatus
{
/// <summary>
/// The request is pending and has not yet been processed.
/// </summary>
[JsonStringEnumMemberName("pending")]
Pending,
/// <summary>
/// The request has been completed successfully.
/// </summary>
[JsonStringEnumMemberName("completed")]
Completed,
/// <summary>
/// The request has failed.
/// </summary>
[JsonStringEnumMemberName("failed")]
Failed,
/// <summary>
/// The request was not found, possibly due to it being deleted or never existing.
/// </summary>
[JsonStringEnumMemberName("not_found")]
NotFound,
}
@@ -0,0 +1,23 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Represents an operation to send a request message to another actor.
/// </summary>
/// <param name="Message">The request message to send.</param>
public sealed class SendRequestOperation(ActorRequestMessage Message) : ActorMessageWriteOperation
{
/// <summary>
/// Gets the message to send.
/// </summary>
[JsonPropertyName("message")]
public ActorRequestMessage Message { get; } = Message;
/// <summary>
/// Gets the type of the write operation.
/// </summary>
public override ActorWriteOperationType Type => ActorWriteOperationType.SendRequest;
}
@@ -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;
/// <summary>
/// Represents an operation to set a key-value pair in an actor's state.
/// </summary>
/// <param name="Key">The key to set in the actor's state.</param>
/// <param name="Value">The value to associate with the key.</param>
public sealed class SetValueOperation(string Key, JsonElement Value) : ActorStateWriteOperation
{
/// <summary>
/// Gets the key for the state operation.
/// </summary>
[JsonPropertyName("key")]
public string Key { get; } = Key;
/// <summary>
/// Gets the value for the state operation.
/// </summary>
[JsonPropertyName("value")]
public JsonElement Value { get; } = Value;
/// <summary>
/// Gets the type of the write operation.
/// </summary>
public override ActorWriteOperationType Type => ActorWriteOperationType.SetValue;
}
@@ -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;
/// <summary>
/// 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).
/// </summary>
/// <param name="MessageId">The identifier of the message to update.</param>
/// <param name="Status">The new status for the request.</param>
/// <param name="Data">The data associated with the status update (e.g., result for completed requests).</param>
public sealed class UpdateRequestOperation(string MessageId, RequestStatus Status, JsonElement Data) : ActorMessageWriteOperation
{
/// <summary>
/// Gets the identifier of the message to update.
/// </summary>
[JsonPropertyName("messageId")]
public string MessageId { get; } = MessageId;
/// <summary>
/// Gets the new status for the request.
/// </summary>
[JsonPropertyName("status")]
public RequestStatus Status { get; } = Status;
/// <summary>
/// Gets the data associated with the status update.
/// </summary>
[JsonPropertyName("data")]
public JsonElement Data { get; } = Data;
/// <summary>
/// Gets the type of the write operation.
/// </summary>
public override ActorWriteOperationType Type => ActorWriteOperationType.UpdateRequest;
}
@@ -0,0 +1,29 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Represents the response of a write request for an actor.
/// </summary>
/// <param name="eTag">The actor's updated ETag value after the write operation.</param>
/// <param name="success">Whether the write operation was successful.</param>
public class WriteResponse(string eTag, bool success)
{
/// <summary>
/// Gets the version of the state update.
/// </summary>
[JsonPropertyName("etag")]
public string ETag { get; } = eTag;
/// <summary>
/// Whether the write operation was successful.
/// </summary>
/// <remarks>
/// If <c>false</c>, the write operation may have failed due to a concurrency conflict or other issue.
/// In either case the <see cref="ETag"/> property will contain the last known ETag value of the actor's state.
/// </remarks>
[JsonPropertyName("success")]
public bool Success { get; } = success;
}
@@ -0,0 +1,409 @@
// Copyright (c) Microsoft. All rights reserved.
using static Microsoft.Extensions.AI.Agents.Runtime.ActorRuntimeOpenTelemetryConsts;
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Helper methods for setting common telemetry attributes on activities.
/// </summary>
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";
/// <summary>
/// Sets common actor attributes on an activity.
/// </summary>
/// <param name="activity">The activity to set attributes on.</param>
/// <param name="actorId">The actor ID.</param>
/// <param name="operation">Optional operation name.</param>
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);
}
}
/// <summary>
/// Sets common message attributes on an activity.
/// </summary>
/// <param name="activity">The activity to set attributes on.</param>
/// <param name="messageId">The message ID.</param>
/// <param name="messageType">Optional message type.</param>
/// <param name="method">Optional message method.</param>
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);
}
}
/// <summary>
/// Sets common request attributes on an activity.
/// </summary>
/// <param name="activity">The activity to set attributes on.</param>
/// <param name="requestId">The request ID.</param>
/// <param name="method">Optional request method.</param>
/// <param name="timeout">Optional timeout value.</param>
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);
}
}
/// <summary>
/// Sets common state operation attributes on an activity.
/// </summary>
/// <param name="activity">The activity to set attributes on.</param>
/// <param name="operationType">The type of state operation.</param>
/// <param name="operationCount">Optional count of operations.</param>
/// <param name="etag">Optional ETag value.</param>
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);
}
}
/// <summary>
/// Sets success/failure status on an activity.
/// </summary>
/// <param name="activity">The activity to set status on.</param>
/// <param name="success">Whether the operation was successful.</param>
/// <param name="errorMessage">Optional error message for failures.</param>
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);
}
}
/// <summary>
/// Sets error attributes on an activity.
/// </summary>
/// <param name="activity">The activity to set error attributes on.</param>
/// <param name="exception">The exception that occurred.</param>
/// <param name="errorType">Optional custom error type.</param>
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
}));
}
/// <summary>
/// Sets RPC-style attributes for actor operations.
/// </summary>
/// <param name="activity">The activity to set attributes on.</param>
/// <param name="service">The RPC service name.</param>
/// <param name="method">The RPC method name.</param>
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);
}
/// <summary>
/// Sets up complete telemetry for actor retrieval/creation operations.
/// </summary>
/// <param name="activity">The activity to set attributes on.</param>
/// <param name="actorId">The actor ID.</param>
/// <param name="exists">Whether the actor already exists.</param>
/// <param name="started">Whether the actor was started.</param>
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);
}
}
/// <summary>
/// Sets up complete telemetry for message operations.
/// </summary>
/// <param name="activity">The activity to set attributes on.</param>
/// <param name="actorId">The actor ID.</param>
/// <param name="messageId">The message ID.</param>
/// <param name="messageType">Optional message type.</param>
/// <param name="method">Optional message method.</param>
/// <param name="status">Optional message status.</param>
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);
}
}
/// <summary>
/// Sets up complete telemetry for request operations.
/// </summary>
/// <param name="activity">The activity to set attributes on.</param>
/// <param name="actorId">The actor ID.</param>
/// <param name="requestId">The request ID.</param>
/// <param name="method">Optional request method.</param>
/// <param name="service">The RPC service name.</param>
/// <param name="rpcMethod">The RPC method name.</param>
/// <param name="timeout">Optional timeout value.</param>
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);
}
/// <summary>
/// Sets up complete telemetry for state operations.
/// </summary>
/// <param name="activity">The activity to set attributes on.</param>
/// <param name="actorId">The actor ID.</param>
/// <param name="operationType">The type of state operation.</param>
/// <param name="operationCount">Optional count of operations.</param>
/// <param name="etag">Optional ETag value.</param>
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);
}
/// <summary>
/// Records successful completion of an operation with optional additional attributes.
/// </summary>
/// <param name="activity">The activity to update.</param>
/// <param name="additionalTags">Optional additional tags to set.</param>
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);
}
}
/// <summary>
/// Records failure of an operation with error details.
/// </summary>
/// <param name="activity">The activity to update.</param>
/// <param name="exception">The exception that occurred.</param>
/// <param name="errorType">Optional custom error type.</param>
/// <param name="additionalTags">Optional additional tags to set.</param>
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);
}
}
/// <summary>
/// Adds an event with common actor context.
/// </summary>
/// <param name="activity">The activity to add the event to.</param>
/// <param name="eventName">The name of the event.</param>
/// <param name="actorId">The actor ID.</param>
/// <param name="additionalData">Optional additional event data.</param>
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));
}
/// <summary>
/// Records successful completion and adds an event in a single terse call.
/// </summary>
/// <param name="activity">The activity to update.</param>
/// <param name="eventName">The name of the event to add.</param>
/// <param name="actorId">The actor ID for the event.</param>
/// <param name="statusTags">Status tags to set on the activity.</param>
/// <param name="eventData">Additional event data.</param>
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);
}
/// <summary>
/// Complete with event - ultra-terse single-line calls.
/// </summary>
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);
/// <summary>
/// Complete with multiple status tags and event.
/// </summary>
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);
/// <summary>
/// Record success with single status.
/// </summary>
public static void Success(this System.Diagnostics.Activity? activity, string status) =>
RecordSuccess(activity, (Request.Status, status));
/// <summary>
/// Add actor event.
/// </summary>
public static void Event(this System.Diagnostics.Activity? activity, string @event, ActorId actor, params (string, object?)[] data) =>
AddActorEvent(activity, @event, actor, data);
/// <summary>
/// Record failure.
/// </summary>
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);
}
@@ -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;
/// <summary>
/// Internal implementation of <see cref="IActorRuntimeBuilder"/> that manages actor type registrations
/// and their associated factory methods for the actor runtime system.
/// </summary>
internal sealed class ActorRuntimeBuilder : IActorRuntimeBuilder
{
private readonly IHostApplicationBuilder _builder;
/// <summary>
/// Gets the collection of registered actor types and their corresponding factory methods.
/// </summary>
/// <value>
/// A dictionary where keys are <see cref="ActorType"/> instances and values are factory functions
/// that create <see cref="IActor"/> instances given an <see cref="IServiceProvider"/> and <see cref="IActorRuntimeContext"/>.
/// </value>
public Dictionary<ActorType, Func<IServiceProvider, IActorRuntimeContext, IActor>> ActorFactories { get; } = new();
/// <summary>
/// Gets or creates an <see cref="ActorRuntimeBuilder"/> 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.
/// </summary>
/// <param name="builder">The host application builder to associate with the actor runtime builder.</param>
/// <returns>
/// An <see cref="ActorRuntimeBuilder"/> instance that can be used to configure actor types.
/// </returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/> is null.</exception>
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;
}
/// <summary>
/// Initializes a new instance of the <see cref="ActorRuntimeBuilder"/> class.
/// </summary>
/// <param name="builder">The host application builder to associate with this actor runtime builder.</param>
private ActorRuntimeBuilder(IHostApplicationBuilder builder)
{
this._builder = builder;
}
/// <summary>
/// Registers an actor type with its factory method in the actor runtime.
/// </summary>
/// <param name="type">The actor type to register.</param>
/// <param name="activator">
/// The factory method that creates instances of the actor. This function receives an
/// <see cref="IServiceProvider"/> for dependency injection and an <see cref="IActorRuntimeContext"/>
/// for the actor's runtime context, and returns an <see cref="IActor"/> instance.
/// </param>
/// <exception cref="ArgumentException">
/// Thrown when an actor type with the same name is already registered.
/// </exception>
/// <remarks>
/// 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.
/// </remarks>
public void AddActorType(ActorType type, Func<IServiceProvider, IActorRuntimeContext, IActor> activator)
{
this.ActorFactories.Add(type, activator);
}
private void ConfigureServices(IServiceCollection services)
{
services.AddSingleton<IActorRuntimeBuilder>(this);
services.AddSingleton<IActorStateStorage, InMemoryActorStateStorage>();
services.AddSingleton<IActorClient, InProcessActorClient>();
services.AddSingleton<InProcessActorRuntime>(sp =>
{
var jsonSerializerOptions = sp.GetService<JsonSerializerOptions>() ?? new();
var actorStateStorage = sp.GetRequiredService<IActorStateStorage>();
return new InProcessActorRuntime(sp, this.ActorFactories, actorStateStorage, jsonSerializerOptions);
});
}
}
@@ -0,0 +1,23 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using Microsoft.Extensions.Hosting;
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Provides extension methods for configuring actor runtime services in a host application.
/// </summary>
public static class ActorRuntimeHostingExtensions
{
/// <summary>
/// Adds actor runtime services to the specified host application builder.
/// </summary>
/// <param name="builder">The <see cref="IHostApplicationBuilder"/> to configure.</param>
/// <returns>An <see cref="IActorRuntimeBuilder"/> that can be used to further configure the actor runtime.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/> is null.</exception>
public static IActorRuntimeBuilder AddActorRuntime(this IHostApplicationBuilder builder)
{
return ActorRuntimeBuilder.GetOrAdd(builder);
}
}
@@ -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
{
}
@@ -0,0 +1,782 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Provides constants used by actor runtime telemetry services following OpenTelemetry semantic conventions.
/// Extends the base agent telemetry with runtime-specific attributes and operations.
/// </summary>
internal static class ActorRuntimeOpenTelemetryConsts
{
/// <summary>
/// The default source name for actor runtime telemetry.
/// </summary>
public const string DefaultSourceName = "Microsoft.Extensions.AI.Agents.Runtime";
/// <summary>
/// The default source name for in-process actor runtime telemetry.
/// </summary>
public const string InProcessSourceName = "Microsoft.Extensions.AI.Agents.Runtime.InProcess";
/// <summary>
/// The unit for count measurements.
/// </summary>
public const string CountUnit = "count";
/// <summary>
/// The unit for byte measurements.
/// </summary>
public const string ByteUnit = "byte";
/// <summary>
/// Constants for runtime operation names following OpenTelemetry semantic conventions.
/// These operations align with RPC and GenAI conventions where applicable.
/// </summary>
public static class Operations
{
/// <summary>
/// Actor creation operation.
/// </summary>
public const string CreateActor = "create_actor";
/// <summary>
/// Actor retrieval operation.
/// </summary>
public const string GetActor = "get_actor";
/// <summary>
/// Actor invocation operation (aligns with GenAI agent invoke conventions).
/// </summary>
public const string InvokeActor = "invoke_actor";
/// <summary>
/// Actor start operation.
/// </summary>
public const string StartActor = "start_actor";
/// <summary>
/// Actor stop operation.
/// </summary>
public const string StopActor = "stop_actor";
/// <summary>
/// Actor dispose operation.
/// </summary>
public const string DisposeActor = "dispose_actor";
/// <summary>
/// Message send operation.
/// </summary>
public const string SendMessage = "send_message";
/// <summary>
/// Message receive operation.
/// </summary>
public const string ReceiveMessage = "receive_message";
/// <summary>
/// Message process operation.
/// </summary>
public const string ProcessMessage = "process_message";
/// <summary>
/// Request send operation (follows RPC client pattern).
/// </summary>
public const string SendRequest = "send_request";
/// <summary>
/// Request receive operation (follows RPC server pattern).
/// </summary>
public const string ReceiveRequest = "receive_request";
/// <summary>
/// Request process operation.
/// </summary>
public const string ProcessRequest = "process_request";
/// <summary>
/// Response send operation.
/// </summary>
public const string SendResponse = "send_response";
/// <summary>
/// Response receive operation.
/// </summary>
public const string ReceiveResponse = "receive_response";
/// <summary>
/// Progress update operation.
/// </summary>
public const string ProgressUpdate = "progress_update";
/// <summary>
/// State read operation.
/// </summary>
public const string StateRead = "state_read";
/// <summary>
/// State write operation.
/// </summary>
public const string StateWrite = "state_write";
/// <summary>
/// Actor runtime initialization operation.
/// </summary>
public const string InitializeRuntime = "initialize_runtime";
/// <summary>
/// Actor runtime shutdown operation.
/// </summary>
public const string ShutdownRuntime = "shutdown_runtime";
}
/// <summary>
/// Constants for span naming patterns following OpenTelemetry semantic conventions.
/// Span names should be low-cardinality and follow the pattern: {namespace} {operation_name} [{target}]
/// </summary>
public static class SpanNames
{
/// <summary>
/// Base pattern for actor operations: "actor {operation}"
/// </summary>
public const string ActorOperationPattern = "actor {0}";
/// <summary>
/// Pattern for actor operations with specific actor type: "actor {operation} {actor_type}"
/// </summary>
public const string ActorOperationWithTypePattern = "actor {0} {1}";
/// <summary>
/// Pattern for message operations: "actor.message {operation}"
/// </summary>
public const string MessageOperationPattern = "actor.message {0}";
/// <summary>
/// Pattern for request operations: "actor.request {operation}"
/// </summary>
public const string RequestOperationPattern = "actor.request {0}";
/// <summary>
/// Pattern for state operations: "actor.state {operation}"
/// </summary>
public const string StateOperationPattern = "actor.state {0}";
/// <summary>
/// Pattern for runtime operations: "actor.runtime {operation}"
/// </summary>
public const string RuntimeOperationPattern = "actor.runtime {0}";
/// <summary>
/// Formats a span name for actor operations.
/// </summary>
/// <param name="operation">The operation name</param>
/// <returns>Formatted span name</returns>
public static string FormatActorOperation(string operation) => $"actor {operation}";
/// <summary>
/// Formats a span name for actor operations with actor type.
/// </summary>
/// <param name="operation">The operation name</param>
/// <param name="actorType">The actor type</param>
/// <returns>Formatted span name</returns>
public static string FormatActorOperationWithType(string operation, string actorType) => $"actor {operation} {actorType}";
/// <summary>
/// Formats a span name for message operations.
/// </summary>
/// <param name="operation">The operation name</param>
/// <returns>Formatted span name</returns>
public static string FormatMessageOperation(string operation) => $"actor.message {operation}";
/// <summary>
/// Formats a span name for request operations.
/// </summary>
/// <param name="operation">The operation name</param>
/// <returns>Formatted span name</returns>
public static string FormatRequestOperation(string operation) => $"actor.request {operation}";
/// <summary>
/// Formats a span name for state operations.
/// </summary>
/// <param name="operation">The operation name</param>
/// <returns>Formatted span name</returns>
public static string FormatStateOperation(string operation) => $"actor.state {operation}";
/// <summary>
/// Formats a span name for runtime operations.
/// </summary>
/// <param name="operation">The operation name</param>
/// <returns>Formatted span name</returns>
public static string FormatRuntimeOperation(string operation) => $"actor.runtime {operation}";
}
/// <summary>
/// Constants for actor-related telemetry attributes.
/// </summary>
public static class Actor
{
/// <summary>
/// The attribute name for the actor ID.
/// </summary>
public const string Id = "actor.id";
/// <summary>
/// The attribute name for the actor type.
/// </summary>
public const string Type = "actor.type";
/// <summary>
/// The attribute name for the actor key.
/// </summary>
public const string Key = "actor.key";
/// <summary>
/// The attribute name for the actor operation.
/// </summary>
public const string Operation = "actor.operation";
/// <summary>
/// The attribute name for whether the actor exists.
/// </summary>
public const string Exists = "actor.exists";
/// <summary>
/// The attribute name for whether the actor was started.
/// </summary>
public const string Started = "actor.started";
/// <summary>
/// The attribute name for the actor runtime type.
/// </summary>
public const string RuntimeType = "actor.runtime.type";
/// <summary>
/// The attribute name for the actor state.
/// </summary>
public const string State = "actor.state";
/// <summary>
/// RPC system identifier for actor runtime (follows RPC semantic conventions).
/// </summary>
public const string RpcSystem = "rpc.system";
/// <summary>
/// RPC service name for actor runtime (follows RPC semantic conventions).
/// </summary>
public const string RpcService = "rpc.service";
/// <summary>
/// RPC method name for actor runtime (follows RPC semantic conventions).
/// </summary>
public const string RpcMethod = "rpc.method";
/// <summary>
/// The system name for actor runtime operations.
/// </summary>
public const string SystemName = "actor_runtime";
/// <summary>
/// Constants for actor lifecycle attributes.
/// </summary>
public static class Lifecycle
{
/// <summary>
/// The attribute name for the actor creation time.
/// </summary>
public const string CreatedAt = "actor.lifecycle.created_at";
/// <summary>
/// The attribute name for the actor start time.
/// </summary>
public const string StartedAt = "actor.lifecycle.started_at";
/// <summary>
/// The attribute name for the actor stop time.
/// </summary>
public const string StoppedAt = "actor.lifecycle.stopped_at";
/// <summary>
/// The attribute name for the actor uptime.
/// </summary>
public const string Uptime = "actor.lifecycle.uptime";
}
/// <summary>
/// Constants for actor context attributes.
/// </summary>
public static class Context
{
/// <summary>
/// The attribute name for the actor context type.
/// </summary>
public const string Type = "actor.context.type";
/// <summary>
/// The attribute name for the actor context status.
/// </summary>
public const string Status = "actor.context.status";
/// <summary>
/// The attribute name for the actor context error.
/// </summary>
public const string Error = "actor.context.error";
}
/// <summary>
/// Constants for actor performance metrics.
/// </summary>
public static class Performance
{
/// <summary>
/// The attribute name for messages processed count.
/// </summary>
public const string MessagesProcessed = "actor.performance.messages_processed";
/// <summary>
/// The attribute name for requests processed count.
/// </summary>
public const string RequestsProcessed = "actor.performance.requests_processed";
/// <summary>
/// The attribute name for processing time.
/// </summary>
public const string ProcessingTime = "actor.performance.processing_time";
/// <summary>
/// The attribute name for queue size.
/// </summary>
public const string QueueSize = "actor.performance.queue_size";
}
}
/// <summary>
/// Constants for message-related telemetry attributes.
/// </summary>
public static class Message
{
/// <summary>
/// The attribute name for the message ID.
/// </summary>
public const string Id = "message.id";
/// <summary>
/// The attribute name for the message type.
/// </summary>
public const string Type = "message.type";
/// <summary>
/// The attribute name for the message method.
/// </summary>
public const string Method = "message.method";
/// <summary>
/// The attribute name for the message size in bytes.
/// </summary>
public const string Size = "message.size";
/// <summary>
/// The attribute name for the message timestamp.
/// </summary>
public const string Timestamp = "message.timestamp";
/// <summary>
/// The attribute name for the message sender.
/// </summary>
public const string Sender = "message.sender";
/// <summary>
/// The attribute name for the message recipient.
/// </summary>
public const string Recipient = "message.recipient";
/// <summary>
/// The attribute name for the message status.
/// </summary>
public const string Status = "message.status";
/// <summary>
/// The attribute name for the message sequence number.
/// </summary>
public const string SequenceNumber = "message.sequence_number";
/// <summary>
/// Constants for message processing attributes.
/// </summary>
public static class Processing
{
/// <summary>
/// The attribute name for processing start time.
/// </summary>
public const string StartTime = "message.processing.start_time";
/// <summary>
/// The attribute name for processing end time.
/// </summary>
public const string EndTime = "message.processing.end_time";
/// <summary>
/// The attribute name for processing duration.
/// </summary>
public const string Duration = "message.processing.duration";
/// <summary>
/// The attribute name for processing status.
/// </summary>
public const string Status = "message.processing.status";
/// <summary>
/// The attribute name for processing error.
/// </summary>
public const string Error = "message.processing.error";
}
}
/// <summary>
/// Constants for request-related telemetry attributes.
/// </summary>
public static class Request
{
/// <summary>
/// The attribute name for the request ID.
/// </summary>
public const string Id = "request.id";
/// <summary>
/// The attribute name for the request method.
/// </summary>
public const string Method = "request.method";
/// <summary>
/// The attribute name for the request status.
/// </summary>
public const string Status = "request.status";
/// <summary>
/// The attribute name for the request timeout.
/// </summary>
public const string Timeout = "request.timeout";
/// <summary>
/// The attribute name for whether the request was cancelled.
/// </summary>
public const string Cancelled = "request.cancelled";
/// <summary>
/// The attribute name for the request retry count.
/// </summary>
public const string RetryCount = "request.retry_count";
}
/// <summary>
/// Constants for response-related telemetry attributes.
/// </summary>
public static class Response
{
/// <summary>
/// The attribute name for the response ID.
/// </summary>
public const string Id = "response.id";
/// <summary>
/// The attribute name for the response status.
/// </summary>
public const string Status = "response.status";
/// <summary>
/// The attribute name for the response size.
/// </summary>
public const string Size = "response.size";
/// <summary>
/// The attribute name for the response type.
/// </summary>
public const string Type = "response.type";
}
/// <summary>
/// Constants for state-related telemetry attributes.
/// </summary>
public static class State
{
/// <summary>
/// The attribute name for the state operation type.
/// </summary>
public const string OperationType = "state.operation.type";
/// <summary>
/// The attribute name for the state operation count.
/// </summary>
public const string OperationCount = "state.operation.count";
/// <summary>
/// The attribute name for the state result count.
/// </summary>
public const string ResultCount = "state.result.count";
/// <summary>
/// The attribute name for the state operation success.
/// </summary>
public const string Success = "state.success";
/// <summary>
/// The attribute name for the state ETag.
/// </summary>
public const string ETag = "state.etag";
/// <summary>
/// The attribute name for the state size.
/// </summary>
public const string Size = "state.size";
/// <summary>
/// The attribute name for the state key.
/// </summary>
public const string Key = "state.key";
}
/// <summary>
/// Constants for runtime client metrics.
/// </summary>
public static class Client
{
/// <summary>
/// Constants for operation duration metrics.
/// </summary>
public static class OperationDuration
{
/// <summary>
/// The description for the operation duration metric.
/// </summary>
public const string Description = "Measures the duration of actor runtime operations";
/// <summary>
/// The name for the operation duration metric.
/// </summary>
public const string Name = "actor.runtime.client.operation.duration";
/// <summary>
/// The explicit bucket boundaries for the operation duration histogram.
/// </summary>
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];
}
/// <summary>
/// Constants for message count metrics.
/// </summary>
public static class MessageCount
{
/// <summary>
/// The description for the message count metric.
/// </summary>
public const string Description = "Measures the number of messages processed by actors";
/// <summary>
/// The name for the message count metric.
/// </summary>
public const string Name = "actor.runtime.client.message.count";
}
/// <summary>
/// Constants for request count metrics.
/// </summary>
public static class RequestCount
{
/// <summary>
/// The description for the request count metric.
/// </summary>
public const string Description = "Measures the number of requests processed by actors";
/// <summary>
/// The name for the request count metric.
/// </summary>
public const string Name = "actor.runtime.client.request.count";
}
/// <summary>
/// Constants for actor count metrics.
/// </summary>
public static class ActorCount
{
/// <summary>
/// The description for the actor count metric.
/// </summary>
public const string Description = "Measures the number of active actors";
/// <summary>
/// The name for the actor count metric.
/// </summary>
public const string Name = "actor.runtime.client.actor.count";
}
/// <summary>
/// Constants for queue size metrics.
/// </summary>
public static class QueueSize
{
/// <summary>
/// The description for the queue size metric.
/// </summary>
public const string Description = "Measures the size of actor message queues";
/// <summary>
/// The name for the queue size metric.
/// </summary>
public const string Name = "actor.runtime.client.queue.size";
/// <summary>
/// The explicit bucket boundaries for the queue size histogram.
/// </summary>
public static readonly int[] ExplicitBucketBoundaries = [0, 1, 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000];
}
/// <summary>
/// Constants for state operation metrics.
/// </summary>
public static class StateOperations
{
/// <summary>
/// The description for the state operations metric.
/// </summary>
public const string Description = "Measures the number of state operations";
/// <summary>
/// The name for the state operations metric.
/// </summary>
public const string Name = "actor.runtime.client.state.operations";
}
}
/// <summary>
/// Constants for error attributes.
/// </summary>
public static class ErrorInfo
{
/// <summary>
/// The attribute name for the error type (follows OpenTelemetry error conventions).
/// </summary>
public const string Type = "error.type";
/// <summary>
/// The attribute name for the error message.
/// </summary>
public const string Message = "error.message";
/// <summary>
/// The attribute name for the error stack trace.
/// </summary>
public const string StackTrace = "error.stack_trace";
/// <summary>
/// Well-known error type for unknown errors.
/// </summary>
public const string TypeOther = "_OTHER";
/// <summary>
/// Well-known error types for actor runtime operations.
/// </summary>
public static class Types
{
/// <summary>
/// Actor not found error.
/// </summary>
public const string ActorNotFound = "actor_not_found";
/// <summary>
/// Actor already exists error.
/// </summary>
public const string ActorAlreadyExists = "actor_already_exists";
/// <summary>
/// Message delivery failure.
/// </summary>
public const string MessageDeliveryFailure = "message_delivery_failure";
/// <summary>
/// Request timeout error.
/// </summary>
public const string RequestTimeout = "request_timeout";
/// <summary>
/// State operation failure.
/// </summary>
public const string StateOperationFailure = "state_operation_failure";
/// <summary>
/// Runtime initialization failure.
/// </summary>
public const string RuntimeInitializationFailure = "runtime_initialization_failure";
}
}
/// <summary>
/// Constants for event attributes and well-known event names.
/// </summary>
public static class EventInfo
{
/// <summary>
/// The attribute name for the event name.
/// </summary>
public const string Name = "event.name";
/// <summary>
/// The attribute name for the event data.
/// </summary>
public const string Data = "event.data";
/// <summary>
/// The attribute name for the event timestamp.
/// </summary>
public const string Timestamp = "event.timestamp";
/// <summary>
/// Well-known event names for actor runtime operations.
/// </summary>
public static class Names
{
/// <summary>
/// Actor created event.
/// </summary>
public const string ActorCreated = "actor.created";
/// <summary>
/// Actor started event.
/// </summary>
public const string ActorStarted = "actor.started";
/// <summary>
/// Actor stopped event.
/// </summary>
public const string ActorStopped = "actor.stopped";
/// <summary>
/// Message sent event.
/// </summary>
public const string MessageSent = "actor.message.sent";
/// <summary>
/// Message received event.
/// </summary>
public const string MessageReceived = "actor.message.received";
/// <summary>
/// Request completed event.
/// </summary>
public const string RequestCompleted = "actor.request.completed";
/// <summary>
/// State updated event.
/// </summary>
public const string StateUpdated = "actor.state.updated";
/// <summary>
/// Runtime initialized event.
/// </summary>
public const string RuntimeInitialized = "actor.runtime.initialized";
/// <summary>
/// Runtime shutdown event.
/// </summary>
public const string RuntimeShutdown = "actor.runtime.shutdown";
}
}
}
@@ -0,0 +1,131 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.Logging;
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// High-performance logging messages using LoggerMessage source generator for InProcessActorContext.
/// </summary>
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);
}
@@ -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<ActorMessage> _pendingMessages = Channel.CreateUnbounded<ActorMessage>();
private readonly object _lock = new();
private readonly Dictionary<string, ActorInboxEntry> _inbox = [];
private readonly InProcessActorRuntime _runtime;
private readonly IActor _actorInstance;
private readonly ILogger<InProcessActorContext> _logger;
private Task? _actorRunTask;
public InProcessActorContext(
ActorId ActorId,
InProcessActorRuntime runtime,
Func<IServiceProvider, IActorRuntimeContext, IActor> actorFactory)
{
this._runtime = runtime;
this.ActorId = ActorId;
this._logger = runtime.Services.GetRequiredService<ILogger<InProcessActorContext>>();
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<ReadResponse> 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<ActorStateReadOperation>()],
cancellationToken).ConfigureAwait(false);
Log.ReadOperationCompleted(this._logger, this.ActorId.ToString(), result.Results.Count);
return result;
}
public async IAsyncEnumerable<ActorMessage> 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<WriteResponse> 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<ActorStateWriteOperation>()],
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<ActorResponseMessage> _responseTcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly Channel<UpdateRequestOperation> _updates = Channel.CreateUnbounded<UpdateRequestOperation>();
public CancellationTokenSource Cts { get; } = new();
public ActorRequestMessage Request { get; } = Request;
public Task<ActorResponseMessage> Response => this._responseTcs.Task;
public IAsyncEnumerable<UpdateRequestOperation> 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<ActorResponse> 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<string>(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<ActorRequestUpdate> WatchUpdatesAsync([EnumeratorCancellation] CancellationToken cancellationToken)
{
await foreach (var update in entry.WatchUpdatesAsync(cancellationToken).ConfigureAwait(false))
{
yield return new ActorRequestUpdate(update.Status, update.Data);
}
}
}
}
@@ -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<ActorType, Func<IServiceProvider, IActorRuntimeContext, IActor>> 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<long> ActorCreatedCounter = Meter.CreateCounter<long>(
ActorRuntimeOpenTelemetryConsts.Client.ActorCount.Name,
ActorRuntimeOpenTelemetryConsts.CountUnit,
ActorRuntimeOpenTelemetryConsts.Client.ActorCount.Description);
private static readonly Histogram<double> OperationDurationHistogram = Meter.CreateHistogram<double>(
ActorRuntimeOpenTelemetryConsts.Client.OperationDuration.Name,
"s",
ActorRuntimeOpenTelemetryConsts.Client.OperationDuration.Description);
private readonly object _createActorLock = new();
private readonly IReadOnlyDictionary<ActorType, Func<IServiceProvider, IActorRuntimeContext, IActor>> _actorFactories = actorFactories;
private readonly ConcurrentDictionary<ActorId, InProcessActorContext> _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<IServiceProvider, IActorRuntimeContext, IActor>) 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<string, object?>(ActorRuntimeOpenTelemetryConsts.Actor.Operation, ActorRuntimeOpenTelemetryConsts.Operations.GetActor),
new KeyValuePair<string, object?>(ActorRuntimeOpenTelemetryConsts.Actor.Type, actorId.Type.Name));
}
}
private static InProcessActorContext CreateActorInstance(ActorId actorId, InProcessActorRuntime self, Func<IServiceProvider, IActorRuntimeContext, IActor> 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<string, object?>(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<long> RequestCounter = ClientMeter.CreateCounter<long>(
ActorRuntimeOpenTelemetryConsts.Client.RequestCount.Name,
ActorRuntimeOpenTelemetryConsts.CountUnit,
ActorRuntimeOpenTelemetryConsts.Client.RequestCount.Description);
private static readonly Histogram<double> ClientOperationDurationHistogram = ClientMeter.CreateHistogram<double>(
ActorRuntimeOpenTelemetryConsts.Client.OperationDuration.Name,
"s",
ActorRuntimeOpenTelemetryConsts.Client.OperationDuration.Description);
private readonly InProcessActorRuntime _runtime = runtime;
public ValueTask<ActorResponseHandle> 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<ActorResponseHandle> 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<string, object?>(Tel.Actor.Type, actorId.Type.Name),
new KeyValuePair<string, object?>(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<string, object?>(ActorRuntimeOpenTelemetryConsts.Actor.Operation, ActorRuntimeOpenTelemetryConsts.Operations.SendRequest),
new KeyValuePair<string, object?>(ActorRuntimeOpenTelemetryConsts.Actor.Type, request.ActorId.Type.Name));
}
}
}
@@ -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;
/// <summary>
/// Provides extension methods for JSON serialization with source generation support.
/// </summary>
internal static class JsonSerializerExtensions
{
/// <summary>
/// Gets the JsonTypeInfo for a type, preferring the one from options if available,
/// otherwise falling back to the source-generated context.
/// </summary>
/// <typeparam name="T">The type to get JsonTypeInfo for.</typeparam>
/// <param name="options">The JsonSerializerOptions to check first.</param>
/// <param name="fallbackContext">The fallback JsonSerializerContext to use if not found in options.</param>
/// <returns>The JsonTypeInfo for the requested type.</returns>
public static JsonTypeInfo<T> GetTypeInfo<T>(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<T> typeInfo)
{
return typeInfo;
}
// Fall back to the provided source-generated context
return (JsonTypeInfo<T>)fallbackContext.GetTypeInfo(typeof(T))!;
}
}

Some files were not shown because too many files have changed in this diff Show More