From e7441ee29ed5be48d67a23a6957bcd8e37aeabbc Mon Sep 17 00:00:00 2001 From: Reuben Bond <203839+ReubenBond@users.noreply.github.com> Date: Wed, 6 Aug 2025 14:26:36 -0700 Subject: [PATCH] .NET: Add agent hosting package and update sample (#296) * Add agent hosting package and update sample * Review feedback and cleanup * Include the narrator * wip * wip * Remove workaround for empty state writes. * Handle changes to AgentThread. * One more. * Fix. --------- Co-authored-by: Aditya Mandaleeka --- dotnet/Directory.Build.props | 3 +- dotnet/Directory.Packages.props | 4 + dotnet/agent-framework-dotnet.slnx | 89 +- .../ActorFrameworkWebApplicationExtensions.cs | 40 + .../AgentWebChat.AgentHost.csproj} | 7 +- .../HttpActorApiRouteBuilderExtensions.cs | 72 ++ .../HttpActorProcessor.cs | 145 ++++ .../AgentWebChat.AgentHost}/Log.cs | 8 +- .../AgentWebChat.AgentHost/Program.cs | 99 +++ .../Properties/launchSettings.json | 2 + .../Utilities/ChatClientConnectionInfo.cs | 2 +- .../Utilities/ChatClientExtensions.cs | 4 +- .../appsettings.Development.json | 2 +- .../AgentWebChat.AgentHost}/appsettings.json | 2 +- .../AgentWebChat.AppHost.csproj} | 4 +- .../AgentWebChat.AppHost}/ModelExtensions.cs | 2 +- .../AgentWebChat.AppHost}/Program.cs | 12 +- .../Properties/launchSettings.json | 0 .../appsettings.Development.json | 2 +- .../AgentWebChat.AppHost}/appsettings.json | 2 +- .../AgentWebChat.ServiceDefaults.csproj} | 0 .../ServiceDefaultsExtensions.cs | 0 .../AgentWebChat.Web/AgentDiscoveryClient.cs | 28 + .../AgentWebChat.Web/AgentWebChat.Web.csproj} | 7 +- .../AgentWebChat.Web}/Components/App.razor | 2 +- .../Components/Layout/MainLayout.razor | 15 + .../Components/Layout/MainLayout.razor.css | 33 + .../Components/Pages/Error.razor | 0 .../Components/Pages/Home.razor | 741 +++++++++++++++++ .../AgentWebChat.Web}/Components/Routes.razor | 0 .../Components/_Imports.razor | 4 +- .../AgentWebChat.Web/HttpActorClient.cs | 190 +++++ .../AgentWebChat.Web}/Program.cs | 16 +- .../Properties/launchSettings.json | 0 .../appsettings.Development.json | 2 +- .../AgentWebChat.Web}/appsettings.json | 2 +- .../AgentWebChat.Web}/wwwroot/app.css | 54 +- .../AgentWebChat.Web}/wwwroot/favicon.png | Bin .../ActorFrameworkWebApplicationExtensions.cs | 174 ---- .../ChatClientAgentActorJsonContext.cs | 17 - .../ChatClientAgentRunRequest.cs | 12 - .../HostApplicationBuilderAgentExtensions.cs | 45 -- .../InvocationResponse.cs | 15 - .../HelloHttpApi.ApiService/PingResponse.cs | 14 - .../PingResponseStatus.cs | 9 - .../HelloHttpApi.ApiService/Program.cs | 35 - .../HelloHttpApi.Web/AgentClient.cs | 179 ----- .../Components/Layout/MainLayout.razor | 23 - .../Components/Layout/MainLayout.razor.css | 96 --- .../Components/Layout/NavMenu.razor | 29 - .../Components/Layout/NavMenu.razor.css | 102 --- .../Components/Pages/Counter.razor | 19 - .../Components/Pages/Home.razor | 7 - .../Components/Pages/PirateTalk.razor | 208 ----- .../AgentActor.cs} | 52 +- .../AgentActorConstants.cs | 8 + .../AgentCatalog.cs | 38 + .../AgentHostingJsonUtilities.cs | 60 ++ .../AgentProxy.cs | 158 ++++ .../AgentProxyThread.cs | 72 ++ .../AgentRunRequest.cs | 18 + .../HostApplicationBuilderAgentExtensions.cs | 158 ++++ .../LocalAgentCatalog.cs | 37 + .../LocalAgentRegistry.cs | 10 + .../Log.cs | 57 ++ ...rosoft.Extensions.AI.Agents.Hosting.csproj | 44 + .../ActorJsonContext.cs | 41 - .../ActorResponse.cs | 21 + .../ActorResponseHandle.cs | 16 +- .../ActorRuntimeJsonUtilities.cs | 81 ++ .../ActorType.cs | 30 +- .../RequestStatusExtensions.cs | 16 + .../CosmosActorStateJsonContext.cs | 4 +- .../ActorRuntimeJsonContext.cs | 14 - .../AgentRuntimeJsonUtilities.cs | 57 ++ .../InProcessActorContext.cs | 44 +- .../InProcessActorRuntime.cs | 14 +- .../NotFoundActorResponseHandle.cs | 47 ++ .../CosmosTestFixture.cs | 1 + .../AgentActorTests.cs | 35 + .../AgentProxyTests.cs | 758 ++++++++++++++++++ .../AgentProxyThreadTests.cs | 305 +++++++ ...tApplicationBuilderAgentExtensionsTests.cs | 332 ++++++++ ...ensions.AI.Agents.Hosting.UnitTests.csproj | 11 + .../ActorTypeTests.cs | 297 +++++++ .../JsonSerializationTests.cs | 202 ++++- 86 files changed, 4388 insertions(+), 1229 deletions(-) create mode 100644 dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/ActorFrameworkWebApplicationExtensions.cs rename dotnet/samples/{HelloHttpApi/HelloHttpApi.ApiService/HelloHttpApi.ApiService.csproj => AgentWebChat/AgentWebChat.AgentHost/AgentWebChat.AgentHost.csproj} (76%) create mode 100644 dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/HttpActorApiRouteBuilderExtensions.cs create mode 100644 dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/HttpActorProcessor.cs rename dotnet/samples/{HelloHttpApi/HelloHttpApi.ApiService => AgentWebChat/AgentWebChat.AgentHost}/Log.cs (95%) create mode 100644 dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs rename dotnet/samples/{HelloHttpApi/HelloHttpApi.ApiService => AgentWebChat/AgentWebChat.AgentHost}/Properties/launchSettings.json (91%) rename dotnet/samples/{HelloHttpApi/HelloHttpApi.ApiService => AgentWebChat/AgentWebChat.AgentHost}/Utilities/ChatClientConnectionInfo.cs (98%) rename dotnet/samples/{HelloHttpApi/HelloHttpApi.ApiService => AgentWebChat/AgentWebChat.AgentHost}/Utilities/ChatClientExtensions.cs (98%) rename dotnet/samples/{HelloHttpApi/HelloHttpApi.Web => AgentWebChat/AgentWebChat.AgentHost}/appsettings.Development.json (73%) rename dotnet/samples/{HelloHttpApi/HelloHttpApi.Web => AgentWebChat/AgentWebChat.AgentHost}/appsettings.json (77%) rename dotnet/samples/{HelloHttpApi/HelloHttpApi.AppHost/HelloHttpApi.AppHost.csproj => AgentWebChat/AgentWebChat.AppHost/AgentWebChat.AppHost.csproj} (81%) rename dotnet/samples/{HelloHttpApi/HelloHttpApi.AppHost => AgentWebChat/AgentWebChat.AppHost}/ModelExtensions.cs (99%) rename dotnet/samples/{HelloHttpApi/HelloHttpApi.AppHost => AgentWebChat/AgentWebChat.AppHost}/Program.cs (76%) rename dotnet/samples/{HelloHttpApi/HelloHttpApi.AppHost => AgentWebChat/AgentWebChat.AppHost}/Properties/launchSettings.json (100%) rename dotnet/samples/{HelloHttpApi/HelloHttpApi.ApiService => AgentWebChat/AgentWebChat.AppHost}/appsettings.Development.json (73%) rename dotnet/samples/{HelloHttpApi/HelloHttpApi.AppHost => AgentWebChat/AgentWebChat.AppHost}/appsettings.json (79%) rename dotnet/samples/{HelloHttpApi/HelloHttpApi.ServiceDefaults/HelloHttpApi.ServiceDefaults.csproj => AgentWebChat/AgentWebChat.ServiceDefaults/AgentWebChat.ServiceDefaults.csproj} (100%) rename dotnet/samples/{HelloHttpApi/HelloHttpApi.ServiceDefaults => AgentWebChat/AgentWebChat.ServiceDefaults}/ServiceDefaultsExtensions.cs (100%) create mode 100644 dotnet/samples/AgentWebChat/AgentWebChat.Web/AgentDiscoveryClient.cs rename dotnet/samples/{HelloHttpApi/HelloHttpApi.Web/HelloHttpApi.Web.csproj => AgentWebChat/AgentWebChat.Web/AgentWebChat.Web.csproj} (53%) rename dotnet/samples/{HelloHttpApi/HelloHttpApi.Web => AgentWebChat/AgentWebChat.Web}/Components/App.razor (88%) create mode 100644 dotnet/samples/AgentWebChat/AgentWebChat.Web/Components/Layout/MainLayout.razor create mode 100644 dotnet/samples/AgentWebChat/AgentWebChat.Web/Components/Layout/MainLayout.razor.css rename dotnet/samples/{HelloHttpApi/HelloHttpApi.Web => AgentWebChat/AgentWebChat.Web}/Components/Pages/Error.razor (100%) create mode 100644 dotnet/samples/AgentWebChat/AgentWebChat.Web/Components/Pages/Home.razor rename dotnet/samples/{HelloHttpApi/HelloHttpApi.Web => AgentWebChat/AgentWebChat.Web}/Components/Routes.razor (100%) rename dotnet/samples/{HelloHttpApi/HelloHttpApi.Web => AgentWebChat/AgentWebChat.Web}/Components/_Imports.razor (86%) create mode 100644 dotnet/samples/AgentWebChat/AgentWebChat.Web/HttpActorClient.cs rename dotnet/samples/{HelloHttpApi/HelloHttpApi.Web => AgentWebChat/AgentWebChat.Web}/Program.cs (67%) rename dotnet/samples/{HelloHttpApi/HelloHttpApi.Web => AgentWebChat/AgentWebChat.Web}/Properties/launchSettings.json (100%) rename dotnet/samples/{HelloHttpApi/HelloHttpApi.AppHost => AgentWebChat/AgentWebChat.Web}/appsettings.Development.json (73%) rename dotnet/samples/{HelloHttpApi/HelloHttpApi.ApiService => AgentWebChat/AgentWebChat.Web}/appsettings.json (77%) rename dotnet/samples/{HelloHttpApi/HelloHttpApi.Web => AgentWebChat/AgentWebChat.Web}/wwwroot/app.css (64%) rename dotnet/samples/{HelloHttpApi/HelloHttpApi.Web => AgentWebChat/AgentWebChat.Web}/wwwroot/favicon.png (100%) delete mode 100644 dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/ActorFrameworkWebApplicationExtensions.cs delete mode 100644 dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/ChatClientAgentActorJsonContext.cs delete mode 100644 dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/ChatClientAgentRunRequest.cs delete mode 100644 dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/HostApplicationBuilderAgentExtensions.cs delete mode 100644 dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/InvocationResponse.cs delete mode 100644 dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/PingResponse.cs delete mode 100644 dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/PingResponseStatus.cs delete mode 100644 dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/Program.cs delete mode 100644 dotnet/samples/HelloHttpApi/HelloHttpApi.Web/AgentClient.cs delete mode 100644 dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Layout/MainLayout.razor delete mode 100644 dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Layout/MainLayout.razor.css delete mode 100644 dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Layout/NavMenu.razor delete mode 100644 dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Layout/NavMenu.razor.css delete mode 100644 dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Pages/Counter.razor delete mode 100644 dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Pages/Home.razor delete mode 100644 dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Pages/PirateTalk.razor rename dotnet/{samples/HelloHttpApi/HelloHttpApi.ApiService/ChatClientAgentActor.cs => src/Microsoft.Extensions.AI.Agents.Hosting/AgentActor.cs} (64%) create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentActorConstants.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentCatalog.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentHostingJsonUtilities.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentProxy.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentProxyThread.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentRunRequest.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/HostApplicationBuilderAgentExtensions.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/LocalAgentCatalog.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/LocalAgentRegistry.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/Log.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/Microsoft.Extensions.AI.Agents.Hosting.csproj delete mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorJsonContext.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorRuntimeJsonUtilities.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/RequestStatusExtensions.cs delete mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/ActorRuntimeJsonContext.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/AgentRuntimeJsonUtilities.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/NotFoundActorResponseHandle.cs create mode 100644 dotnet/tests/Microsoft.Extensions.AI.Agents.Hosting.UnitTests/AgentActorTests.cs create mode 100644 dotnet/tests/Microsoft.Extensions.AI.Agents.Hosting.UnitTests/AgentProxyTests.cs create mode 100644 dotnet/tests/Microsoft.Extensions.AI.Agents.Hosting.UnitTests/AgentProxyThreadTests.cs create mode 100644 dotnet/tests/Microsoft.Extensions.AI.Agents.Hosting.UnitTests/HostApplicationBuilderAgentExtensionsTests.cs create mode 100644 dotnet/tests/Microsoft.Extensions.AI.Agents.Hosting.UnitTests/Microsoft.Extensions.AI.Agents.Hosting.UnitTests.csproj create mode 100644 dotnet/tests/Microsoft.Extensions.AI.Agents.Runtime.Abstractions.UnitTests/ActorTypeTests.cs diff --git a/dotnet/Directory.Build.props b/dotnet/Directory.Build.props index 695cbd32bd..1600c872de 100644 --- a/dotnet/Directory.Build.props +++ b/dotnet/Directory.Build.props @@ -14,6 +14,7 @@ net9.0;net8.0;netstandard2.0;net472 net9.0;net472 true + Debug;Release;Publish @@ -22,7 +23,7 @@ - disable + false diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 19c7cb6f84..8b2bdc776d 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -17,6 +17,7 @@ + @@ -27,8 +28,10 @@ + + @@ -40,6 +43,7 @@ + diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 7e976a0493..6420da7f0a 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -5,48 +5,24 @@ - - - - - - - - - - - - - - - - - - + + + + + + - - - - - - - - - - - - - - - - - + - - - + + + + + + + @@ -143,37 +119,20 @@ + - - - - - - + + - - - - - - - - - - - - - - - - - - - - - + + + + + + + diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/ActorFrameworkWebApplicationExtensions.cs b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/ActorFrameworkWebApplicationExtensions.cs new file mode 100644 index 0000000000..7dfdaf5263 --- /dev/null +++ b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/ActorFrameworkWebApplicationExtensions.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; +using Microsoft.Extensions.AI.Agents.Hosting; + +namespace AgentWebChat.AgentHost; + +internal static class ActorFrameworkWebApplicationExtensions +{ + public static void MapAgentDiscovery(this IEndpointRouteBuilder endpoints, [StringSyntax("Route")] string path) + { + var routeGroup = endpoints.MapGroup(path); + routeGroup.MapGet("/", async ( + AgentCatalog agentCatalog, + CancellationToken cancellationToken) => + { + var results = new List(); + await foreach (var result in agentCatalog.GetAgentsAsync(cancellationToken).ConfigureAwait(false)) + { + results.Add(new AgentDiscoveryCard + { + Name = result.Name!, + Description = result.Description, + }); + } + + return Results.Ok(results); + }) + .WithName("GetAgents"); + } + + internal sealed class AgentDiscoveryCard + { + public required string Name { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Description { get; set; } + } +} diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/HelloHttpApi.ApiService.csproj b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/AgentWebChat.AgentHost.csproj similarity index 76% rename from dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/HelloHttpApi.ApiService.csproj rename to dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/AgentWebChat.AgentHost.csproj index 6efdd1f81e..bfb23fa3ff 100644 --- a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/HelloHttpApi.ApiService.csproj +++ b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/AgentWebChat.AgentHost.csproj @@ -4,16 +4,18 @@ net9.0 enable enable + true + - + @@ -25,6 +27,9 @@ + + + diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/HttpActorApiRouteBuilderExtensions.cs b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/HttpActorApiRouteBuilderExtensions.cs new file mode 100644 index 0000000000..4b00c8ef76 --- /dev/null +++ b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/HttpActorApiRouteBuilderExtensions.cs @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.AI.Agents.Runtime; + +namespace AgentWebChat.AgentHost; + +internal static partial class HttpActorApiRouteBuilderExtensions +{ + private const string BasePath = "/actors/v1"; + + public static void MapActors(this IEndpointRouteBuilder endpoints, IActorClient? actorClient = null, [StringSyntax("Route")] string? path = default) + { + path ??= BasePath; + actorClient ??= endpoints.ServiceProvider.GetRequiredService(); + + var routeGroup = endpoints.MapGroup(path); + + // GET /actors/v1/{actorType}/{actorKey}/{messageId} + routeGroup.MapGet( + "/{actorType}/{actorKey}/{messageId}", async ( + string actorType, + string actorKey, + string messageId, + [FromQuery] bool? blocking, + [FromQuery] bool? streaming, + HttpContext context, + CancellationToken cancellationToken) => + await HttpActorProcessor.GetResponseAsync( + actorType, + actorKey, + messageId, + blocking: blocking, + streaming: streaming, + context, + actorClient, + cancellationToken)) + .WithName("GetActorResponse"); + + // POST /actors/v1/{actorType}/{actorKey}/{messageId} + routeGroup.MapPost( + "/{actorType}/{actorKey}/{messageId}", async ( + string actorType, + string actorKey, + string messageId, + [FromQuery] bool? blocking, + [FromQuery] bool? streaming, + [FromBody] ActorRequest request, + CancellationToken cancellationToken) => + await HttpActorProcessor.SendRequestAsync( + actorType, + actorKey, + messageId, + blocking: blocking, + streaming: streaming, + request, + actorClient, + cancellationToken)) + .WithName("SendActorRequest"); + + // POST /actors/v1/{actorType}/{actorKey}/{messageId}:cancel + routeGroup.MapPost( + "/{actorType}/{actorKey}/{messageId}:cancel", async ( + string actorType, + string actorKey, + string messageId, + CancellationToken cancellationToken) => + await HttpActorProcessor.CancelRequestAsync(actorType, actorKey, messageId, actorClient, cancellationToken)) + .WithName("CancelActorRequest"); + } +} diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/HttpActorProcessor.cs b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/HttpActorProcessor.cs new file mode 100644 index 0000000000..6b4ec34483 --- /dev/null +++ b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/HttpActorProcessor.cs @@ -0,0 +1,145 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.AI.Agents.Hosting; +using Microsoft.Extensions.AI.Agents.Runtime; + +namespace AgentWebChat.AgentHost; + +internal static class HttpActorProcessor +{ + public static async Task GetResponseAsync( + string actorType, + string actorKey, + string messageId, + bool? blocking, + bool? streaming, + HttpContext context, + IActorClient actorClient, + CancellationToken cancellationToken) + { + var actorId = new ActorId(actorType, actorKey); + + var responseHandle = await actorClient.GetResponseAsync(actorId, messageId, cancellationToken); + + if (responseHandle.TryGetResponse(out var response)) + { + return GetResult(response); + } + + if (streaming == true) + { + return new ActorUpdateStreamingResult(responseHandle); + } + + if (blocking == true) + { + response = await responseHandle.GetResponseAsync(cancellationToken); + return GetResult(response); + } + + return Results.Ok(new ActorResponse + { + ActorId = actorId, + MessageId = messageId, + Status = RequestStatus.Pending, + Data = JsonDocument.Parse("{}").RootElement + }); + } + + public static async Task SendRequestAsync( + string actorType, + string actorKey, + string messageId, + bool? blocking, + bool? streaming, + ActorRequest request, + IActorClient actorClient, + CancellationToken cancellationToken) + { + var responseHandle = await actorClient.SendRequestAsync(request, cancellationToken); + if (responseHandle.TryGetResponse(out var response)) + { + return GetResult(response); + } + + if (streaming == true) + { + return new ActorUpdateStreamingResult(responseHandle); + } + + if (blocking == true) + { + response = await responseHandle.GetResponseAsync(cancellationToken); + return GetResult(response); + } + + return Results.Accepted(); + } + + private static IResult GetResult(ActorResponse response) + { + if (response.Status == RequestStatus.NotFound) + { + return Results.NotFound(); + } + + return Results.Ok(response); + } + + public static async Task CancelRequestAsync( + string actorType, + string actorKey, + string messageId, + IActorClient actorClient, + CancellationToken cancellationToken) + { + var actorId = new ActorId(actorType, actorKey); + var responseHandle = await actorClient.GetResponseAsync(actorId, messageId, cancellationToken); + + if (responseHandle.TryGetResponse(out var response)) + { + if (response.Status is RequestStatus.NotFound) + { + return Results.NotFound(); + } + else if (response.Status is RequestStatus.Completed or RequestStatus.Failed) + { + return Results.Conflict("The request has already completed and cannot be cancelled."); + } + } + + await responseHandle.CancelAsync(cancellationToken); + return Results.NoContent(); + } + + private sealed class ActorUpdateStreamingResult( + ActorResponseHandle responseHandle) : IResult + { + public async Task ExecuteAsync(HttpContext httpContext) + { + var cancellationToken = httpContext.RequestAborted; + var response = httpContext.Response; + response.Headers.ContentType = "text/event-stream"; + response.Headers.CacheControl = "no-cache,no-store"; + response.Headers.Connection = "keep-alive"; + + // Make sure we disable all response buffering for SSE. + response.Headers.ContentEncoding = "identity"; + httpContext.Features.GetRequiredFeature().DisableBuffering(); + await response.Body.FlushAsync(cancellationToken); + + var updateTypeInfo = AgentHostingJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ActorRequestUpdate)); + + await foreach (var update in responseHandle.WatchUpdatesAsync(cancellationToken).ConfigureAwait(false)) + { + var eventData = JsonSerializer.Serialize(update, updateTypeInfo); + var eventText = $"data: {eventData}\n\n"; + + await response.WriteAsync(eventText, cancellationToken); + await response.Body.FlushAsync(cancellationToken); + } + } + } +} diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/Log.cs b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Log.cs similarity index 95% rename from dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/Log.cs rename to dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Log.cs index 7a61897cc9..0efb81961a 100644 --- a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/Log.cs +++ b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Log.cs @@ -2,7 +2,7 @@ using Microsoft.Extensions.AI.Agents.Runtime; -namespace HelloHttpApi.ApiService; +namespace AgentWebChat.AgentHost; /// /// High-performance logging messages using LoggerMessage source generator. @@ -62,12 +62,6 @@ internal static partial class Log 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, diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs new file mode 100644 index 0000000000..722a1ba87a --- /dev/null +++ b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using AgentWebChat.AgentHost; +using AgentWebChat.AgentHost.Utilities; +using Microsoft.Agents.Orchestration; +using Microsoft.Azure.Cosmos; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.AI.Agents; +using Microsoft.Extensions.AI.Agents.Hosting; +using Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB; + +var builder = WebApplication.CreateBuilder(args); + +// Add service defaults & Aspire client integrations. +builder.AddServiceDefaults(); +builder.Services.AddOpenApi(); + +// Add services to the container. +builder.Services.AddProblemDetails(); + +// Add CosmosDB client integration +builder.AddAzureCosmosClient("agent-web-chat-cosmosdb", null, CosmosClientOptions => +{ + CosmosClientOptions.ApplicationName = "AgentWebChat"; + CosmosClientOptions.ConnectionMode = ConnectionMode.Direct; + CosmosClientOptions.ConsistencyLevel = ConsistencyLevel.Session; + CosmosClientOptions.UseSystemTextJsonSerializerWithOptions = new JsonSerializerOptions() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + TypeInfoResolver = CosmosActorStateJsonContext.Default + }; +}); + +// Configure the chat model and our agent. +builder.AddKeyedChatClient("chat-model"); + +builder.AddAIAgent( + "pirate", + instructions: "You are a pirate. Speak like a pirate", + description: "An agent that speaks like a pirate.", + chatClientServiceKey: "chat-model"); + +builder.AddAIAgent("knights-and-knaves", (sp, key) => +{ + var chatClient = sp.GetRequiredKeyedService("chat-model"); + + ChatClientAgent knight = new( + chatClient, + """ + You are a knight. This means that you must always tell the truth. Your name is Alice. + Bob is standing next to you. Bob is a knave, which means he always lies. + When replying, always start with your name (Alice). Eg, "Alice: I am a knight." + """, "Alice"); + + ChatClientAgent knave = new( + chatClient, + """ + You are a knave. This means that you must always lie. Your name is Bob. + Alice is standing next to you. Alice is a knight, which means she always tells the truth. + When replying, always include your name (Bob). Eg, "Bob: I am a knight." + """, "Bob"); + + ChatClientAgent narrator = new( + chatClient, + """ + You are are the narrator of a puzzle involving knights (who always tell the truth) and knaves (who always lie). + The user is going to ask questions and guess whether Alice or Bob is the knight or knave. + Alice is standing to one side of you. Alice is a knight, which means she always tells the truth. + Bob is standing to the other side of you. Bob is a knave, which means he always lies. + When replying, always include your name (Narrator). + Once the user has deduced what type (knight or knave) both Alice and Bob are, tell them whether they are right or wrong. + If the user asks a general question about their surrounding, make something up which is consistent with the scenario. + """, "Narrator"); + + return new ConcurrentOrchestration([knight, knave, narrator], name: key); +}); + +// Add CosmosDB state storage to override default storage +builder.Services.AddCosmosActorStateStorage("actor-state-db", "ActorState"); + +var app = builder.Build(); + +app.MapOpenApi(); +app.UseSwaggerUI(options => +{ + options.SwaggerEndpoint("/openapi/v1.json", "Agents API"); +}); + +// Configure the HTTP request pipeline. +app.UseExceptionHandler(); + +app.MapActors(); + +// Map the agents HTTP endpoints +app.MapAgentDiscovery("/agents"); + +app.MapDefaultEndpoints(); +app.Run(); diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/Properties/launchSettings.json b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Properties/launchSettings.json similarity index 91% rename from dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/Properties/launchSettings.json rename to dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Properties/launchSettings.json index b14d22e4fe..2ae820b4a7 100644 --- a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/Properties/launchSettings.json +++ b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Properties/launchSettings.json @@ -6,6 +6,7 @@ "dotnetRunMessages": true, "launchBrowser": false, "applicationUrl": "http://localhost:5390", + "launchUrl": "swagger", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } @@ -14,6 +15,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": false, + "launchUrl": "swagger", "applicationUrl": "https://localhost:7373;http://localhost:5390", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/Utilities/ChatClientConnectionInfo.cs b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Utilities/ChatClientConnectionInfo.cs similarity index 98% rename from dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/Utilities/ChatClientConnectionInfo.cs rename to dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Utilities/ChatClientConnectionInfo.cs index 5cb808b3e7..9c459c1a58 100644 --- a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/Utilities/ChatClientConnectionInfo.cs +++ b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Utilities/ChatClientConnectionInfo.cs @@ -3,7 +3,7 @@ using System.Data.Common; using System.Diagnostics.CodeAnalysis; -namespace HelloHttpApi.ApiService.Utilities; +namespace AgentWebChat.AgentHost.Utilities; public class ChatClientConnectionInfo { diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/Utilities/ChatClientExtensions.cs b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Utilities/ChatClientExtensions.cs similarity index 98% rename from dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/Utilities/ChatClientExtensions.cs rename to dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Utilities/ChatClientExtensions.cs index ca050cced8..d6333d102c 100644 --- a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/Utilities/ChatClientExtensions.cs +++ b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Utilities/ChatClientExtensions.cs @@ -1,12 +1,12 @@ // Copyright (c) Microsoft. All rights reserved. +using AgentWebChat.AgentHost.Utilities; using Azure; using Azure.AI.Inference; -using HelloHttpApi.ApiService.Utilities; using Microsoft.Extensions.AI; using OllamaSharp; -namespace HelloHttpApi.ApiService.Utilities; +namespace AgentWebChat.AgentHost.Utilities; public static class ChatClientExtensions { diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/appsettings.Development.json b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/appsettings.Development.json similarity index 73% rename from dotnet/samples/HelloHttpApi/HelloHttpApi.Web/appsettings.Development.json rename to dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/appsettings.Development.json index 527a02bb3c..0c208ae918 100644 --- a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/appsettings.Development.json +++ b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/appsettings.Development.json @@ -1,7 +1,7 @@ { "Logging": { "LogLevel": { - "Default": "Trace", + "Default": "Information", "Microsoft.AspNetCore": "Warning" } } diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/appsettings.json b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/appsettings.json similarity index 77% rename from dotnet/samples/HelloHttpApi/HelloHttpApi.Web/appsettings.json rename to dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/appsettings.json index f41d5acb6b..10f68b8c8b 100644 --- a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/appsettings.json +++ b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/appsettings.json @@ -1,7 +1,7 @@ { "Logging": { "LogLevel": { - "Default": "Trace", + "Default": "Information", "Microsoft.AspNetCore": "Warning" } }, diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.AppHost/HelloHttpApi.AppHost.csproj b/dotnet/samples/AgentWebChat/AgentWebChat.AppHost/AgentWebChat.AppHost.csproj similarity index 81% rename from dotnet/samples/HelloHttpApi/HelloHttpApi.AppHost/HelloHttpApi.AppHost.csproj rename to dotnet/samples/AgentWebChat/AgentWebChat.AppHost/AgentWebChat.AppHost.csproj index 95c5809b39..f200bd03b1 100644 --- a/dotnet/samples/HelloHttpApi/HelloHttpApi.AppHost/HelloHttpApi.AppHost.csproj +++ b/dotnet/samples/AgentWebChat/AgentWebChat.AppHost/AgentWebChat.AppHost.csproj @@ -18,8 +18,8 @@ - - + + diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.AppHost/ModelExtensions.cs b/dotnet/samples/AgentWebChat/AgentWebChat.AppHost/ModelExtensions.cs similarity index 99% rename from dotnet/samples/HelloHttpApi/HelloHttpApi.AppHost/ModelExtensions.cs rename to dotnet/samples/AgentWebChat/AgentWebChat.AppHost/ModelExtensions.cs index 55bcd54ea0..a44e9734f4 100644 --- a/dotnet/samples/HelloHttpApi/HelloHttpApi.AppHost/ModelExtensions.cs +++ b/dotnet/samples/AgentWebChat/AgentWebChat.AppHost/ModelExtensions.cs @@ -1,6 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. -namespace HelloHttpApi.AppHost; +namespace AgentWebChat.AppHost; public static class ModelExtensions { diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.AppHost/Program.cs b/dotnet/samples/AgentWebChat/AgentWebChat.AppHost/Program.cs similarity index 76% rename from dotnet/samples/HelloHttpApi/HelloHttpApi.AppHost/Program.cs rename to dotnet/samples/AgentWebChat/AgentWebChat.AppHost/Program.cs index ddf06aa01e..9b25fe3488 100644 --- a/dotnet/samples/HelloHttpApi/HelloHttpApi.AppHost/Program.cs +++ b/dotnet/samples/AgentWebChat/AgentWebChat.AppHost/Program.cs @@ -1,6 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. -using HelloHttpApi.AppHost; +using AgentWebChat.AppHost; var builder = DistributedApplication.CreateBuilder(args); @@ -10,17 +10,17 @@ var chatModel = builder.AddAIModel("chat-model").AsAzureOpenAI("gpt-4o", o => o. var cosmosDbResource = builder.AddParameterFromConfiguration("CosmosDbName", "CosmosDb:Name"); var cosmosDbResourceGroup = builder.AddParameterFromConfiguration("CosmosDbResourceGroup", "CosmosDb:ResourceGroup"); -var cosmos = builder.AddAzureCosmosDB("hello-http-api-cosmosdb").RunAsExisting(cosmosDbResource, cosmosDbResourceGroup); +var cosmos = builder.AddAzureCosmosDB("agent-web-chat-cosmosdb").RunAsExisting(cosmosDbResource, cosmosDbResourceGroup); var stateDb = cosmos.AddCosmosDatabase("actor-state-db"); -var apiService = builder.AddProject("apiservice") +var agentHost = builder.AddProject("agenthost") .WithReference(chatModel) .WithReference(cosmos).WaitFor(cosmos); -builder.AddProject("webfrontend") +builder.AddProject("webfrontend") .WithExternalHttpEndpoints() - .WithReference(apiService) - .WaitFor(apiService); + .WithReference(agentHost) + .WaitFor(agentHost); builder.Build().Run(); diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.AppHost/Properties/launchSettings.json b/dotnet/samples/AgentWebChat/AgentWebChat.AppHost/Properties/launchSettings.json similarity index 100% rename from dotnet/samples/HelloHttpApi/HelloHttpApi.AppHost/Properties/launchSettings.json rename to dotnet/samples/AgentWebChat/AgentWebChat.AppHost/Properties/launchSettings.json diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/appsettings.Development.json b/dotnet/samples/AgentWebChat/AgentWebChat.AppHost/appsettings.Development.json similarity index 73% rename from dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/appsettings.Development.json rename to dotnet/samples/AgentWebChat/AgentWebChat.AppHost/appsettings.Development.json index 527a02bb3c..0c208ae918 100644 --- a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/appsettings.Development.json +++ b/dotnet/samples/AgentWebChat/AgentWebChat.AppHost/appsettings.Development.json @@ -1,7 +1,7 @@ { "Logging": { "LogLevel": { - "Default": "Trace", + "Default": "Information", "Microsoft.AspNetCore": "Warning" } } diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.AppHost/appsettings.json b/dotnet/samples/AgentWebChat/AgentWebChat.AppHost/appsettings.json similarity index 79% rename from dotnet/samples/HelloHttpApi/HelloHttpApi.AppHost/appsettings.json rename to dotnet/samples/AgentWebChat/AgentWebChat.AppHost/appsettings.json index 75c1fb6ea8..31c092aa45 100644 --- a/dotnet/samples/HelloHttpApi/HelloHttpApi.AppHost/appsettings.json +++ b/dotnet/samples/AgentWebChat/AgentWebChat.AppHost/appsettings.json @@ -1,7 +1,7 @@ { "Logging": { "LogLevel": { - "Default": "Trace", + "Default": "Information", "Microsoft.AspNetCore": "Warning", "Aspire.Hosting.Dcp": "Warning" } diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.ServiceDefaults/HelloHttpApi.ServiceDefaults.csproj b/dotnet/samples/AgentWebChat/AgentWebChat.ServiceDefaults/AgentWebChat.ServiceDefaults.csproj similarity index 100% rename from dotnet/samples/HelloHttpApi/HelloHttpApi.ServiceDefaults/HelloHttpApi.ServiceDefaults.csproj rename to dotnet/samples/AgentWebChat/AgentWebChat.ServiceDefaults/AgentWebChat.ServiceDefaults.csproj diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.ServiceDefaults/ServiceDefaultsExtensions.cs b/dotnet/samples/AgentWebChat/AgentWebChat.ServiceDefaults/ServiceDefaultsExtensions.cs similarity index 100% rename from dotnet/samples/HelloHttpApi/HelloHttpApi.ServiceDefaults/ServiceDefaultsExtensions.cs rename to dotnet/samples/AgentWebChat/AgentWebChat.ServiceDefaults/ServiceDefaultsExtensions.cs diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.Web/AgentDiscoveryClient.cs b/dotnet/samples/AgentWebChat/AgentWebChat.Web/AgentDiscoveryClient.cs new file mode 100644 index 0000000000..df0894d8f0 --- /dev/null +++ b/dotnet/samples/AgentWebChat/AgentWebChat.Web/AgentDiscoveryClient.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using Microsoft.Extensions.AI.Agents.Hosting; + +namespace AgentWebChat.Web; + +public class AgentDiscoveryClient(HttpClient httpClient, ILogger logger) +{ + public async Task> GetAgentsAsync(CancellationToken cancellationToken = default) + { + var response = await httpClient.GetAsync(new Uri("/agents", UriKind.Relative), cancellationToken); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(cancellationToken); + var agents = JsonSerializer.Deserialize>(json, AgentHostingJsonUtilities.DefaultOptions) ?? []; + + logger.LogInformation("Retrieved {AgentCount} agents from the API", agents.Count); + _ = new HttpActorClient(null!); + return agents; + } + + public class AgentDiscoveryCard + { + public string? Name { get; set; } + public string? Description { get; set; } + } +} diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/HelloHttpApi.Web.csproj b/dotnet/samples/AgentWebChat/AgentWebChat.Web/AgentWebChat.Web.csproj similarity index 53% rename from dotnet/samples/HelloHttpApi/HelloHttpApi.Web/HelloHttpApi.Web.csproj rename to dotnet/samples/AgentWebChat/AgentWebChat.Web/AgentWebChat.Web.csproj index e0aec4096f..ed929f6d80 100644 --- a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/HelloHttpApi.Web.csproj +++ b/dotnet/samples/AgentWebChat/AgentWebChat.Web/AgentWebChat.Web.csproj @@ -7,7 +7,12 @@ - + + + + + + diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/App.razor b/dotnet/samples/AgentWebChat/AgentWebChat.Web/Components/App.razor similarity index 88% rename from dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/App.razor rename to dotnet/samples/AgentWebChat/AgentWebChat.Web/Components/App.razor index 2a9bdedfe7..ddb084903c 100644 --- a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/App.razor +++ b/dotnet/samples/AgentWebChat/AgentWebChat.Web/Components/App.razor @@ -7,7 +7,7 @@ - + diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.Web/Components/Layout/MainLayout.razor b/dotnet/samples/AgentWebChat/AgentWebChat.Web/Components/Layout/MainLayout.razor new file mode 100644 index 0000000000..50c63db89f --- /dev/null +++ b/dotnet/samples/AgentWebChat/AgentWebChat.Web/Components/Layout/MainLayout.razor @@ -0,0 +1,15 @@ +@inherits LayoutComponentBase + +
+
+
+ @Body +
+
+
+ +
+ An unhandled error has occurred. + Reload + 🗙 +
\ No newline at end of file diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.Web/Components/Layout/MainLayout.razor.css b/dotnet/samples/AgentWebChat/AgentWebChat.Web/Components/Layout/MainLayout.razor.css new file mode 100644 index 0000000000..cc4ae8d91d --- /dev/null +++ b/dotnet/samples/AgentWebChat/AgentWebChat.Web/Components/Layout/MainLayout.razor.css @@ -0,0 +1,33 @@ +.page { + position: relative; + display: flex; + flex-direction: column; + min-height: 100vh; +} + +main { + flex: 1; +} + +.content { + padding: 0; +} + +#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; + } \ No newline at end of file diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Pages/Error.razor b/dotnet/samples/AgentWebChat/AgentWebChat.Web/Components/Pages/Error.razor similarity index 100% rename from dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Pages/Error.razor rename to dotnet/samples/AgentWebChat/AgentWebChat.Web/Components/Pages/Error.razor diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.Web/Components/Pages/Home.razor b/dotnet/samples/AgentWebChat/AgentWebChat.Web/Components/Pages/Home.razor new file mode 100644 index 0000000000..face3c14b6 --- /dev/null +++ b/dotnet/samples/AgentWebChat/AgentWebChat.Web/Components/Pages/Home.razor @@ -0,0 +1,741 @@ +@page "/" +@attribute [StreamRendering(true)] +@inject AgentDiscoveryClient AgentClient +@inject IJSRuntime JSRuntime +@inject ILogger Logger +@inject IActorClient ActorClient +@rendermode InteractiveServer +@using System.Text +@using System.Text.Json +@using Microsoft.Extensions.AI +@using Microsoft.Extensions.AI.Agents +@using Microsoft.Extensions.AI.Agents.Hosting +@using Microsoft.Extensions.AI.Agents.Runtime + +Agent Web Chat + +
+
+

+ + + + Agent Web Chat +

+

The best hypertext-based chat on the Web!

+
+ +
+ +
+ + @if (!string.IsNullOrEmpty(selectedAgentName) && currentConversation == null) + { + + } +
+
+ + @if (conversations.Any()) + { +
+
+ @foreach (var conv in conversations) + { +
+ @GetAgentIcon(conv.AgentName) + @GetAgentDisplayName(conv.AgentName) + +
+ } +
+
+ } + + @if (currentConversation != null) + { +
+
+ @foreach (var message in currentConversation.Messages) + { +
+ @if (message.Role != ChatRole.User) + { +
@GetAgentIcon(currentConversation.AgentName)
+ } +
+
@message.Text
+
+ @(message.Role == ChatRole.User ? "You" : GetAgentDisplayName(currentConversation.AgentName)) +
+
+
+ } + + @if (isStreaming && currentStreamedMessage.Length > 0) + { +
+
@GetAgentIcon(currentConversation.AgentName)
+
+
+ @currentStreamedMessage + +
+
+
+ } +
+ +
+
+ + +
+
+
+ } +
+ + + +@code { + + + + private string currentMessage = ""; + private bool isStreaming = false; + private bool isLoadingAgents = true; + private string currentStreamedMessage = ""; + private string selectedAgentName = ""; + private List availableAgents = new(); + private List conversations = new(); + private Conversation? currentConversation; + + private class Conversation + { + public string SessionId { get; set; } = Guid.NewGuid().ToString(); + public string AgentName { get; set; } = ""; + public List Messages { get; set; } = new(); + } + + protected override async Task OnInitializedAsync() + { + Logger.LogDebug("Initializing Agent Chat component"); + + // Load agents + try + { + availableAgents = await AgentClient.GetAgentsAsync(); + Logger.LogInformation("Loaded {AgentCount} agents", availableAgents.Count); + + // Default to first agent and start a conversation + if (availableAgents.Any()) + { + selectedAgentName = availableAgents.First().Name!; + StartNewConversation(); + } + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to load agents"); + } + finally + { + isLoadingAgents = false; + } + + // Conversations start fresh on page load + } + + private string GetAgentIcon(string agentName) + { + return agentName?.ToLower() switch + { + "pirate" => "🏴‍☠️", + "knights-and-knaves" => "⚔️", + _ => "🤖" + }; + } + + private string GetAgentDisplayName(string agentName) + { + return agentName?.ToLower() switch + { + "pirate" => "Pirate", + "knights-and-knaves" => "Knights & Knaves", + _ => agentName ?? "Agent" + }; + } + + + + private void StartNewConversation() + { + if (string.IsNullOrEmpty(selectedAgentName)) + return; + + var newConversation = new Conversation + { + AgentName = selectedAgentName + }; + + conversations.Add(newConversation); + currentConversation = newConversation; + + Logger.LogInformation("Started new conversation with agent: {AgentName}, session: {SessionId}", + newConversation.AgentName, newConversation.SessionId); + + StateHasChanged(); + } + + private void SelectConversation(string sessionId) + { + currentConversation = conversations.FirstOrDefault(c => c.SessionId == sessionId); + if (currentConversation != null) + { + selectedAgentName = currentConversation.AgentName; + Logger.LogDebug("Selected conversation with session: {SessionId}", sessionId); + } + StateHasChanged(); + } + + private void CloseConversation(string sessionId) + { + var conversationToRemove = conversations.FirstOrDefault(c => c.SessionId == sessionId); + if (conversationToRemove != null) + { + conversations.Remove(conversationToRemove); + + if (currentConversation?.SessionId == sessionId) + { + currentConversation = conversations.FirstOrDefault(); + if (currentConversation != null) + { + selectedAgentName = currentConversation.AgentName; + } + } + + Logger.LogInformation("Closed conversation with session: {SessionId}", sessionId); + } + StateHasChanged(); + } + + private async Task SendMessage() + { + if (string.IsNullOrWhiteSpace(currentMessage) || isStreaming || currentConversation == null) + return; + + var userMessage = currentMessage.Trim(); + currentMessage = ""; + + Logger.LogInformation("User sending message: '{UserMessage}' to agent {AgentName} in session {SessionId}", + userMessage, currentConversation.AgentName, currentConversation.SessionId); + + // Add user message to chat + currentConversation.Messages.Add(new ChatMessage(ChatRole.User, userMessage)); + StateHasChanged(); + await ScrollToBottom(); + + // Start streaming response + isStreaming = true; + currentStreamedMessage = ""; + StateHasChanged(); + + try + { + var responseContent = new StringBuilder(); + var agent = new AgentProxy(currentConversation.AgentName, ActorClient); + var thread = agent.GetNewThread(); + thread.ConversationId = currentConversation.SessionId; + + await foreach (var update in agent.RunStreamingAsync( + [new ChatMessage(ChatRole.User, userMessage)], + thread, + cancellationToken: CancellationToken.None)) + { + var content = update.Text ?? ""; + if (!string.IsNullOrEmpty(content)) + { + responseContent.Append(content); + currentStreamedMessage = responseContent.ToString(); + StateHasChanged(); + await ScrollToBottom(); + } + } + + // Add the complete agent response to chat messages + if (responseContent.Length > 0) + { + currentConversation.Messages.Add(new ChatMessage(ChatRole.Assistant, responseContent.ToString())); + } + else + { + currentConversation.Messages.Add(new ChatMessage(ChatRole.Assistant, "Sorry, I couldn't generate a response.")); + } + } + catch (Exception ex) + { + Logger.LogError(ex, "Error occurred while processing message in session {SessionId}: {ErrorMessage}", + currentConversation.SessionId, ex.Message); + currentConversation.Messages.Add(new ChatMessage(ChatRole.Assistant, $"Error: {ex.Message}")); + } + finally + { + isStreaming = false; + currentStreamedMessage = ""; + StateHasChanged(); + await ScrollToBottom(); + } + } + + private bool ShouldPreventDefault = false; + + private async Task HandleKeyPress(KeyboardEventArgs e) + { + if (e.Key == "Enter" && !e.ShiftKey) + { + ShouldPreventDefault = true; + await SendMessage(); + ShouldPreventDefault = false; + } + else if (e.Key == "Escape") + { + currentMessage = ""; // Clear input on Escape + ShouldPreventDefault = true; + StateHasChanged(); + ShouldPreventDefault = false; // Reset after clearing + } + else + { + ShouldPreventDefault = false; + } + } + + private async Task ScrollToBottom() + { + try + { + await JSRuntime.InvokeVoidAsync("scrollToBottom", "chat-messages"); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Failed to scroll to bottom"); + } + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + await JSRuntime.InvokeVoidAsync("eval", @" + window.scrollToBottom = function(elementId) { + const element = document.getElementById(elementId); + if (element) { + requestAnimationFrame(() => { + element.scrollTop = element.scrollHeight; + }); + } + }; + "); + } + } +} \ No newline at end of file diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Routes.razor b/dotnet/samples/AgentWebChat/AgentWebChat.Web/Components/Routes.razor similarity index 100% rename from dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Routes.razor rename to dotnet/samples/AgentWebChat/AgentWebChat.Web/Components/Routes.razor diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/_Imports.razor b/dotnet/samples/AgentWebChat/AgentWebChat.Web/Components/_Imports.razor similarity index 86% rename from dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/_Imports.razor rename to dotnet/samples/AgentWebChat/AgentWebChat.Web/Components/_Imports.razor index 4c2793f6db..5e3c2b03bd 100644 --- a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/_Imports.razor +++ b/dotnet/samples/AgentWebChat/AgentWebChat.Web/Components/_Imports.razor @@ -7,5 +7,5 @@ @using Microsoft.AspNetCore.Components.Web.Virtualization @using Microsoft.AspNetCore.OutputCaching @using Microsoft.JSInterop -@using HelloHttpApi.Web -@using HelloHttpApi.Web.Components +@using AgentWebChat.Web +@using AgentWebChat.Web.Components diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.Web/HttpActorClient.cs b/dotnet/samples/AgentWebChat/AgentWebChat.Web/HttpActorClient.cs new file mode 100644 index 0000000000..ed841f8799 --- /dev/null +++ b/dotnet/samples/AgentWebChat/AgentWebChat.Web/HttpActorClient.cs @@ -0,0 +1,190 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using System.Net.ServerSentEvents; +using System.Runtime.CompilerServices; +using System.Text.Json; +using Microsoft.Extensions.AI.Agents; +using Microsoft.Extensions.AI.Agents.Hosting; +using Microsoft.Extensions.AI.Agents.Runtime; + +namespace AgentWebChat.Web; + +internal sealed class HttpActorClient(HttpClient httpClient) : IActorClient +{ + private const string BaseUri = "/actors/v1"; + + public async ValueTask GetResponseAsync(ActorId actorId, string messageId, CancellationToken cancellationToken) + { + var uri = new Uri($"{BaseUri}/{actorId.Type}/{actorId.Key}/{messageId}", UriKind.Relative); + var response = await httpClient.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + return new HttpActorResponseHandle(httpClient, actorId, messageId, initialResponseMessage: response); + } + + public async ValueTask SendRequestAsync(ActorRequest request, CancellationToken cancellationToken) + { + var actorId = request.ActorId; + var messageId = request.MessageId; + var uri = new Uri($"{BaseUri}/{actorId.Type}/{actorId.Key}/{messageId}?streaming=true", UriKind.Relative); + var jsonContent = JsonContent.Create(request, AgentHostingJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ActorRequest))); + var message = new HttpRequestMessage(HttpMethod.Post, uri) { Content = jsonContent }; + var response = await httpClient.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + return new HttpActorResponseHandle(httpClient, actorId, messageId, response); + } + + private sealed class HttpActorResponseHandle( + HttpClient httpClient, + ActorId actorId, + string messageId, + HttpResponseMessage? initialResponseMessage) : ActorResponseHandle + { + private HttpResponseMessage? _responseMessage = initialResponseMessage; + private ActorResponse? _lastResponse; + + public override async ValueTask CancelAsync(CancellationToken cancellationToken) + { + this._responseMessage?.Dispose(); + this._responseMessage = null; + + var uri = new Uri($"{BaseUri}/{actorId.Type}/{actorId.Key}/{messageId}:cancel", UriKind.Relative); + await httpClient.PostAsync(uri, null, cancellationToken).ConfigureAwait(false); + } + + public override async ValueTask GetResponseAsync(CancellationToken cancellationToken) + { + try + { + // If the response is already completed, don't bother requesting the response again; + if (this._lastResponse is { } response && response.Status.IsTerminated()) + { + return response; + } + + if (IsStreamingResponse(this._responseMessage)) + { + try + { + var updates = new List(); + await foreach (var update in EnumerateAsync(this._responseMessage, cancellationToken).ConfigureAwait(false)) + { + if (!update.Status.IsTerminated()) + { + continue; + } + + response = new ActorResponse { ActorId = actorId, MessageId = messageId, Status = update.Status, Data = update.Data }; + this._lastResponse = response; + return response; + } + } + finally + { + this._responseMessage?.Dispose(); + this._responseMessage = null; + } + } + + var uri = new Uri($"{BaseUri}/{actorId.Type}/{actorId.Key}/{messageId}?blocking=true", UriKind.Relative); + using var responseMessage = this._responseMessage ?? await httpClient.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + response = await this.ReadResponseAsync(responseMessage, cancellationToken).ConfigureAwait(false); + this._lastResponse = response; + return response; + } + finally + { + this._responseMessage = null; + } + } + + public override bool TryGetResponse([NotNullWhen(true)] out ActorResponse? response) + { + response = this._lastResponse; + return response != null; + } + + public override async IAsyncEnumerable WatchUpdatesAsync([EnumeratorCancellation] CancellationToken cancellationToken) + { + // If the response is already completed, don't bother streaming anything. + if (this._lastResponse is { } response && response.Status.IsTerminated()) + { + yield return new ActorRequestUpdate(response.Status, response.Data); + yield break; + } + + try + { + var uri = new Uri($"{BaseUri}/{actorId.Type}/{actorId.Key}/{messageId}?streaming=true", UriKind.Relative); + using var responseMessage = this._responseMessage ?? await httpClient.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + if (IsJsonResponse(responseMessage)) + { + // If the response is JSON, read it as a single response and yield it. + response = await this.ReadResponseAsync(responseMessage, cancellationToken).ConfigureAwait(false); + + yield return new ActorRequestUpdate(response.Status, response.Data); + yield break; + } + + await foreach (var update in EnumerateAsync(responseMessage, cancellationToken).ConfigureAwait(false)) + { + yield return update; + } + } + finally + { + this._responseMessage = null; + } + } + + private static async IAsyncEnumerable EnumerateAsync(HttpResponseMessage responseMessage, [EnumeratorCancellation] CancellationToken cancellationToken) + { + var responseStream = await responseMessage.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + var sseParser = SseParser.Create(responseStream, (eventType, data) => + { + if (eventType != "message") + { + // Only process default message events + return null; + } + + var reader = new Utf8JsonReader(data); + return JsonSerializer.Deserialize(ref reader, AgentHostingJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ActorRequestUpdate))) as ActorRequestUpdate; + }); + + await foreach (var item in sseParser.EnumerateAsync(cancellationToken).ConfigureAwait(false)) + { + if (item.Data is not null) + { + yield return item.Data; + } + } + } + + private async Task ReadResponseAsync(HttpResponseMessage responseMessage, CancellationToken cancellationToken) + { + var response = await responseMessage.Content.ReadFromJsonAsync(AgentRuntimeJsonUtilities.DefaultOptions, cancellationToken).ConfigureAwait(false); + if (response == null) + { + throw new InvalidOperationException($"No response found for actor '{actorId}' with message ID '{messageId}'."); + } + + return response; + } + + private static bool IsJsonResponse([NotNullWhen(true)] HttpResponseMessage? response) + { + return response?.Content.Headers.ContentType?.MediaType == "application/json"; + } + + private static bool IsStreamingResponse([NotNullWhen(true)] HttpResponseMessage? response) + { + return response?.Content.Headers.ContentType?.MediaType == "text/event-stream"; + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + this._responseMessage?.Dispose(); + this._responseMessage = null; + } + } +} diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Program.cs b/dotnet/samples/AgentWebChat/AgentWebChat.Web/Program.cs similarity index 67% rename from dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Program.cs rename to dotnet/samples/AgentWebChat/AgentWebChat.Web/Program.cs index 68c8f8be5c..1173f2de29 100644 --- a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Program.cs +++ b/dotnet/samples/AgentWebChat/AgentWebChat.Web/Program.cs @@ -1,7 +1,8 @@ // Copyright (c) Microsoft. All rights reserved. -using HelloHttpApi.Web; -using HelloHttpApi.Web.Components; +using AgentWebChat.Web; +using AgentWebChat.Web.Components; +using Microsoft.Extensions.AI.Agents.Runtime; var builder = WebApplication.CreateBuilder(args); @@ -14,12 +15,8 @@ builder.Services.AddRazorComponents() builder.Services.AddOutputCache(); -builder.Services.AddHttpClient(client => - { - // This URL uses "https+http://" to indicate HTTPS is preferred over HTTP. - // Learn more about service discovery scheme resolution at https://aka.ms/dotnet/sdschemes. - client.BaseAddress = new("https+http://apiservice"); - }); +builder.Services.AddHttpClient(client => client.BaseAddress = new("https+http://agenthost")); +builder.Services.AddHttpClient(client => client.BaseAddress = new("https+http://agenthost")); var app = builder.Build(); @@ -32,11 +29,12 @@ if (!app.Environment.IsDevelopment()) app.UseHttpsRedirection(); -app.UseStaticFiles(); app.UseAntiforgery(); app.UseOutputCache(); +app.MapStaticAssets(); + app.MapRazorComponents() .AddInteractiveServerRenderMode(); diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Properties/launchSettings.json b/dotnet/samples/AgentWebChat/AgentWebChat.Web/Properties/launchSettings.json similarity index 100% rename from dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Properties/launchSettings.json rename to dotnet/samples/AgentWebChat/AgentWebChat.Web/Properties/launchSettings.json diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.AppHost/appsettings.Development.json b/dotnet/samples/AgentWebChat/AgentWebChat.Web/appsettings.Development.json similarity index 73% rename from dotnet/samples/HelloHttpApi/HelloHttpApi.AppHost/appsettings.Development.json rename to dotnet/samples/AgentWebChat/AgentWebChat.Web/appsettings.Development.json index 527a02bb3c..0c208ae918 100644 --- a/dotnet/samples/HelloHttpApi/HelloHttpApi.AppHost/appsettings.Development.json +++ b/dotnet/samples/AgentWebChat/AgentWebChat.Web/appsettings.Development.json @@ -1,7 +1,7 @@ { "Logging": { "LogLevel": { - "Default": "Trace", + "Default": "Information", "Microsoft.AspNetCore": "Warning" } } diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/appsettings.json b/dotnet/samples/AgentWebChat/AgentWebChat.Web/appsettings.json similarity index 77% rename from dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/appsettings.json rename to dotnet/samples/AgentWebChat/AgentWebChat.Web/appsettings.json index f41d5acb6b..10f68b8c8b 100644 --- a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/appsettings.json +++ b/dotnet/samples/AgentWebChat/AgentWebChat.Web/appsettings.json @@ -1,7 +1,7 @@ { "Logging": { "LogLevel": { - "Default": "Trace", + "Default": "Information", "Microsoft.AspNetCore": "Warning" } }, diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/wwwroot/app.css b/dotnet/samples/AgentWebChat/AgentWebChat.Web/wwwroot/app.css similarity index 64% rename from dotnet/samples/HelloHttpApi/HelloHttpApi.Web/wwwroot/app.css rename to dotnet/samples/AgentWebChat/AgentWebChat.Web/wwwroot/app.css index 2a2a313024..6b032e3458 100644 --- a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/wwwroot/app.css +++ b/dotnet/samples/AgentWebChat/AgentWebChat.Web/wwwroot/app.css @@ -1,39 +1,8 @@ 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; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + margin: 0; + padding: 0; + background-color: #f9fafb; } .blazor-error-boundary { @@ -42,15 +11,6 @@ h1:focus { 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; -} +.blazor-error-boundary::after { + content: "An error has occurred." +} \ No newline at end of file diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/wwwroot/favicon.png b/dotnet/samples/AgentWebChat/AgentWebChat.Web/wwwroot/favicon.png similarity index 100% rename from dotnet/samples/HelloHttpApi/HelloHttpApi.Web/wwwroot/favicon.png rename to dotnet/samples/AgentWebChat/AgentWebChat.Web/wwwroot/favicon.png diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/ActorFrameworkWebApplicationExtensions.cs b/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/ActorFrameworkWebApplicationExtensions.cs deleted file mode 100644 index 2295bfc0e5..0000000000 --- a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/ActorFrameworkWebApplicationExtensions.cs +++ /dev/null @@ -1,174 +0,0 @@ -// 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; - -namespace HelloHttpApi.ApiService; - -internal static class ActorFrameworkWebApplicationExtensions -{ - public static void MapAgents(this WebApplication app) - { - app.MapPost( - "/invocations/actor/{name}/{sessionId}/{requestId}", async ( - string name, - string sessionId, - string requestId, - [FromQuery] bool? stream, - [FromBody] JsonElement request, - HttpContext context, - ILogger logger, - IActorClient actorClient, - CancellationToken cancellationToken) => - { - var stopwatch = Stopwatch.StartNew(); - var streamRequested = stream == true; - - Log.ActorInvocationStarted(logger, name, sessionId, requestId, streamRequested); - Log.ActorRequestReceived(logger, requestId, request.GetRawText().Length, streamRequested); - - try - { - var responseHandle = await actorClient.SendRequestAsync(new ActorRequest(new ActorId(name, sessionId), requestId, method: "run", @params: request), cancellationToken); - Log.ActorRequestSent(logger, requestId, name, sessionId); - - if (!responseHandle.TryGetResponse(out var response)) - { - Log.ActorResponseHandleObtained(logger, requestId, false); - - if (stream == true) - { - Log.SseStreamingStarted(logger, requestId); - // If no response is available and streaming is requested, stream the response handle. - var result = await StreamResponse(context, responseHandle, cancellationToken); - Log.ActorInvocationCompleted(logger, name, sessionId, requestId, RequestStatus.Pending, stopwatch.ElapsedMilliseconds); - return result; - } - - // Otherwise, wait for a response to become available. - Log.WaitingForActorResponse(logger, requestId); - response = await responseHandle.GetResponseAsync(cancellationToken); - } - else - { - Log.ActorResponseHandleObtained(logger, requestId, true); - } - - Log.ActorResponseReceived(logger, requestId, response.Status); - var processResult = await ProcessResponse(name, sessionId, requestId, stream, context, responseHandle, response, cancellationToken); - Log.ActorInvocationCompleted(logger, name, sessionId, requestId, response.Status, stopwatch.ElapsedMilliseconds); - return processResult; - } - catch (Exception ex) - { - Log.ActorInvocationFailed(logger, ex, name, sessionId, requestId, stopwatch.ElapsedMilliseconds); - return Results.Problem("An error occurred processing the request.", statusCode: 500); - } - - static async Task StreamResponse(HttpContext context, ActorResponseHandle responseHandle, CancellationToken cancellationToken) - { - var requestId = context.Request.RouteValues["requestId"]?.ToString() ?? "unknown"; - var logger = context.RequestServices.GetRequiredService>(); - - Log.SseStreamingStarted(logger, requestId); - InitializeSseResponse(context); - await context.Response.Body.FlushAsync(cancellationToken); - - var updateCount = 0; - try - { - await foreach (var progress in responseHandle.WatchUpdatesAsync(cancellationToken)) - { - // Properly serialize the progress data as JSON and escape for SSE - var progressJson = JsonSerializer.Serialize(progress.Data, (JsonSerializerOptions?)null); - var eventData = JsonSerializer.Serialize(new { @event = JsonDocument.Parse(progressJson).RootElement }); - var eventText = $"data: {eventData}\n\n"; - - await context.Response.WriteAsync(eventText, cancellationToken); - await context.Response.Body.FlushAsync(cancellationToken); - - updateCount++; - Log.SseProgressUpdateSent(logger, requestId, updateCount); - } - - // Send completion marker - await context.Response.WriteAsync("data: completed\n\n", cancellationToken); - await context.Response.Body.FlushAsync(cancellationToken); - - Log.SseStreamingCompleted(logger, requestId, updateCount); - } - catch (OperationCanceledException) - { - Log.SseStreamingCancelled(logger, requestId); - } - catch (Exception ex) - { - Log.SseStreamingError(logger, ex, requestId); - } - - // TODO: refactor the enclosing method so we don't need to return a result here. - return Results.Empty; - } - - static void InitializeSseResponse(HttpContext context) - { - context.Response.Headers.ContentType = "text/event-stream"; - context.Response.Headers.CacheControl = "no-cache,no-store"; - context.Response.Headers.Connection = "keep-alive"; - - // Make sure we disable all response buffering for SSE. - context.Response.Headers.ContentEncoding = "identity"; - context.Features.GetRequiredFeature().DisableBuffering(); - } - - static async Task ProcessResponse( - string name, - string sessionId, - string requestId, - bool? stream, - HttpContext context, - ActorResponseHandle responseHandle, - ActorResponse response, - CancellationToken cancellationToken) - { - var logger = context.RequestServices.GetRequiredService>(); - var isStreaming = stream != false && response.Status == RequestStatus.Pending; - - Log.ProcessingActorResponse(logger, requestId, response.Status, isStreaming); - - var result = response.Status switch - { - // If the response is pending & streaming is disabled, return a 202 Accepted with the messageId. - RequestStatus.Pending when stream == false => Results.Accepted($"/invocations/actor/{name}/{sessionId}/{requestId}"), - - // If streaming is not explicitly disabled, stream the response back. - RequestStatus.Pending => await StreamResponse(context, responseHandle, cancellationToken), - RequestStatus.Completed => Results.Ok(response.Data), - - // If the response failed, we can return a 500 Internal Server Error. - RequestStatus.Failed => Results.Problem("The invocation failed.", statusCode: 500), - RequestStatus.NotFound => Results.NotFound(new { message = "Not found." }),// If the actor is not found, we can return a 404 Not Found. - _ => throw new NotSupportedException($"Unsupported request status: {response.Status}"), - }; - - var responseType = response.Status switch - { - RequestStatus.Pending when stream == false => "Accepted", - RequestStatus.Pending => "Streaming", - RequestStatus.Completed => "Ok", - RequestStatus.Failed => "Problem", - RequestStatus.NotFound => "NotFound", - _ => "Unknown" - }; - - Log.ActorResponseProcessed(logger, requestId, responseType); - return result; - } - }) - .WithName("Invocations"); - } -} diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/ChatClientAgentActorJsonContext.cs b/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/ChatClientAgentActorJsonContext.cs deleted file mode 100644 index fe8d72b533..0000000000 --- a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/ChatClientAgentActorJsonContext.cs +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace HelloHttpApi.ApiService; - -/// -/// Source-generated JSON type information for use by ChatClientAgentActor. -/// -[JsonSourceGenerationOptions( - JsonSerializerDefaults.Web, - UseStringEnumConverter = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - WriteIndented = false)] -[JsonSerializable(typeof(ChatClientAgentRunRequest))] -internal sealed partial class ChatClientAgentActorJsonContext : JsonSerializerContext; diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/ChatClientAgentRunRequest.cs b/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/ChatClientAgentRunRequest.cs deleted file mode 100644 index c367ad0bb6..0000000000 --- a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/ChatClientAgentRunRequest.cs +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Text.Json.Serialization; -using Microsoft.Extensions.AI; - -namespace HelloHttpApi.ApiService; - -public sealed class ChatClientAgentRunRequest -{ - [JsonPropertyName("messages")] - public List Messages { get; set; } = []; -} diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/HostApplicationBuilderAgentExtensions.cs b/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/HostApplicationBuilderAgentExtensions.cs deleted file mode 100644 index 284e3f18fa..0000000000 --- a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/HostApplicationBuilderAgentExtensions.cs +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Agents.Orchestration; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.AI.Agents; -using Microsoft.Extensions.AI.Agents.Runtime; -using Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB; - -namespace HelloHttpApi.ApiService; - -public static class HostApplicationBuilderAgentExtensions -{ - public static IHostApplicationBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string instructions, string? chatClientKey = null) - { - var agentKey = $"agent:{name}"; - builder.Services.AddKeyedSingleton(agentKey, (sp, key) => - { - var chatClient = chatClientKey is null ? sp.GetRequiredService() : sp.GetRequiredKeyedService(chatClientKey); - - ChatClientAgent triage = new(chatClient, "You are a triage agent. You will determine which agent to hand off the conversation to based on the user's input.", $"{name}_triageAgent"); - ChatClientAgent target = new(chatClient, instructions, $"{name}_targetAgent"); - ChatClientAgent customerService = new(chatClient, "You are a customer service agent. You will handle rude, angry, or upset customer inquiries, asking them to be more calm and polite.", $"{name}_customerServiceAgent"); - - return Handoffs - .StartWith(triage) - .Add(triage, target, "Hand off to the target agent for handling normal customer requests.") - .Add(triage, customerService, "Hand off to the customer service agent for handling rude customer inquiries.") - .Build("PirateWorkflow"); - }); - - var actorBuilder = builder.AddActorRuntime(); - - // Add CosmosDB state storage to override default storage - builder.Services.AddCosmosActorStateStorage("actor-state-db", "ActorState"); - - actorBuilder.AddActorType( - new ActorType(agentKey), - (sp, ctx) => new ChatClientAgentActor( - sp.GetRequiredKeyedService(agentKey), - ctx, - sp.GetRequiredService>())); - - return builder; - } -} diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/InvocationResponse.cs b/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/InvocationResponse.cs deleted file mode 100644 index b2d4820bff..0000000000 --- a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/InvocationResponse.cs +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace HelloHttpApi.ApiService; - -public class InvocationResponse -{ - [JsonPropertyName("response")] - public JsonElement Response { get; set; } - - [JsonPropertyName("status")] - public string? Status { get; set; } = "success"; -} diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/PingResponse.cs b/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/PingResponse.cs deleted file mode 100644 index 7ca5e90b0e..0000000000 --- a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/PingResponse.cs +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Text.Json.Serialization; - -namespace HelloHttpApi.ApiService; - -public class PingResponse(PingResponseStatus status, long timeOfLastUpdate) -{ - [JsonPropertyName("status")] - public PingResponseStatus Status { get; } = status; - - [JsonPropertyName("time_of_last_update")] - public long TimeOfLastUpdate { get; } = timeOfLastUpdate; -} diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/PingResponseStatus.cs b/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/PingResponseStatus.cs deleted file mode 100644 index 8a0c45d22b..0000000000 --- a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/PingResponseStatus.cs +++ /dev/null @@ -1,9 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -namespace HelloHttpApi.ApiService; - -public enum PingResponseStatus -{ - Healthy, - HealthyBusy, -} diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/Program.cs b/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/Program.cs deleted file mode 100644 index 81a8534708..0000000000 --- a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/Program.cs +++ /dev/null @@ -1,35 +0,0 @@ -// 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 CosmosDB client integration -builder.AddAzureCosmosClient("hello-http-api-cosmosdb"); - -// Add services to the container. -builder.Services.AddProblemDetails(); - -// Configure the chat model and our agent. -builder.AddKeyedChatClient("chat-model"); - -builder.AddAIAgent( - name: "pirate", - instructions: "You are a pirate. Speak like a pirate.", - chatClientKey: "chat-model"); - -var app = builder.Build(); - -// Configure the HTTP request pipeline. -app.UseExceptionHandler(); - -// Map the agents HTTP endpoints -app.MapAgents(); - -app.MapDefaultEndpoints(); - -app.Run(); diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/AgentClient.cs b/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/AgentClient.cs deleted file mode 100644 index 8a17efbb32..0000000000 --- a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/AgentClient.cs +++ /dev/null @@ -1,179 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Text.Json; -using System.Text.Json.Serialization; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.AI.Agents; - -namespace HelloHttpApi.Web; - -public class AgentClient(HttpClient httpClient, ILogger logger) -{ - public async IAsyncEnumerable SendMessageStreamAsync( - string agentName, - string message, - string sessionId = "default", - [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) - { - var requestId = Guid.NewGuid().ToString(); - var request = new ChatClientAgentRunRequest - { - Messages = [new ChatMessage(ChatRole.User, message)] - }; - - var content = JsonContent.Create(request, AgentClientJsonContext.Default.ChatClientAgentRunRequest); - - 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(); - - await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); - using var reader = new StreamReader(stream); - - string? line; - while ((line = await reader.ReadLineAsync(cancellationToken)) != null) - { - // If this indicates completion, break the loop - if (IsCompletionEvent(line)) - { - yield break; - } - - if (line.StartsWith("data: ", StringComparison.Ordinal)) - { - var jsonData = line.Substring(6); // Remove "data: " prefix - - if (TryParseEventData(jsonData, logger, out var responseUpdate)) - { - if (responseUpdate != null) - { - yield return responseUpdate; - } - } - else - { - logger.LogWarning("Received unrecognized event data: {JsonData}", jsonData); - } - } - } - } - - public async Task SendMessageAsync( - string agentName, - string message, - string sessionId = "default", - CancellationToken cancellationToken = default) - { - var requestId = Guid.NewGuid().ToString(); - var request = new ChatClientAgentRunRequest - { - Messages = [new ChatMessage(ChatRole.User, message)] - }; - - var content = JsonContent.Create(request, AgentClientJsonContext.Default.ChatClientAgentRunRequest); - - 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(AgentClientJsonContext.Default.AgentResponse, 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, AgentClientJsonContext.Default.EventData); - if (eventData?.Event != null) - { - var eventElement = eventData.Event.Value; - - // Try to deserialize as AgentRunResponseUpdate for intermediate updates - try - { - var update = JsonSerializer.Deserialize(eventElement.GetRawText(), AgentAbstractionsJsonUtilities.DefaultOptions); - if (update != null) - { - responseUpdate = update; - return true; - } - } - catch (JsonException) - { - // If it fails to deserialize as AgentRunResponseUpdate, it might be something else - logger.LogDebug("Failed to deserialize event as AgentRunResponseUpdate, might be final response or other data"); - } - - // Fallback: create a simple update with the raw content - responseUpdate = new AgentRunResponseUpdate(ChatRole.Assistant, eventElement.ToString()); - return true; - } - } - catch (JsonException ex) - { - logger.LogError(ex, "Failed to parse event data JSON: {JsonData}", jsonData); - } - - return false; - } - - private static bool IsCompletionEvent(string line) => string.Equals("data: completed", line, StringComparison.Ordinal); -} - -public class ChatClientAgentRunRequest -{ - [JsonPropertyName("messages")] - public List Messages { get; set; } = []; -} - -public class EventData -{ - [JsonPropertyName("event")] - public JsonElement? Event { get; set; } -} - -public class AgentResponse -{ - [JsonPropertyName("content")] - public string Content { get; set; } = ""; - - [JsonPropertyName("status")] - public string Status { get; set; } = ""; -} - -/// -/// Source-generated JSON type information for use by AgentClient. -/// -[JsonSourceGenerationOptions( - JsonSerializerDefaults.Web, - UseStringEnumConverter = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - WriteIndented = false)] -[JsonSerializable(typeof(ChatClientAgentRunRequest))] -[JsonSerializable(typeof(EventData))] -[JsonSerializable(typeof(AgentResponse))] -internal sealed partial class AgentClientJsonContext : JsonSerializerContext; diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Layout/MainLayout.razor b/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Layout/MainLayout.razor deleted file mode 100644 index 5a24bb1371..0000000000 --- a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Layout/MainLayout.razor +++ /dev/null @@ -1,23 +0,0 @@ -@inherits LayoutComponentBase - -
- - -
-
- About -
- -
- @Body -
-
-
- -
- An unhandled error has occurred. - Reload - 🗙 -
diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Layout/MainLayout.razor.css b/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Layout/MainLayout.razor.css deleted file mode 100644 index 038baf178b..0000000000 --- a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Layout/MainLayout.razor.css +++ /dev/null @@ -1,96 +0,0 @@ -.page { - position: relative; - display: flex; - flex-direction: column; -} - -main { - flex: 1; -} - -.sidebar { - background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%); -} - -.top-row { - background-color: #f7f7f7; - border-bottom: 1px solid #d6d5d5; - justify-content: flex-end; - height: 3.5rem; - display: flex; - align-items: center; -} - - .top-row ::deep a, .top-row ::deep .btn-link { - white-space: nowrap; - margin-left: 1.5rem; - text-decoration: none; - } - - .top-row ::deep a:hover, .top-row ::deep .btn-link:hover { - text-decoration: underline; - } - - .top-row ::deep a:first-child { - overflow: hidden; - text-overflow: ellipsis; - } - -@media (max-width: 640.98px) { - .top-row { - justify-content: space-between; - } - - .top-row ::deep a, .top-row ::deep .btn-link { - margin-left: 0; - } -} - -@media (min-width: 641px) { - .page { - flex-direction: row; - } - - .sidebar { - width: 250px; - height: 100vh; - position: sticky; - top: 0; - } - - .top-row { - position: sticky; - top: 0; - z-index: 1; - } - - .top-row.auth ::deep a:first-child { - flex: 1; - text-align: right; - width: 0; - } - - .top-row, article { - padding-left: 2rem !important; - padding-right: 1.5rem !important; - } -} - -#blazor-error-ui { - background: lightyellow; - bottom: 0; - box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); - display: none; - left: 0; - padding: 0.6rem 1.25rem 0.7rem 1.25rem; - position: fixed; - width: 100%; - z-index: 1000; -} - - #blazor-error-ui .dismiss { - cursor: pointer; - position: absolute; - right: 0.75rem; - top: 0.5rem; - } diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Layout/NavMenu.razor b/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Layout/NavMenu.razor deleted file mode 100644 index 8cc90687e7..0000000000 --- a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Layout/NavMenu.razor +++ /dev/null @@ -1,29 +0,0 @@ - - - - - diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Layout/NavMenu.razor.css b/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Layout/NavMenu.razor.css deleted file mode 100644 index 1338edb61e..0000000000 --- a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Layout/NavMenu.razor.css +++ /dev/null @@ -1,102 +0,0 @@ -.navbar-toggler { - appearance: none; - cursor: pointer; - width: 3.5rem; - height: 2.5rem; - color: white; - position: absolute; - top: 0.5rem; - right: 1rem; - border: 1px solid rgba(255, 255, 255, 0.1); - background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") no-repeat center/1.75rem rgba(255, 255, 255, 0.1); -} - -.navbar-toggler:checked { - background-color: rgba(255, 255, 255, 0.5); -} - -.top-row { - min-height: 3.5rem; - background-color: rgba(0,0,0,0.4); -} - -.navbar-brand { - font-size: 1.1rem; -} - -.bi { - display: inline-block; - position: relative; - width: 1.25rem; - height: 1.25rem; - margin-right: 0.75rem; - top: -1px; - background-size: cover; -} - -.bi-house-door-fill { - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E"); -} - -.bi-plus-square-fill { - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E"); -} - -.bi-list-nested { - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E"); -} - -.nav-item { - font-size: 0.9rem; - padding-bottom: 0.5rem; -} - - .nav-item:first-of-type { - padding-top: 1rem; - } - - .nav-item:last-of-type { - padding-bottom: 1rem; - } - - .nav-item ::deep a { - color: #d7d7d7; - border-radius: 4px; - height: 3rem; - display: flex; - align-items: center; - line-height: 3rem; - } - -.nav-item ::deep a.active { - background-color: rgba(255,255,255,0.37); - color: white; -} - -.nav-item ::deep a:hover { - background-color: rgba(255,255,255,0.1); - color: white; -} - -.nav-scrollable { - display: none; -} - -.navbar-toggler:checked ~ .nav-scrollable { - display: block; -} - -@media (min-width: 641px) { - .navbar-toggler { - display: none; - } - - .nav-scrollable { - /* Never collapse the sidebar for wide screens */ - display: block; - - /* Allow sidebar to scroll for tall menus */ - height: calc(100vh - 3.5rem); - overflow-y: auto; - } -} diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Pages/Counter.razor b/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Pages/Counter.razor deleted file mode 100644 index 1a4f8e7553..0000000000 --- a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Pages/Counter.razor +++ /dev/null @@ -1,19 +0,0 @@ -@page "/counter" -@rendermode InteractiveServer - -Counter - -

Counter

- -

Current count: @currentCount

- - - -@code { - private int currentCount = 0; - - private void IncrementCount() - { - currentCount++; - } -} diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Pages/Home.razor b/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Pages/Home.razor deleted file mode 100644 index 9001e0bd27..0000000000 --- a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Pages/Home.razor +++ /dev/null @@ -1,7 +0,0 @@ -@page "/" - -Home - -

Hello, world!

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

🏴‍☠️ Pirate Talk

- -

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

- -
-
- @foreach (var message in chatMessages) - { -
- @(message.IsUser ? "You" : "🏴‍☠️ Pirate"): -
@message.Content
-
- } - - @if (isStreaming && currentStreamedMessage.Length > 0) - { -
- 🏴‍☠️ Pirate: -
@currentStreamedMessage
-
- } -
- -
- - -
-
- - - -@code { - private string currentMessage = ""; - private bool isStreaming = false; - private string currentStreamedMessage = ""; - private List chatMessages = new(); - private string sessionId = Guid.NewGuid().ToString(); - private const string AgentName = "agent:pirate"; - - protected override void OnInitialized() - { - Logger.LogDebug("Initializing PirateTalk component with session ID: {SessionId}", sessionId); - } - - private async Task SendMessage() - { - if (string.IsNullOrWhiteSpace(currentMessage) || isStreaming) - return; - - var userMessage = currentMessage.Trim(); - currentMessage = ""; - - Logger.LogInformation("User sending message: '{UserMessage}' in session {SessionId}", userMessage, sessionId); - - // Add user message to chat - chatMessages.Add(new ChatMessage { Content = userMessage, IsUser = true }); - Logger.LogDebug("Chat message added to collection. Total messages: {MessageCount}, Is user message: {IsUserMessage}", chatMessages.Count, true); - Logger.LogTrace("StateHasChanged called during streaming for session {SessionId}", sessionId); - StateHasChanged(); - await ScrollToBottom(); - - // Start streaming response - isStreaming = true; - currentStreamedMessage = ""; - Logger.LogDebug("Starting streaming response for session {SessionId}", sessionId); - Logger.LogTrace("StateHasChanged called during streaming for session {SessionId}", sessionId); - StateHasChanged(); - - try - { - var responseContent = new StringBuilder(); - - await foreach (var update in AgentClient.SendMessageStreamAsync(AgentName, userMessage, sessionId)) - { - Logger.LogTrace("Received streaming update with text length: {TextLength} for session {SessionId}", update.Text?.Length ?? 0, sessionId); - - // Extract text content from the AgentRunResponseUpdate - var content = update.Text ?? ""; - if (!string.IsNullOrEmpty(content)) - { - Logger.LogDebug("Extracted content from update: '{ExtractedContent}' for session {SessionId}", content, sessionId); - responseContent.Append(content); - currentStreamedMessage = responseContent.ToString(); - Logger.LogTrace("StateHasChanged called during streaming for session {SessionId}", sessionId); - StateHasChanged(); - await ScrollToBottom(); - } - } - - // Add the complete pirate response to chat messages - if (responseContent.Length > 0) - { - Logger.LogInformation("Streaming completed with total response length: {ResponseLength} for session {SessionId}", responseContent.Length, sessionId); - chatMessages.Add(new ChatMessage { Content = responseContent.ToString(), IsUser = false }); - Logger.LogDebug("Chat message added to collection. Total messages: {MessageCount}, Is user message: {IsUserMessage}", chatMessages.Count, false); - } - else - { - Logger.LogWarning("Empty response received from agent for session {SessionId}", sessionId); - chatMessages.Add(new ChatMessage { Content = "Arrr, something went wrong with me response, matey!", IsUser = false }); - Logger.LogDebug("Chat message added to collection. Total messages: {MessageCount}, Is user message: {IsUserMessage}", chatMessages.Count, false); - } - } - catch (Exception ex) - { - Logger.LogError(ex, "Error occurred while processing message in session {SessionId}: {ErrorMessage}", sessionId, ex.Message); - chatMessages.Add(new ChatMessage { Content = $"Arrr, encountered rough seas: {ex.Message}", IsUser = false }); - Logger.LogDebug("Chat message added to collection. Total messages: {MessageCount}, Is user message: {IsUserMessage}", chatMessages.Count, false); - } - finally - { - isStreaming = false; - currentStreamedMessage = ""; - Logger.LogTrace("StateHasChanged called during streaming for session {SessionId}", sessionId); - StateHasChanged(); - await ScrollToBottom(); - } - } - - private async Task HandleKeyPress(KeyboardEventArgs e) - { - Logger.LogDebug("Handling key press event: {Key} for session {SessionId}", e.Key, sessionId); - if (e.Key == "Enter" && !e.ShiftKey) - { - await SendMessage(); - } - } - - private async Task ScrollToBottom() - { - try - { - Logger.LogTrace("Scrolling chat to bottom for session {SessionId}", sessionId); - await JSRuntime.InvokeVoidAsync("scrollToBottom", "chat-messages"); - } - catch (Exception ex) - { - Logger.LogWarning(ex, "Failed to scroll to bottom due to JavaScript error for session {SessionId}", sessionId); - // Ignore JS errors - } - } - - protected override async Task OnAfterRenderAsync(bool firstRender) - { - if (firstRender) - { - Logger.LogDebug("Component first render completed, JavaScript functions initialized for session {SessionId}", sessionId); - await JSRuntime.InvokeVoidAsync("eval", @" - window.scrollToBottom = function(elementId) { - const element = document.getElementById(elementId); - if (element) { - element.scrollTop = element.scrollHeight; - } - }; - "); - } - } - - private class ChatMessage - { - public string Content { get; set; } = ""; - public bool IsUser { get; set; } - } -} diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/ChatClientAgentActor.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentActor.cs similarity index 64% rename from dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/ChatClientAgentActor.cs rename to dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentActor.cs index 275c4f3467..9502e2c062 100644 --- a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/ChatClientAgentActor.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentActor.cs @@ -1,18 +1,22 @@ // Copyright (c) Microsoft. All rights reserved. +using System; +using System.Collections.Generic; using System.Diagnostics; using System.Text.Json; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.AI.Agents; +using System.Threading; +using System.Threading.Tasks; using Microsoft.Extensions.AI.Agents.Runtime; +using Microsoft.Extensions.Logging; -namespace HelloHttpApi.ApiService; +namespace Microsoft.Extensions.AI.Agents.Hosting; -internal sealed class ChatClientAgentActor( +internal sealed class AgentActor( AIAgent agent, IActorRuntimeContext context, - ILogger logger) : IActor + ILogger logger) : IActor { + private const string ThreadStateKey = "thread"; private string? _etag; private AgentThread? _thread; @@ -25,7 +29,7 @@ internal sealed class ChatClientAgentActor( // Restore thread state var response = await context.ReadAsync( - new ActorReadOperationBatch([new GetValueOperation("thread")]), + new ActorReadOperationBatch([new GetValueOperation(ThreadStateKey)]), cancellationToken).ConfigureAwait(false); this._etag = response.ETag; @@ -33,8 +37,8 @@ internal sealed class ChatClientAgentActor( { if (threadResult.Value is { } threadJson) { - // Deserialize the thread state if it exist - this._thread = await agent.DeserializeThreadAsync(threadJson, cancellationToken: cancellationToken).ConfigureAwait(false); + // Deserialize the thread state if it exists + await agent.DeserializeThreadAsync(threadJson, cancellationToken: cancellationToken).ConfigureAwait(false); } } @@ -63,6 +67,11 @@ internal sealed class ChatClientAgentActor( } catch (Exception ex) { + if (cancellationToken.IsCancellationRequested && ex is OperationCanceledException) + { + return; + } + Log.ErrorProcessingMessages(logger, ex, context.ActorId.ToString()); } } @@ -74,11 +83,25 @@ internal sealed class ChatClientAgentActor( Debug.Assert(this._thread is not null); Debug.Assert(this._etag is not null); + if (message.Method is not AgentActorConstants.RunMethodName) + { + // Unsupported method, we can only handle "Run" requests. + var data = JsonSerializer.SerializeToElement("Unsupported method.", AgentHostingJsonUtilities.DefaultOptions.GetTypeInfo(typeof(string))); + var writeResponse = await context.WriteAsync( + new(this._etag, [ + new UpdateRequestOperation( + requestId, + RequestStatus.Failed, + data)]), + cancellationToken).ConfigureAwait(false); + return; + } + // Parse the request to get the agent run parameters List? messages; if (message.Params is { } payload) { - var arg = payload.Deserialize(ChatClientAgentActorJsonContext.Default.ChatClientAgentRunRequest); + var arg = payload.Deserialize(AgentHostingJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AgentRunRequest))) as AgentRunRequest; messages = arg?.Messages; } @@ -91,7 +114,7 @@ internal sealed class ChatClientAgentActor( var updates = new List(); await foreach (var update in agent.RunStreamingAsync(messages, this._thread, cancellationToken: cancellationToken).ConfigureAwait(false)) { - var updateJson = JsonSerializer.SerializeToElement(update, AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AgentRunResponseUpdate))); + var updateJson = JsonSerializer.SerializeToElement(update, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AgentRunResponseUpdate))); context.OnProgressUpdate(requestId, i++, updateJson); updates.Add(update); Log.AgentStreamingUpdate(logger, requestId, i); @@ -99,9 +122,14 @@ internal sealed class ChatClientAgentActor( var serializedRunResponse = JsonSerializer.SerializeToElement( updates.ToAgentRunResponse(), - AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AgentRunResponse))); + AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AgentRunResponse))); + var updatedThread = JsonSerializer.SerializeToElement(this._thread, AgentHostingJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AgentThread))); var writeResponse = await context.WriteAsync( - new(this._etag, [new UpdateRequestOperation(requestId, RequestStatus.Completed, serializedRunResponse)]), cancellationToken) + new(this._etag, + [ + new UpdateRequestOperation(requestId, RequestStatus.Completed, serializedRunResponse), + new SetValueOperation(ThreadStateKey, updatedThread) + ]), cancellationToken) .ConfigureAwait(false); if (!writeResponse.Success) { diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentActorConstants.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentActorConstants.cs new file mode 100644 index 0000000000..de10aa345a --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentActorConstants.cs @@ -0,0 +1,8 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Extensions.AI.Agents.Hosting; + +internal static class AgentActorConstants +{ + public const string RunMethodName = "Run"; +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentCatalog.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentCatalog.cs new file mode 100644 index 0000000000..b62714daec --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentCatalog.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Threading; + +namespace Microsoft.Extensions.AI.Agents.Hosting; + +/// +/// Provides a catalog of registered AI agents within the hosting environment. +/// +/// +/// The agent catalog allows enumeration of all registered agents in the dependency injection container. +/// This is useful for scenarios where you need to discover and interact with multiple agents programmatically. +/// +public abstract class AgentCatalog +{ + /// + /// Initializes a new instance of the class. + /// + protected AgentCatalog() + { + } + + /// + /// Asynchronously retrieves all registered AI agents from the catalog. + /// + /// A cancellation token that can be used to cancel the enumeration operation. + /// + /// An asynchronous enumerable of instances representing all registered agents. + /// The enumeration will only include agents that are successfully resolved from the service provider. + /// + /// + /// This method enumerates through all registered agent names and attempts to resolve each agent + /// from the dependency injection container. Only successfully resolved agents are yielded. + /// The enumeration is lazy and agents are resolved on-demand during iteration. + /// + public abstract IAsyncEnumerable GetAgentsAsync(CancellationToken cancellationToken = default); +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentHostingJsonUtilities.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentHostingJsonUtilities.cs new file mode 100644 index 0000000000..dcc62c5d52 --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentHostingJsonUtilities.cs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.AI.Agents.Runtime; + +namespace Microsoft.Extensions.AI.Agents.Hosting; + +/// Provides a collection of utility methods for working with JSON data in the context of agent hosting. +public static partial class AgentHostingJsonUtilities +{ + /// + /// Gets the singleton used as the default in JSON serialization operations. + /// + /// + /// + /// For Native AOT or applications disabling , this instance + /// includes source generated contracts for all common exchange types contained in this library. + /// + /// + /// It additionally turns on the following settings: + /// + /// Enables defaults. + /// Enables as the default ignore condition for properties. + /// Enables as the default number handling for number types. + /// + /// + /// + public static JsonSerializerOptions DefaultOptions { get; } = CreateDefaultOptions(); + + /// + /// Creates default options to use for agent hosting-related serialization. + /// + /// The configured options. + [UnconditionalSuppressMessage("ReflectionAnalysis", "IL3050:RequiresDynamicCode", Justification = "Converter is guarded by IsReflectionEnabledByDefault check.")] + [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access", Justification = "Converter is guarded by IsReflectionEnabledByDefault check.")] + private static JsonSerializerOptions CreateDefaultOptions() + { + // Copy the configuration from the source generated context. + JsonSerializerOptions options = new(JsonContext.Default.Options); + + // Chain with all supported types from Microsoft.Extensions.AI.Agents.Abstractions. + options.TypeInfoResolverChain.Add(AgentAbstractionsJsonUtilities.DefaultOptions.TypeInfoResolver!); + options.TypeInfoResolverChain.Add(AgentRuntimeAbstractionsJsonUtilities.DefaultOptions.TypeInfoResolver!); + + options.MakeReadOnly(); + return options; + } + + // Keep in sync with CreateDefaultOptions above. + [JsonSourceGenerationOptions(JsonSerializerDefaults.Web, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + NumberHandling = JsonNumberHandling.AllowReadingFromString)] + [JsonSerializable(typeof(AgentRunRequest))] + [JsonSerializable(typeof(AgentProxyThread))] + [JsonSerializable(typeof(AgentThread))] + [ExcludeFromCodeCoverage] + internal sealed partial class JsonContext : JsonSerializerContext; +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentProxy.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentProxy.cs new file mode 100644 index 0000000000..e2ea2d0453 --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentProxy.cs @@ -0,0 +1,158 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI.Agents.Runtime; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI.Agents.Hosting; + +/// +/// Represents a proxy for an AI agent that communicates with the agent runtime via an actor client. +/// +public sealed class AgentProxy : AIAgent +{ + private readonly IActorClient _client; + + /// + /// Initializes a new instance of the class with the specified agent name and actor client. + /// + /// The name of the agent. + /// The actor client used to communicate with the agent. + public AgentProxy(string name, IActorClient client) + { + Throw.IfNull(client); + Throw.IfNullOrEmpty(name); + this._client = client; + this.Name = name; + } + + /// + public override string Name { get; } + + /// + public override AgentThread GetNewThread() => new AgentProxyThread(); + + /// + /// Gets a thread by its . + /// + /// The thread identifier. + /// The thread. + public AgentThread GetThread(string conversationId) => new AgentProxyThread(conversationId); + + /// + public override async Task RunAsync( + IReadOnlyCollection messages, + AgentThread? thread = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + { + Throw.IfNull(messages); + var agentThread = GetAgentThreadId(thread); + return await this.RunAsync(messages, agentThread, cancellationToken).ConfigureAwait(false); + } + + /// + public override async IAsyncEnumerable RunStreamingAsync( + IReadOnlyCollection messages, + AgentThread? thread = null, + AgentRunOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + Throw.IfNull(messages); + var agentThread = GetAgentThreadId(thread); + await foreach (var item in this.RunStreamingAsync(messages, agentThread, cancellationToken).ConfigureAwait(false)) + { + yield return item; + } + } + + private async Task RunAsync(IReadOnlyCollection messages, string threadId, CancellationToken cancellationToken) + { + Throw.IfNull(messages); + var handle = await this.RunCoreAsync(messages, threadId, cancellationToken).ConfigureAwait(false); + var response = await handle.GetResponseAsync(cancellationToken).ConfigureAwait(false); + return response.Status switch + { + RequestStatus.Completed => (AgentRunResponse)response.Data.Deserialize( + AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AgentRunResponse)))!, + RequestStatus.Failed => throw new InvalidOperationException($"The agent run request failed: {response.Data}"), + RequestStatus.Pending => throw new InvalidOperationException("The agent run request is still pending."), + _ => throw new NotSupportedException($"The agent run request returned an unsupported status: {response.Status}.") + }; + } + + private async IAsyncEnumerable RunStreamingAsync( + IReadOnlyCollection messages, + string threadId, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + Throw.IfNull(messages); + var response = await this.RunCoreAsync(messages, threadId, cancellationToken).ConfigureAwait(false); + var updateTypeInfo = AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AgentRunResponseUpdate)); + await foreach (var update in response.WatchUpdatesAsync(cancellationToken).ConfigureAwait(false)) + { + if (update.Status is RequestStatus.Failed) + { + throw new InvalidOperationException($"The agent run request failed: {update.Data}"); + } + + if (update.Status is RequestStatus.Completed) + { + var responseTypeInfo = AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AgentRunResponse)); + var runResponse = (AgentRunResponse)update.Data.Deserialize(responseTypeInfo)!; + foreach (var item in runResponse.ToAgentRunResponseUpdates()) + { + yield return item; + } + + yield break; + } + + var runResponseUpdate = (AgentRunResponseUpdate)update.Data.Deserialize(updateTypeInfo)!; + yield return runResponseUpdate; + } + } + + private static string GetAgentThreadId(AgentThread? thread) + { + if (thread is null) + { + return AgentProxyThread.CreateId(); + } + + if (thread is not AgentProxyThread agentProxyThread) + { + throw new ArgumentException("The thread must be an instance of AgentProxyThread.", nameof(thread)); + } + + return agentProxyThread.ConversationId!; + } + + private async Task RunCoreAsync(IReadOnlyCollection messages, string threadId, CancellationToken cancellationToken) + { + Debug.Assert(messages is not null); + Debug.Assert(threadId is not null); + var newMessages = new List(messages); + + var runRequest = new AgentRunRequest + { + Messages = newMessages + }; + + string messageId = newMessages.LastOrDefault()?.MessageId ?? Guid.NewGuid().ToString(); + var actorRequest = new ActorRequest( + actorId: new ActorId(this.Name, threadId), + messageId, + method: AgentActorConstants.RunMethodName, + @params: JsonSerializer.SerializeToElement(runRequest, AgentHostingJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AgentRunRequest)))); + var handle = await this._client.SendRequestAsync(actorRequest, cancellationToken).ConfigureAwait(false); + return handle; + } +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentProxyThread.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentProxyThread.cs new file mode 100644 index 0000000000..83cea8eabb --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentProxyThread.cs @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Text.RegularExpressions; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI.Agents.Hosting; + +/// +/// Represents an agent thread for a . +/// +internal sealed partial class AgentProxyThread : AgentThread +{ +#if NET7_0_OR_GREATER + [System.Diagnostics.CodeAnalysis.StringSyntax("Regex")] +#endif + private const string ThreadValidationRegex = "^[a-zA-Z0-9_.\\-~]+$"; + +#if NET7_0_OR_GREATER + /// + /// Regular expression pattern for valid thread IDs. + /// Thread IDs must be alphanumeric and can contain hyphens, underscores, dots, and tildes (RFC 3986 unreserved characters). + /// + [GeneratedRegex(ThreadValidationRegex, RegexOptions.Compiled)] + private static partial Regex ValidIdPattern(); +#else + /// + /// Regular expression pattern for valid thread IDs. + /// Thread IDs must be alphanumeric and can contain hyphens, underscores, dots, and tildes (RFC 3986 unreserved characters). + /// + private static readonly Regex s_validIdPattern = new(ThreadValidationRegex, RegexOptions.Compiled); + + /// + /// Regular expression pattern for valid thread IDs. + /// Thread IDs must be alphanumeric and can contain hyphens, underscores, dots, and tildes (RFC 3986 unreserved characters). + /// + private static Regex ValidIdPattern() => s_validIdPattern; +#endif + + /// + /// Initializes a new instance of the class with the specified identifier. + /// + /// The unique identifier for the agent proxy thread. + public AgentProxyThread(string id) + { + Throw.IfNullOrEmpty(id); + ValidateId(id); + this.ConversationId = id; + } + + /// + /// Initializes a new instance of the class with the specified identifier. + /// + public AgentProxyThread() : this(CreateId()) + { + } + + internal static string CreateId() => Guid.NewGuid().ToString("N"); + + /// + /// Validates that the provided ID matches the required pattern for thread IDs. + /// + /// The ID to validate. + /// Thrown when the ID is not valid. + private static void ValidateId(string id) + { + if (!ValidIdPattern().IsMatch(id)) + { + throw new ArgumentException($"Thread ID '{id}' is not valid. Thread IDs must contain only alphanumeric characters, hyphens, underscores, dots, and tildes.", nameof(id)); + } + } +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentRunRequest.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentRunRequest.cs new file mode 100644 index 0000000000..4ee50e85a0 --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentRunRequest.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Microsoft.Extensions.AI.Agents.Hosting; + +/// +/// Represents a request to run an agent with a collection of chat messages. +/// +public sealed class AgentRunRequest +{ + /// + /// Gets or sets the collection of chat messages to be processed by the agent. + /// + [JsonPropertyName("messages")] + public List? Messages { get; set; } +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/HostApplicationBuilderAgentExtensions.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/HostApplicationBuilderAgentExtensions.cs new file mode 100644 index 0000000000..e402e27d52 --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/HostApplicationBuilderAgentExtensions.cs @@ -0,0 +1,158 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Linq; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.AI.Agents.Runtime; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI.Agents.Hosting; + +/// +/// Provides extension methods for configuring AI agents in a host application builder. +/// +public static class HostApplicationBuilderAgentExtensions +{ + /// + /// Adds an AI agent to the host application builder with the specified name and instructions. + /// + /// The host application builder to configure. + /// The name of the agent. + /// The instructions for the agent. + /// The configured host application builder. + /// Thrown when , , or is null. + public static IHostApplicationBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string instructions) + { + Throw.IfNull(builder); + Throw.IfNull(name); + return builder.AddAIAgent(name, instructions, chatClientServiceKey: null); + } + + /// + /// Adds an AI agent to the host application builder with the specified name, instructions, and chat client key. + /// + /// The host application builder to configure. + /// The name of the agent. + /// The instructions for the agent. + /// The chat client which the agent will use for inference. + /// The configured host application builder. + /// Thrown when , , or is null. + public static IHostApplicationBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string instructions, IChatClient chatClient) + { + Throw.IfNull(builder); + Throw.IfNull(name); + return builder.AddAIAgent(name, (sp, key) => new ChatClientAgent(chatClient, instructions, key)); + } + + /// + /// Adds an AI agent to the host application builder with the specified name, instructions, and chat client key. + /// + /// The host application builder to configure. + /// The name of the agent. + /// The instructions for the agent. + /// A description of the agent. + /// The key to use when resolving the chat client from the service provider. If null, a non-keyed service will be resolved. + /// The configured host application builder. + /// Thrown when , , or is null. + public static IHostApplicationBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string instructions, string description, object? chatClientServiceKey) + { + Throw.IfNull(builder); + Throw.IfNull(name); + return builder.AddAIAgent(name, (sp, key) => + { + var chatClient = chatClientServiceKey is null ? sp.GetRequiredService() : sp.GetRequiredKeyedService(chatClientServiceKey); + return new ChatClientAgent(chatClient, instructions: instructions, name: key, description: description); + }); + } + + /// + /// Adds an AI agent to the host application builder with the specified name, instructions, and chat client key. + /// + /// The host application builder to configure. + /// The name of the agent. + /// The instructions for the agent. + /// The key to use when resolving the chat client from the service provider. If null, a non-keyed service will be resolved. + /// The configured host application builder. + /// Thrown when , , or is null. + public static IHostApplicationBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string instructions, object? chatClientServiceKey) + { + Throw.IfNull(builder); + Throw.IfNull(name); + return builder.AddAIAgent(name, (sp, key) => + { + var chatClient = chatClientServiceKey is null ? sp.GetRequiredService() : sp.GetRequiredKeyedService(chatClientServiceKey); + return new ChatClientAgent(chatClient, instructions, key); + }); + } + + /// + /// Adds an AI agent to the host application builder using a custom factory delegate. + /// + /// The host application builder to configure. + /// The name of the agent. + /// A factory delegate that creates the AI agent instance. The delegate receives the service provider and agent key as parameters. + /// The configured host application builder. + /// Thrown when , , or is null. + /// Thrown when the agent factory delegate returns null or an invalid AI agent instance. + public static IHostApplicationBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, Func createAgentDelegate) + { + Throw.IfNull(builder); + Throw.IfNull(name); + Throw.IfNull(createAgentDelegate); + builder.Services.AddKeyedSingleton(name, (sp, key) => + { + Throw.IfNull(key); + var keyString = key as string; + Throw.IfNullOrEmpty(keyString); + var agent = createAgentDelegate(sp, keyString) ?? throw new InvalidOperationException($"The agent factory did not return a valid {nameof(AIAgent)} instance for key '{keyString}'."); + if (agent.Name != keyString) + { + throw new InvalidOperationException($"The agent factory returned an agent with name '{agent.Name}', but the expected name is '{keyString}'."); + } + + return agent; + }); + + return builder.AddAgentActor(name); + } + + private static IHostApplicationBuilder AddAgentActor(this IHostApplicationBuilder builder, string name) + { + Throw.IfNull(builder); + + // Register the agent by name for discovery. + var agentHostBuilder = GetAgentRegistry(builder); + agentHostBuilder.AgentNames.Add(name); + + // Add the actor runtime and register the agent actor type. + var actorBuilder = builder.AddActorRuntime(); + actorBuilder.AddActorType( + new ActorType(name), + (sp, ctx) => new AgentActor( + sp.GetRequiredKeyedService(name), + ctx, + sp.GetRequiredService>())); + return builder; + } + + private static LocalAgentRegistry GetAgentRegistry(IHostApplicationBuilder builder) + { + var descriptor = builder.Services.FirstOrDefault(s => !s.IsKeyedService && s.ServiceType.Equals(typeof(LocalAgentRegistry))); + if (descriptor?.ImplementationInstance is not LocalAgentRegistry instance) + { + instance = new LocalAgentRegistry(); + ConfigureHostBuilder(builder, instance); + } + + return instance; + } + + private static void ConfigureHostBuilder(IHostApplicationBuilder builder, LocalAgentRegistry agentHostBuilderContext) + { + builder.Services.Add(ServiceDescriptor.Singleton(agentHostBuilderContext)); + builder.Services.AddSingleton(); + } +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/LocalAgentCatalog.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/LocalAgentCatalog.cs new file mode 100644 index 0000000000..e578581d06 --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/LocalAgentCatalog.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.Extensions.AI.Agents.Hosting; + +// Implementation of an AgentCatalog which enumerates agents registered in the local service provider. +internal sealed class LocalAgentCatalog : AgentCatalog +{ + public readonly HashSet _registeredAgents; + private readonly IServiceProvider _serviceProvider; + + public LocalAgentCatalog(LocalAgentRegistry agentHostBuilder, IServiceProvider serviceProvider) + { + this._registeredAgents = [.. agentHostBuilder.AgentNames]; + this._serviceProvider = serviceProvider; + } + + public override async IAsyncEnumerable GetAgentsAsync([EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await Task.CompletedTask.ConfigureAwait(false); + + foreach (var name in this._registeredAgents) + { + var agent = this._serviceProvider.GetKeyedService(name); + if (agent is not null) + { + yield return agent; + } + } + } +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/LocalAgentRegistry.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/LocalAgentRegistry.cs new file mode 100644 index 0000000000..a15e6d5e3c --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/LocalAgentRegistry.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; + +namespace Microsoft.Extensions.AI.Agents.Hosting; + +internal sealed class LocalAgentRegistry +{ + public HashSet AgentNames { get; } = []; +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/Log.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/Log.cs new file mode 100644 index 0000000000..ab07708cf9 --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/Log.cs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Extensions.AI.Agents.Hosting; + +/// +/// High-performance logging messages using LoggerMessage source generator. +/// +internal static partial class Log +{ + [LoggerMessage( + Level = LogLevel.Information, + Message = "Actor started: ActorId={ActorId}, AgentName={AgentName}")] + public static partial void ActorStarted(ILogger logger, string actorId, string agentName); + + [LoggerMessage( + Level = LogLevel.Debug, + Message = "Thread state restored: ActorId={ActorId}, HasExistingThread={HasExistingThread}")] + public static partial void ThreadStateRestored(ILogger logger, string actorId, bool hasExistingThread); + + [LoggerMessage( + Level = LogLevel.Information, + Message = "Processing agent request: RequestId={RequestId}, ActorId={ActorId}, MessageCount={MessageCount}")] + public static partial void ProcessingAgentRequest(ILogger logger, string requestId, string actorId, int messageCount); + + [LoggerMessage( + Level = LogLevel.Debug, + Message = "Agent streaming update: RequestId={RequestId}, UpdateNumber={UpdateNumber}")] + public static partial void AgentStreamingUpdate(ILogger logger, string requestId, int updateNumber); + + [LoggerMessage( + Level = LogLevel.Information, + Message = "Agent request completed: RequestId={RequestId}, TotalUpdates={TotalUpdates}")] + public static partial void AgentRequestCompleted(ILogger logger, string requestId, int totalUpdates); + + [LoggerMessage( + Level = LogLevel.Error, + Message = "Agent request failed: RequestId={RequestId}, ActorId={ActorId}")] + public static partial void AgentRequestFailed(ILogger logger, Exception exception, string requestId, string actorId); + + [LoggerMessage( + Level = LogLevel.Warning, + Message = "Unknown message type received: MessageType={MessageType}, ActorId={ActorId}")] + public static partial void UnknownMessageType(ILogger logger, string messageType, string actorId); + + [LoggerMessage( + Level = LogLevel.Warning, + Message = "Error processing messages: ActorId={ActorId}")] + public static partial void ErrorProcessingMessages(ILogger logger, Exception exception, string actorId); + + [LoggerMessage( + Level = LogLevel.Error, + Message = "Write operation failed: ActorId={ActorId}, RequestId={RequestId}")] + public static partial void WriteOperationFailed(ILogger logger, string actorId, string requestId); +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/Microsoft.Extensions.AI.Agents.Hosting.csproj b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/Microsoft.Extensions.AI.Agents.Hosting.csproj new file mode 100644 index 0000000000..b8fcdadd10 --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/Microsoft.Extensions.AI.Agents.Hosting.csproj @@ -0,0 +1,44 @@ + + + + $(ProjectsTargetFrameworks) + $(ProjectsDebugTargetFrameworks) + alpha + + + + true + true + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorJsonContext.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorJsonContext.cs deleted file mode 100644 index 2652143485..0000000000 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorJsonContext.cs +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace Microsoft.Extensions.AI.Agents.Runtime; - -/// -/// Source-generated JSON type information for use by all agent runtime abstractions. -/// -[JsonSourceGenerationOptions( - JsonSerializerDefaults.Web, - UseStringEnumConverter = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - WriteIndented = false)] -[JsonSerializable(typeof(ActorId))] -[JsonSerializable(typeof(ActorMessage))] -[JsonSerializable(typeof(ActorReadOperation))] -[JsonSerializable(typeof(ActorReadOperationBatch))] -[JsonSerializable(typeof(ActorReadResult))] -[JsonSerializable(typeof(ActorRequest))] -[JsonSerializable(typeof(ActorRequestMessage))] -[JsonSerializable(typeof(ActorRequestUpdate))] -[JsonSerializable(typeof(ActorResponse))] -[JsonSerializable(typeof(ActorResponseMessage))] -[JsonSerializable(typeof(ActorType))] -[JsonSerializable(typeof(ActorWriteOperation))] -[JsonSerializable(typeof(ActorWriteOperationBatch))] -[JsonSerializable(typeof(GetValueOperation))] -[JsonSerializable(typeof(GetValueResult))] -[JsonSerializable(typeof(JsonElement))] -[JsonSerializable(typeof(ListKeysOperation))] -[JsonSerializable(typeof(ListKeysResult))] -[JsonSerializable(typeof(ReadResponse))] -[JsonSerializable(typeof(RemoveKeyOperation))] -[JsonSerializable(typeof(RequestStatus))] -[JsonSerializable(typeof(SendRequestOperation))] -[JsonSerializable(typeof(SetValueOperation))] -[JsonSerializable(typeof(UpdateRequestOperation))] -[JsonSerializable(typeof(WriteResponse))] -internal sealed partial class ActorJsonContext : JsonSerializerContext; diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorResponse.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorResponse.cs index f11846c3f7..47543f567e 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorResponse.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorResponse.cs @@ -33,4 +33,25 @@ public sealed class ActorResponse ///
[JsonPropertyName("status")] public RequestStatus Status { get; init; } + + /// + public override string ToString() + { + string dataString; + if (this.Data.ValueKind == JsonValueKind.Undefined) + { + dataString = "undefined"; + } + else + { + var rawText = this.Data.GetRawText(); + dataString = rawText.Length switch + { + > 250 => $"{rawText.Substring(0, 250)}...", + _ => rawText, + }; + } + + return $"ActorResponse(ActorId: {this.ActorId}, Status: {this.Status}, MessageId: {this.MessageId ?? "null"}, Data: {dataString})"; + } } diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorResponseHandle.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorResponseHandle.cs index 2508bd28eb..5b4d095491 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorResponseHandle.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorResponseHandle.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Threading; @@ -10,7 +11,7 @@ namespace Microsoft.Extensions.AI.Agents.Runtime; /// /// Represents a handle to an actor response, allowing retrieval of the response data and status updates. /// -public abstract class ActorResponseHandle +public abstract class ActorResponseHandle : IDisposable { /// /// Attempts to get the response from the request if it is immediately available. @@ -43,4 +44,17 @@ public abstract class ActorResponseHandle /// A token to cancel the watch operation. /// An asynchronous enumerable of request updates. public abstract IAsyncEnumerable WatchUpdatesAsync(CancellationToken cancellationToken); + + /// + public void Dispose() + { + this.Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Disposes of the resources used by the class. + /// + /// A boolean indicating whether the method is being called from the method. + protected virtual void Dispose(bool disposing) { } } diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorRuntimeJsonUtilities.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorRuntimeJsonUtilities.cs new file mode 100644 index 0000000000..db0af21862 --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorRuntimeJsonUtilities.cs @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.Extensions.AI.Agents.Runtime; + +/// Provides a collection of utility methods for working with JSON data in the context of actor runtime abstractions. +public static partial class AgentRuntimeAbstractionsJsonUtilities +{ + /// + /// Gets the singleton used as the default in JSON serialization operations. + /// + /// + /// + /// For Native AOT or applications disabling , this instance + /// includes source generated contracts for all common exchange types contained in this library. + /// + /// + /// It additionally turns on the following settings: + /// + /// Enables defaults. + /// Enables as the default ignore condition for properties. + /// Enables as the default number handling for number types. + /// Enables for enum serialization. + /// + /// + /// + public static JsonSerializerOptions DefaultOptions { get; } = CreateDefaultOptions(); + + /// + /// Creates default options to use for actor runtime-related serialization. + /// + /// The configured options. + [UnconditionalSuppressMessage("ReflectionAnalysis", "IL3050:RequiresDynamicCode", Justification = "Converter is guarded by IsReflectionEnabledByDefault check.")] + [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access", Justification = "Converter is guarded by IsReflectionEnabledByDefault check.")] + private static JsonSerializerOptions CreateDefaultOptions() + { + // Copy the configuration from the source generated context. + JsonSerializerOptions options = new(JsonContext.Default.Options); + + options.MakeReadOnly(); + return options; + } + + /// + /// Source-generated JSON type information for use by all agent runtime abstractions. + /// + [JsonSourceGenerationOptions( + JsonSerializerDefaults.Web, + UseStringEnumConverter = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = false)] + [JsonSerializable(typeof(ActorId))] + [JsonSerializable(typeof(ActorMessage))] + [JsonSerializable(typeof(ActorReadOperation))] + [JsonSerializable(typeof(ActorReadOperationBatch))] + [JsonSerializable(typeof(ActorReadResult))] + [JsonSerializable(typeof(ActorRequest))] + [JsonSerializable(typeof(ActorRequestMessage))] + [JsonSerializable(typeof(ActorRequestUpdate))] + [JsonSerializable(typeof(ActorResponse))] + [JsonSerializable(typeof(ActorResponseMessage))] + [JsonSerializable(typeof(ActorType))] + [JsonSerializable(typeof(ActorWriteOperation))] + [JsonSerializable(typeof(ActorWriteOperationBatch))] + [JsonSerializable(typeof(GetValueOperation))] + [JsonSerializable(typeof(GetValueResult))] + [JsonSerializable(typeof(JsonElement))] + [JsonSerializable(typeof(ListKeysOperation))] + [JsonSerializable(typeof(ListKeysResult))] + [JsonSerializable(typeof(ReadResponse))] + [JsonSerializable(typeof(RemoveKeyOperation))] + [JsonSerializable(typeof(RequestStatus))] + [JsonSerializable(typeof(SendRequestOperation))] + [JsonSerializable(typeof(SetValueOperation))] + [JsonSerializable(typeof(UpdateRequestOperation))] + [JsonSerializable(typeof(WriteResponse))] + internal sealed partial class JsonContext : JsonSerializerContext; +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorType.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorType.cs index 619e018083..67585803b0 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorType.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorType.cs @@ -4,6 +4,7 @@ using System; using System.Text.Json; using System.Text.Json.Serialization; using System.Text.RegularExpressions; +using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.AI.Agents.Runtime; @@ -13,15 +14,21 @@ namespace Microsoft.Extensions.AI.Agents.Runtime; [JsonConverter(typeof(Converter))] public readonly partial struct ActorType : IEquatable { +#if NET7_0_OR_GREATER + [System.Diagnostics.CodeAnalysis.StringSyntax("Regex")] +#endif + private const string AgentTypeValidationRegex = "^[a-zA-Z_][a-zA-Z._:\\-0-9]*$"; + /// /// Initializes a new instance of the struct. /// /// The actor type. public ActorType(string type) { - if (!IsValid(type)) + Throw.IfNullOrEmpty(type); + if (!IsValidType(type)) { - throw new ArgumentException($"Invalid type: '{type}'. Must be alphanumeric (a-z, 0-9, _) and cannot start with a number or contain spaces."); + throw new ArgumentException($"Invalid type: '{type}'. Must start with a letter or underscore, and can only contain letters, dots, underscores, colons, hyphens, and numbers.", nameof(type)); } this.Name = type; @@ -59,16 +66,23 @@ public readonly partial struct ActorType : IEquatable public static bool operator !=(ActorType left, ActorType right) => !(left == right); - internal static bool IsValid(string type) => - type is not null && TypeRegex().IsMatch(type); - -#if NET - [GeneratedRegex("^[a-zA-Z_][a-zA-Z_:0-9]*$")] +#if NET7_0_OR_GREATER + [GeneratedRegex(AgentTypeValidationRegex)] private static partial Regex TypeRegex(); #else - private static Regex TypeRegex() => new("^[a-zA-Z_][a-zA-Z_:0-9:]*$", RegexOptions.Compiled); + private static readonly Regex s_typeRegex = new(AgentTypeValidationRegex, RegexOptions.Compiled); + private static Regex TypeRegex() => s_typeRegex; #endif + /// + /// Validates whether the provided type string is a valid actor type. + /// + public static bool IsValidType(string type) + { + Throw.IfNullOrEmpty(type); + return TypeRegex().IsMatch(type); + } + /// /// JSON converter for . /// diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/RequestStatusExtensions.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/RequestStatusExtensions.cs new file mode 100644 index 0000000000..5f985a7968 --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/RequestStatusExtensions.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Extensions.AI.Agents.Runtime; + +/// +/// Provides extension methods for the enumeration. +/// +public static class RequestStatusExtensions +{ + /// + /// Determines if the request status indicates that the request has terminated. + /// + /// The request status to check. + /// if the request has terminated; otherwise, . + public static bool IsTerminated(this RequestStatus status) => status != RequestStatus.Pending; +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB/CosmosActorStateJsonContext.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB/CosmosActorStateJsonContext.cs index e88dbe310e..3f12809e16 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB/CosmosActorStateJsonContext.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB/CosmosActorStateJsonContext.cs @@ -16,4 +16,6 @@ namespace Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB; [JsonSerializable(typeof(ActorStateDocument))] [JsonSerializable(typeof(ActorRootDocument))] [JsonSerializable(typeof(KeyProjection))] -internal sealed partial class CosmosActorStateJsonContext : JsonSerializerContext; +[JsonSerializable(typeof(KeyProjection[]))] +[JsonSerializable(typeof(JsonElement))] +public sealed partial class CosmosActorStateJsonContext : JsonSerializerContext; diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/ActorRuntimeJsonContext.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/ActorRuntimeJsonContext.cs deleted file mode 100644 index 865bea794e..0000000000 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/ActorRuntimeJsonContext.cs +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace Microsoft.Extensions.AI.Agents.Runtime; - -[JsonSourceGenerationOptions( - JsonSerializerDefaults.Web, - UseStringEnumConverter = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - WriteIndented = false)] -[JsonSerializable(typeof(string))] -internal sealed partial class ActorRuntimeJsonContext : JsonSerializerContext; diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/AgentRuntimeJsonUtilities.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/AgentRuntimeJsonUtilities.cs new file mode 100644 index 0000000000..9f1eb79c79 --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/AgentRuntimeJsonUtilities.cs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.Extensions.AI.Agents.Runtime; + +/// Provides a collection of utility methods for working with JSON data in the context of agent runtime. +public static partial class AgentRuntimeJsonUtilities +{ + /// + /// Gets the singleton used as the default in JSON serialization operations. + /// + /// + /// + /// For Native AOT or applications disabling , this instance + /// includes source generated contracts for all common exchange types contained in this library. + /// + /// + /// It additionally turns on the following settings: + /// + /// Enables defaults. + /// Enables as the default ignore condition for properties. + /// Enables as the default number handling for number types. + /// Enables for enum serialization. + /// + /// + /// + public static JsonSerializerOptions DefaultOptions { get; } = CreateDefaultOptions(); + + /// + /// Creates default options to use for agent runtime-related serialization. + /// + /// The configured options. + [UnconditionalSuppressMessage("ReflectionAnalysis", "IL3050:RequiresDynamicCode", Justification = "Converter is guarded by IsReflectionEnabledByDefault check.")] + [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access", Justification = "Converter is guarded by IsReflectionEnabledByDefault check.")] + private static JsonSerializerOptions CreateDefaultOptions() + { + // Copy the configuration from the source generated context. + JsonSerializerOptions options = new(JsonContext.Default.Options); + + // Chain with all supported types from Microsoft.Extensions.AI.Agents.Runtime.Abstractions. + options.TypeInfoResolverChain.Add(AgentRuntimeAbstractionsJsonUtilities.DefaultOptions.TypeInfoResolver!); + + options.MakeReadOnly(); + return options; + } + + [JsonSourceGenerationOptions( + JsonSerializerDefaults.Web, + UseStringEnumConverter = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = false)] + [JsonSerializable(typeof(string))] + internal sealed partial class JsonContext : JsonSerializerContext; +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/InProcessActorContext.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/InProcessActorContext.cs index ad10dd07e6..317e73ef22 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/InProcessActorContext.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/InProcessActorContext.cs @@ -100,6 +100,21 @@ internal sealed class InProcessActorContext : IActorRuntimeContext, IAsyncDispos } } + public bool TryGetResponseHandle(string messageId, [NotNullWhen(true)] out ActorResponseHandle? handle) + { + lock (this._lock) + { + if (!this._inbox.TryGetValue(messageId, out var entry)) + { + handle = null; + return false; + } + + handle = new InProcessActorResponseHandle(this, entry); + return true; + } + } + public ActorResponseHandle SendRequest(ActorRequest request) { using var activity = ActivitySource.StartActivity( @@ -133,7 +148,9 @@ internal sealed class InProcessActorContext : IActorRuntimeContext, IAsyncDispos requestStatus = "found"; } +#pragma warning disable CA2000 // Dispose objects before losing scope var handle = new InProcessActorResponseHandle(this, entry); +#pragma warning restore CA2000 // Dispose objects before losing scope Log.ResponseHandleCreated(this._logger, this.ActorId.ToString(), request.MessageId); activity.Complete(RequestCompleted, this.ActorId, [(Tel.Request.Status, requestStatus), (Tel.Response.Status, HandleCreated)], @@ -243,19 +260,11 @@ internal sealed class InProcessActorContext : IActorRuntimeContext, IAsyncDispos [.. operations.Operations.OfType()]; WriteResponse result; - if (writeOps.Count == 0) - { - // Nothing to write - result = new WriteResponse(operations.ETag, success: true); - } - else - { - result = await this.Storage.WriteStateAsync( - this.ActorId, - writeOps, - operations.ETag, - cancellationToken).ConfigureAwait(false); - } + result = await this.Storage.WriteStateAsync( + this.ActorId, + writeOps, + operations.ETag, + cancellationToken).ConfigureAwait(false); Log.WriteOperationCompleted(this._logger, this.ActorId.ToString(), result.Success); @@ -396,7 +405,7 @@ internal sealed class InProcessActorContext : IActorRuntimeContext, IAsyncDispos { ActorId = context.ActorId, MessageId = entry.Request.MessageId, - Data = JsonSerializer.SerializeToElement($"Error: {exception.Message}", ActorRuntimeJsonContext.Default.String), + Data = JsonSerializer.SerializeToElement($"Error: {exception.Message}", AgentRuntimeJsonUtilities.JsonContext.Default.String), Status = RequestStatus.Failed, }; } @@ -433,6 +442,13 @@ internal sealed class InProcessActorContext : IActorRuntimeContext, IAsyncDispos { yield return new ActorRequestUpdate(update.Status, update.Data); } + + var response = await entry.Response +#if NET8_0_OR_GREATER + .WaitAsync(cancellationToken) +#endif + .ConfigureAwait(false); + yield return new ActorRequestUpdate(response.Status, response.Data); } } } diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/InProcessActorRuntime.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/InProcessActorRuntime.cs index 8609799a47..60fe298c0d 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/InProcessActorRuntime.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/InProcessActorRuntime.cs @@ -162,7 +162,17 @@ internal sealed class InProcessActorClient(InProcessActorRuntime runtime) : IAct activity.SetupRequestOperation(actorId, messageId, service: "ActorClient", rpcMethod: "GetResponse"); - throw new NotImplementedException("GetResponseAsync is not yet implemented"); + var actorContext = this._runtime.GetOrCreateActor(actorId); +#pragma warning disable CA2000 // Dispose objects before losing scope + if (actorContext.TryGetResponseHandle(messageId, out var handle)) + { + return new(handle); + } +#pragma warning restore CA2000 // Dispose objects before losing scope + +#pragma warning disable CA2000 // Dispose objects before losing scope + return new(new NotFoundActorResponseHandle(actorId, messageId)); +#pragma warning restore CA2000 // Dispose objects before losing scope } public ValueTask SendRequestAsync(ActorRequest request, CancellationToken cancellationToken) @@ -180,7 +190,9 @@ internal sealed class InProcessActorClient(InProcessActorRuntime runtime) : IAct // 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); +#pragma warning disable CA2000 // Dispose objects before losing scope var response = actorContext.SendRequest(request); +#pragma warning restore CA2000 // Dispose objects before losing scope activity.Complete(MessageSent, actorId, Sent, (Tel.Message.Id, request.MessageId)); diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/NotFoundActorResponseHandle.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/NotFoundActorResponseHandle.cs new file mode 100644 index 0000000000..d3a7f0e5f0 --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/NotFoundActorResponseHandle.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.AI.Agents.Runtime; + +internal sealed class NotFoundActorResponseHandle : ActorResponseHandle +{ + private readonly ActorResponse _response; + + public NotFoundActorResponseHandle(ActorId actorId, string messageId) + { + this._response = new ActorResponse() + { + Status = RequestStatus.NotFound, + ActorId = actorId, + MessageId = messageId, + }; + } + + public override ValueTask CancelAsync(CancellationToken cancellationToken) + { + throw new InvalidOperationException( + $"Failed to cancel request for actor '{this._response.ActorId}' with message ID '{this._response.MessageId}'. The request was not found."); + } + + public override ValueTask GetResponseAsync(CancellationToken cancellationToken) + { + return new ValueTask(this._response); + } + + public override bool TryGetResponse([NotNullWhen(true)] out ActorResponse? response) + { + response = this._response; + return true; + } + + public override async IAsyncEnumerable WatchUpdatesAsync([EnumeratorCancellation] CancellationToken cancellationToken) + { + yield break; + } +} diff --git a/dotnet/tests/CosmosDB.IntegrationTests/Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB.Tests/CosmosTestFixture.cs b/dotnet/tests/CosmosDB.IntegrationTests/Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB.Tests/CosmosTestFixture.cs index 09c0b5b6ea..c43ce25cbd 100644 --- a/dotnet/tests/CosmosDB.IntegrationTests/Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB.Tests/CosmosTestFixture.cs +++ b/dotnet/tests/CosmosDB.IntegrationTests/Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB.Tests/CosmosTestFixture.cs @@ -53,6 +53,7 @@ public class CosmosTestFixture : IAsyncLifetime UseSystemTextJsonSerializerWithOptions = new JsonSerializerOptions() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + TypeInfoResolver = CosmosActorStateJsonContext.Default } }; diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.Hosting.UnitTests/AgentActorTests.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.Hosting.UnitTests/AgentActorTests.cs new file mode 100644 index 0000000000..d4d8901c57 --- /dev/null +++ b/dotnet/tests/Microsoft.Extensions.AI.Agents.Hosting.UnitTests/AgentActorTests.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using Microsoft.Extensions.AI.Agents.Runtime; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace Microsoft.Extensions.AI.Agents.Hosting.UnitTests; + +/// +/// Unit tests for . +/// +public class AgentActorTests +{ + /// + /// Verifies that calling DisposeAsync completes successfully without throwing an exception. + /// + [Fact] + public async Task DisposeAsync_NoException_CompletesSuccessfullyAsync() + { + // Arrange + var mockAgent = new Mock(); + var mockContext = new Mock(); + var mockLogger = NullLoggerFactory.Instance.CreateLogger(); + var actor = new AgentActor(mockAgent.Object, mockContext.Object, mockLogger); + + // Act + var valueTask = actor.DisposeAsync(); + + // Assert + Assert.True(valueTask.IsCompleted, "DisposeAsync should return a completed ValueTask."); + await valueTask; + } +} diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.Hosting.UnitTests/AgentProxyTests.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.Hosting.UnitTests/AgentProxyTests.cs new file mode 100644 index 0000000000..17b3274e1d --- /dev/null +++ b/dotnet/tests/Microsoft.Extensions.AI.Agents.Hosting.UnitTests/AgentProxyTests.cs @@ -0,0 +1,758 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI.Agents.Runtime; +using Moq; + +namespace Microsoft.Extensions.AI.Agents.Hosting.UnitTests; + +/// +/// Tests for the constructor. +/// +public class AgentProxyTests +{ + /// + /// Verifies that the constructor assigns the Name property correctly for various valid agent names. + /// + [Theory] + [InlineData("agent")] + [InlineData(" ")] + [InlineData("特殊字符")] + [InlineData(" a")] + public void Constructor_ValidName_SetsNameProperty(string name) + { + // Arrange + var mockClient = new Mock(); + + // Act + var proxy = new AgentProxy(name, mockClient.Object); + + // Assert + Assert.Equal(name, proxy.Name); + } + + /// + /// Verifies that GetNewThread returns a non-null instance. + /// + [Fact] + public void GetNewThread_WhenCalled_ReturnsNewAgentProxyThreadInstance() + { + // Arrange + var mockClient = new Mock(); + var proxy = new AgentProxy("agentName", mockClient.Object); + + // Act + AgentThread result = proxy.GetNewThread(); + + // Assert + Assert.NotNull(result); + Assert.IsType(result); + } + + /// + /// Verifies that consecutive calls to GetNewThread return distinct instances. + /// + [Fact] + public void GetNewThread_MultipleCalls_ReturnsDistinctInstances() + { + // Arrange + var mockClient = new Mock(); + var proxy = new AgentProxy("agentName", mockClient.Object); + + // Act + AgentThread first = proxy.GetNewThread(); + AgentThread second = proxy.GetNewThread(); + + // Assert + Assert.NotNull(first); + Assert.NotNull(second); + Assert.NotSame(first, second); + } + private const string AgentName = "agentName"; + private const string ThreadId = "thread1"; + private static readonly IReadOnlyCollection s_emptyMessages = new List(); + + private static bool IsValidGuid(string value) + { + return Guid.TryParse(value, out _); + } + + /// + /// Verifies that RunAsync returns a deserialized AgentRunResponse when the actor response status is Completed. + /// Input: empty messages, threadId, Completed status with empty JSON object. + /// Expected: AgentRunResponse with no messages. + /// + [Fact] + public async Task RunAsync_WhenStatusIsCompleted_ReturnsDeserializedResponseAsync() + { + // Arrange + var mockClient = new Mock(); + var mockHandle = new Mock(); + var jsonElement = JsonDocument.Parse("{}").RootElement; + var actorResponse = new ActorResponse + { + ActorId = new ActorId(AgentName, ThreadId), + MessageId = "msg1", + Data = jsonElement, + Status = RequestStatus.Completed + }; + mockHandle + .Setup(h => h.GetResponseAsync(It.IsAny())) + .Returns(new ValueTask(actorResponse)); + mockClient + .Setup(c => c.SendRequestAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(mockHandle.Object); + + var proxy = new AgentProxy(AgentName, mockClient.Object); + var thread = proxy.GetThread(ThreadId); + + // Act + var result = await proxy.RunAsync(s_emptyMessages, thread); + + // Assert + Assert.NotNull(result); + Assert.Empty(result.Messages); + } + + /// + /// Verifies that RunAsync throws an InvalidOperationException when the actor response status is Failed. + /// Input: empty messages, threadId, Failed status. + /// Expected: InvalidOperationException with message containing the response data. + /// + [Fact] + public async Task RunAsync_WhenStatusIsFailed_ThrowsInvalidOperationExceptionAsync() + { + // Arrange + var mockClient = new Mock(); + var mockHandle = new Mock(); + var jsonElement = JsonDocument.Parse("{}").RootElement; + var actorResponse = new ActorResponse + { + ActorId = new ActorId(AgentName, ThreadId), + MessageId = "msg1", + Data = jsonElement, + Status = RequestStatus.Failed + }; + mockHandle + .Setup(h => h.GetResponseAsync(It.IsAny())) + .Returns(new ValueTask(actorResponse)); + mockClient + .Setup(c => c.SendRequestAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(mockHandle.Object); + + var proxy = new AgentProxy(AgentName, mockClient.Object); + var thread = proxy.GetThread(ThreadId); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + proxy.RunAsync(s_emptyMessages, thread)); + Assert.Equal("The agent run request failed: {}", exception.Message); + } + + /// + /// Verifies that RunAsync throws an InvalidOperationException when the actor response status is Pending. + /// Input: empty messages, threadId, Pending status. + /// Expected: InvalidOperationException with pending message. + /// + [Fact] + public async Task RunAsync_WhenStatusIsPending_ThrowsInvalidOperationExceptionAsync() + { + // Arrange + var mockClient = new Mock(); + var mockHandle = new Mock(); + var jsonElement = JsonDocument.Parse("{}").RootElement; + var actorResponse = new ActorResponse + { + ActorId = new ActorId(AgentName, ThreadId), + MessageId = "msg1", + Data = jsonElement, + Status = RequestStatus.Pending + }; + mockHandle + .Setup(h => h.GetResponseAsync(It.IsAny())) + .Returns(new ValueTask(actorResponse)); + mockClient + .Setup(c => c.SendRequestAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(mockHandle.Object); + + var proxy = new AgentProxy(AgentName, mockClient.Object); + var thread = proxy.GetThread(ThreadId); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + proxy.RunAsync(s_emptyMessages, thread)); + Assert.Equal("The agent run request is still pending.", exception.Message); + } + + /// + /// Verifies that RunAsync throws a NotSupportedException when the actor response status is unsupported. + /// Input: empty messages, threadId, NotFound status. + /// Expected: NotSupportedException with unsupported status message. + /// + [Fact] + public async Task RunAsync_WhenStatusIsUnsupported_ThrowsNotSupportedExceptionAsync() + { + // Arrange + var mockClient = new Mock(); + var mockHandle = new Mock(); + var jsonElement = JsonDocument.Parse("{}").RootElement; + var actorResponse = new ActorResponse + { + ActorId = new ActorId(AgentName, ThreadId), + MessageId = "msg1", + Data = jsonElement, + Status = RequestStatus.NotFound + }; + mockHandle + .Setup(h => h.GetResponseAsync(It.IsAny())) + .Returns(new ValueTask(actorResponse)); + mockClient + .Setup(c => c.SendRequestAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(mockHandle.Object); + + var proxy = new AgentProxy(AgentName, mockClient.Object); + var thread = proxy.GetThread(ThreadId); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + proxy.RunAsync(s_emptyMessages, thread)); + Assert.Equal($"The agent run request returned an unsupported status: {RequestStatus.NotFound}.", exception.Message); + } + + /// + /// Verifies that passing an AgentThread that is not an AgentProxyThread to RunStreamingAsync throws an ArgumentException. + /// + [Fact] + public async System.Threading.Tasks.Task RunStreamingAsync_InvalidThread_ThrowsArgumentExceptionAsync() + { + // Arrange + var mockClient = new Mock(); + var proxy = new AgentProxy("testAgent", mockClient.Object); + AgentThread invalidThread = new Mock().Object; + + // Act & Assert + await Assert.ThrowsAsync(async () => + { + await foreach (var _ in proxy.RunStreamingAsync(Array.Empty(), invalidThread, cancellationToken: CancellationToken.None)) + { + } + }); + } + + /// + /// This test verifies that RunStreamingAsync completes without throwing when a valid AgentProxyThread is used. + /// TODO: Mock IActorClient.SendRequestAsync to return an ActorResponseHandle whose WatchUpdatesAsync yields no updates. + /// + [Fact(Skip = "Mocking of ActorResponseHandle.WatchUpdatesAsync with IActorClient is required")] + public async System.Threading.Tasks.Task RunStreamingAsync_ValidProxyThread_CompletesSuccessfullyAsync() + { + // Arrange + var mockClient = new Mock(); + var proxy = new AgentProxy("testAgent", mockClient.Object); + var proxyThread = new AgentProxyThread(); + + // Act & Assert + await foreach (var _ in proxy.RunStreamingAsync(Array.Empty(), proxyThread, cancellationToken: CancellationToken.None)) + { + // No items expected + } + } + + /// + /// Verifies that RunStreamingAsync yields AgentRunResponseUpdate for non-failed statuses. + /// This test uses a mock IActorClient to return an ActorResponseHandle that yields a single update with given status. + /// Expected: The method yields the deserialized update. + /// + [Theory] + [InlineData(RequestStatus.Completed)] + [InlineData(RequestStatus.Pending)] + public async Task RunStreamingAsync_NonFailedStatus_YieldsAgentRunResponseUpdateAsync(RequestStatus status) + { + // Arrange + var messages = Array.Empty(); + var threadId = "thread1"; + var expectedUpdate = new AgentRunResponseUpdate(ChatRole.Assistant, "response"); + + JsonElement jsonElement; + if (status == RequestStatus.Completed) + { + // For Completed status, the implementation expects AgentRunResponse + var agentRunResponse = new AgentRunResponse + { + Messages = new List { new(ChatRole.Assistant, "response") } + }; + var responseTypeInfo = AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AgentRunResponse)); + jsonElement = JsonSerializer.SerializeToElement(agentRunResponse, responseTypeInfo); + } + else + { + // For Pending status, the implementation expects AgentRunResponseUpdate + var updateTypeInfo = AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AgentRunResponseUpdate)); + jsonElement = JsonSerializer.SerializeToElement(expectedUpdate, updateTypeInfo); + } + + var actorUpdate = new ActorRequestUpdate(status, jsonElement); + var mockHandle = new Mock(); + mockHandle + .Setup(h => h.WatchUpdatesAsync(It.IsAny())) + .Returns(GetAsyncEnumerableAsync(actorUpdate)); + var mockClient = new Mock(); + mockClient + .Setup(c => c.SendRequestAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(mockHandle.Object); + + var proxy = new AgentProxy("agentName", mockClient.Object); + var thread = proxy.GetThread(threadId); + + // Act + var results = new List(); + await foreach (var update in proxy.RunStreamingAsync(messages, thread)) + { + results.Add(update); + } + + // Assert + Assert.Single(results); + Assert.Equal(expectedUpdate.Text, results[0].Text); + Assert.Equal(expectedUpdate.Role, results[0].Role); + } + + private static async IAsyncEnumerable GetAsyncEnumerableAsync(ActorRequestUpdate update) + { + yield return update; + await Task.CompletedTask; + } + + /// + /// Verifies that RunStreamingAsync throws InvalidOperationException when an update status is Failed. + /// Uses a mock IActorClient to return a Failed update. Expected: InvalidOperationException is thrown. + /// + [Fact] + public async Task RunStreamingAsync_FailedStatus_ThrowsInvalidOperationExceptionAsync() + { + // Arrange + var messages = Array.Empty(); + var threadId = "thread1"; + var expectedUpdate = new AgentRunResponseUpdate(ChatRole.Assistant, "response"); + var updateTypeInfo = AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AgentRunResponseUpdate)); + var jsonElement = JsonSerializer.SerializeToElement(expectedUpdate, updateTypeInfo); + + var actorUpdate = new ActorRequestUpdate(RequestStatus.Failed, jsonElement); + var mockHandle = new Mock(); + mockHandle + .Setup(h => h.WatchUpdatesAsync(It.IsAny())) + .Returns(GetAsyncEnumerableAsync(actorUpdate)); + var mockClient = new Mock(); + mockClient + .Setup(c => c.SendRequestAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(mockHandle.Object); + + var proxy = new AgentProxy("agentName", mockClient.Object); + var thread = proxy.GetThread(threadId); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + { + await foreach (var update in proxy.RunStreamingAsync(messages, thread)) + { + // force enumeration + } + }); + Assert.Contains("The agent run request failed", exception.Message); + } + + /// + /// Verifies that constructor throws ArgumentNullException when client is null. + /// + [Fact] + public void Constructor_NullClient_ThrowsArgumentNullException() + { + // Act & Assert + Assert.Throws(() => new AgentProxy("agentName", null!)); + } + + /// + /// Verifies that constructor throws ArgumentNullException when name is null. + /// + [Fact] + public void Constructor_NullName_ThrowsArgumentNullException() + { + // Arrange + var mockClient = new Mock(); + + // Act & Assert + Assert.Throws(() => new AgentProxy(null!, mockClient.Object)); + } + + /// + /// Verifies that constructor throws ArgumentException when name is empty. + /// + [Fact] + public void Constructor_EmptyName_ThrowsArgumentException() + { + // Arrange + var mockClient = new Mock(); + + // Act & Assert + Assert.Throws(() => new AgentProxy("", mockClient.Object)); + } + + /// + /// Verifies that RunAsync with thread overload validates null messages. + /// + [Fact] + public async Task RunAsync_WithThread_NullMessages_ThrowsArgumentNullExceptionAsync() + { + // Arrange + var mockClient = new Mock(); + var proxy = new AgentProxy("agentName", mockClient.Object); + var thread = new AgentProxyThread(); + + // Act & Assert + await Assert.ThrowsAsync(() => + proxy.RunAsync(messages: null!, thread, null, CancellationToken.None)); + } + + /// + /// Verifies that RunAsync with thread overload throws for invalid thread type. + /// + [Fact] + public async Task RunAsync_WithInvalidThreadType_ThrowsArgumentExceptionAsync() + { + // Arrange + var mockClient = new Mock(); + var proxy = new AgentProxy("agentName", mockClient.Object); + var invalidThread = new Mock().Object; + var messages = new List { new(ChatRole.User, "test") }; + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + proxy.RunAsync(messages, invalidThread, null, CancellationToken.None)); + Assert.Contains("thread must be an instance of AgentProxyThread", exception.Message); + } + + /// + /// Verifies that RunAsync with thread overload creates new thread ID when thread is null. + /// + [Fact] + public async Task RunAsync_WithNullThread_CreatesNewThreadIdAsync() + { + // Arrange + var mockClient = new Mock(); + var mockHandle = new Mock(); + var response = new AgentRunResponse { Messages = [] }; + var jsonElement = JsonSerializer.SerializeToElement(response, + AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AgentRunResponse))); + var actorResponse = new ActorResponse + { + ActorId = new ActorId(AgentName, ThreadId), + MessageId = "msg1", + Data = jsonElement, + Status = RequestStatus.Completed + }; + + mockHandle.Setup(h => h.GetResponseAsync(It.IsAny())) + .Returns(new ValueTask(actorResponse)); + + mockClient.Setup(c => c.SendRequestAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(mockHandle.Object); + + var proxy = new AgentProxy("agentName", mockClient.Object); + var messages = new List { new(ChatRole.User, "test") }; + + // Act + var result = await proxy.RunAsync(messages, thread: null, options: null, CancellationToken.None); + + // Assert + Assert.NotNull(result); + mockClient.Verify(c => c.SendRequestAsync( + It.Is(r => !string.IsNullOrEmpty(r.ActorId.Key)), + It.IsAny()), Times.Once); + } + + /// + /// Verifies that RunAsync handles cancellation properly. + /// + [Fact] + public async Task RunAsync_CancellationRequested_ThrowsOperationCanceledExceptionAsync() + { + // Arrange + var mockClient = new Mock(); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + mockClient.Setup(c => c.SendRequestAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new OperationCanceledException()); + + var proxy = new AgentProxy("agentName", mockClient.Object); + var messages = new List { new(ChatRole.User, "test") }; + var thread = proxy.GetThread(ThreadId); + + // Act & Assert + await Assert.ThrowsAsync(() => + proxy.RunAsync(messages, thread, cancellationToken: cts.Token)); + } + + /// + /// Verifies that RunStreamingAsync with thread overload validates null messages. + /// + [Fact] + public async Task RunStreamingAsync_WithThread_NullMessages_ThrowsArgumentNullExceptionAsync() + { + // Arrange + var mockClient = new Mock(); + var proxy = new AgentProxy("agentName", mockClient.Object); + var thread = new AgentProxyThread(); + + // Act & Assert + await Assert.ThrowsAsync(async () => + { + await foreach (var _ in proxy.RunStreamingAsync(messages: null!, thread, null, CancellationToken.None)) + { + // force enumeration + } + }); + } + + /// + /// Verifies that RunStreamingAsync with thread overload throws for invalid thread type. + /// + [Fact] + public async Task RunStreamingAsync_WithInvalidThreadType_ThrowsArgumentExceptionAsync() + { + // Arrange + var mockClient = new Mock(); + var proxy = new AgentProxy("agentName", mockClient.Object); + var invalidThread = new Mock().Object; + var messages = new List { new(ChatRole.User, "test") }; + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + { + await foreach (var _ in proxy.RunStreamingAsync(messages, invalidThread, null, CancellationToken.None)) + { + // force enumeration + } + }); + Assert.Contains("thread must be an instance of AgentProxyThread", exception.Message); + } + + /// + /// Verifies that RunStreamingAsync handles cancellation during enumeration. + /// + [Fact] + public async Task RunStreamingAsync_CancellationDuringEnumeration_StopsEnumerationAsync() + { + // Arrange + var mockClient = new Mock(); + using var cts = new CancellationTokenSource(); + + var updates = new List + { + new(RequestStatus.Pending, JsonSerializer.SerializeToElement( + new AgentRunResponseUpdate(ChatRole.Assistant, "1"), + AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AgentRunResponseUpdate)))), + new(RequestStatus.Pending, JsonSerializer.SerializeToElement( + new AgentRunResponseUpdate(ChatRole.Assistant, "2"), + AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AgentRunResponseUpdate)))) + }; + + using var fakeHandle = new FakeActorResponseHandle(updates, cts); + + mockClient.Setup(c => c.SendRequestAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(fakeHandle); + + var proxy = new AgentProxy("agentName", mockClient.Object); + var thread = proxy.GetThread(ThreadId); + var messages = new List { new(ChatRole.User, "test") }; + + // Act + var receivedUpdates = new List(); + await Assert.ThrowsAnyAsync(async () => + { + await foreach (var update in proxy.RunStreamingAsync(messages, thread, cancellationToken: cts.Token)) + { + receivedUpdates.Add(update); + } + }); + + // Assert + Assert.Single(receivedUpdates); // Only first update should be received + } + + /// + /// Verifies that RunAsync correctly uses message ID from last message if available. + /// + [Fact] + public async Task RunAsync_UsesLastMessageId_WhenAvailableAsync() + { + // Arrange + var mockClient = new Mock(); + var mockHandle = new Mock(); + var expectedMessageId = "custom-message-id"; + var response = new AgentRunResponse { Messages = [] }; + var jsonElement = JsonSerializer.SerializeToElement(response, + AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AgentRunResponse))); + var actorResponse = new ActorResponse + { + ActorId = new ActorId(AgentName, ThreadId), + MessageId = expectedMessageId, + Data = jsonElement, + Status = RequestStatus.Completed + }; + + mockHandle.Setup(h => h.GetResponseAsync(It.IsAny())) + .Returns(new ValueTask(actorResponse)); + + mockClient.Setup(c => c.SendRequestAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(mockHandle.Object); + + var proxy = new AgentProxy("agentName", mockClient.Object); + var thread = proxy.GetThread(ThreadId); + var messages = new List + { + new(ChatRole.User, "first"), + new(ChatRole.User, "last") { MessageId = expectedMessageId } + }; + + // Act + await proxy.RunAsync(messages, thread); + + // Assert + mockClient.Verify(c => c.SendRequestAsync( + It.Is(r => r.MessageId == expectedMessageId), + It.IsAny()), Times.Once); + } + + /// + /// Verifies that RunAsync generates new message ID when last message has no ID. + /// + [Fact] + public async Task RunAsync_GeneratesMessageId_WhenLastMessageHasNoIdAsync() + { + // Arrange + var mockClient = new Mock(); + var mockHandle = new Mock(); + var response = new AgentRunResponse { Messages = [] }; + var jsonElement = JsonSerializer.SerializeToElement(response, + AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AgentRunResponse))); + var actorResponse = new ActorResponse + { + ActorId = new ActorId(AgentName, ThreadId), + MessageId = "generated-id", + Data = jsonElement, + Status = RequestStatus.Completed + }; + + mockHandle.Setup(h => h.GetResponseAsync(It.IsAny())) + .Returns(new ValueTask(actorResponse)); + + mockClient.Setup(c => c.SendRequestAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(mockHandle.Object); + + var proxy = new AgentProxy("agentName", mockClient.Object); + var thread = proxy.GetThread(ThreadId); + var messages = new List { new(ChatRole.User, "test") }; + + // Act + await proxy.RunAsync(messages, thread); + + // Assert + mockClient.Verify(c => c.SendRequestAsync( + It.Is(r => !string.IsNullOrEmpty(r.MessageId) && IsValidGuid(r.MessageId)), + It.IsAny()), Times.Once); + } + + /// + /// Verifies that GetNewThread returns unique instances with unique IDs. + /// + [Fact] + public void GetNewThread_MultipleCalls_ReturnsUniqueThreadsWithUniqueIds() + { + // Arrange + var mockClient = new Mock(); + var proxy = new AgentProxy("agentName", mockClient.Object); + var threads = new List(); + + // Act + for (int i = 0; i < 10; i++) + { + threads.Add(proxy.GetNewThread()); + } + + // Assert + var threadIds = threads.Cast().Select(t => t.ConversationId).ToList(); + Assert.Equal(10, threadIds.Count); + Assert.Equal(10, threadIds.Distinct().Count()); // All IDs should be unique + } + + /// + /// Fake implementation of ActorResponseHandle for testing purposes. + /// + private sealed class FakeActorResponseHandle : ActorResponseHandle + { + private readonly List _updates; + private readonly CancellationTokenSource _cancellationTokenSource; + private readonly ActorResponse? _response; + private readonly int _delayBetweenUpdates; + + public FakeActorResponseHandle( + List updates, + CancellationTokenSource cancellationTokenSource, + ActorResponse? response = null, + int delayBetweenUpdates = 10) + { + this._updates = updates; + this._cancellationTokenSource = cancellationTokenSource; + this._response = response; + this._delayBetweenUpdates = delayBetweenUpdates; + } + + public override bool TryGetResponse([System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out ActorResponse? response) + { + response = this._response; + return this._response != null; + } + + public override ValueTask GetResponseAsync(CancellationToken cancellationToken) + { + if (this._response == null) + { + throw new InvalidOperationException("No response configured"); + } + return new ValueTask(this._response); + } + + public override ValueTask CancelAsync(CancellationToken cancellationToken) + { + this._cancellationTokenSource.Cancel(); + return default; + } + + public override async IAsyncEnumerable WatchUpdatesAsync([System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + for (int i = 0; i < this._updates.Count; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + + yield return this._updates[i]; + + // Cancel after the first update + if (i == 0) + { + this._cancellationTokenSource.Cancel(); + } + + if (i < this._updates.Count - 1) // Don't delay after the last update + { + await Task.Delay(this._delayBetweenUpdates, cancellationToken); + } + } + } + } +} diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.Hosting.UnitTests/AgentProxyThreadTests.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.Hosting.UnitTests/AgentProxyThreadTests.cs new file mode 100644 index 0000000000..a765f45f83 --- /dev/null +++ b/dotnet/tests/Microsoft.Extensions.AI.Agents.Hosting.UnitTests/AgentProxyThreadTests.cs @@ -0,0 +1,305 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.AI.Agents.Hosting.UnitTests; + +public class AgentProxyThreadTests +{ + /// + /// Provides valid identifier values that conform to RFC 3986 unreserved characters. + /// + public static IEnumerable ValidIds => new List + { + new object[] { "normal" }, + new object[] { "test-id" }, + new object[] { "test_id" }, + new object[] { "test.id" }, + new object[] { "test~id" }, + new object[] { "ABC123" }, + new object[] { "a" }, + new object[] { "123" }, + new object[] { "test-id_with.various~chars" }, + new object[] { new string('a', 100) } // Long but valid ID + }; + + /// + /// Provides invalid identifier values that violate the RFC 3986 unreserved character rules. + /// + public static IEnumerable InvalidIds => new List + { + new object[] { " " }, // Space not allowed + new object[] { "!@#$%^&*()" }, // Special characters not allowed + new object[] { "test id" }, // Space not allowed + new object[] { "test/id" }, // Forward slash not allowed + new object[] { "test?id" }, // Question mark not allowed + new object[] { "test#id" }, // Hash not allowed + new object[] { "test@id" }, // At symbol not allowed + new object[] { "test id with spaces" }, // Multiple spaces not allowed + new object[] { "test\tid" }, // Tab not allowed + new object[] { "test\nid" }, // Newline not allowed + }; + + /// + /// Verifies that providing valid id to constructor sets the Id property correctly. + /// + /// The valid identifier to test. + [Theory] + [MemberData(nameof(ValidIds))] + public void Constructor_ValidId_SetsIdProperty(string id) + { + // Act + var thread = new AgentProxyThread(id); + + // Assert + Assert.Equal(id, thread.ConversationId); + } + + /// + /// Verifies that providing invalid id to constructor throws an . + /// + /// The invalid identifier to test. + [Theory] + [MemberData(nameof(InvalidIds))] + public void Constructor_InvalidId_ThrowsArgumentException(string id) + { + // Act & Assert + var exception = Assert.Throws(() => new AgentProxyThread(id)); + Assert.Contains("Thread ID", exception.Message); + Assert.Contains("alphanumeric characters, hyphens, underscores, dots, and tildes", exception.Message); + } + + /// + /// Verifies that providing a null id to constructor throws an . + /// + [Fact] + public void Constructor_NullId_ThrowsArgumentNullException() + { + // Act & Assert + Assert.Throws(() => new AgentProxyThread(null!)); + } + + /// + /// Verifies that providing an empty id to constructor throws an . + /// + [Fact] + public void Constructor_EmptyId_ThrowsArgumentException() + { + // Act & Assert + Assert.Throws(() => new AgentProxyThread("")); + } + + /// + /// Verifies that the default constructor initializes the Id property with a valid non-empty GUID string in "N" format. + /// + [Fact] + public void Constructor_Default_AssignsValidGuidStringAsId() + { + // Arrange & Act + var thread = new AgentProxyThread(); + + // Assert + Assert.False(string.IsNullOrEmpty(thread.ConversationId)); + Assert.True(Guid.TryParseExact(thread.ConversationId, "N", out _), $"Id '{thread.ConversationId}' is not a valid GUID in 'N' format."); + } + + /// + /// Verifies that successive default constructors produce unique Id values. + /// + [Fact] + public void Constructor_Default_CreatesUniqueIds() + { + // Arrange & Act + var thread1 = new AgentProxyThread(); + var thread2 = new AgentProxyThread(); + + // Assert + Assert.NotEqual(thread1.ConversationId, thread2.ConversationId); + } + + /// + /// Verifies that CreateId returns a non-null, non-empty 32-character hexadecimal string without dashes. + /// + [Fact] + public void CreateId_ReturnsValidHexString() + { + // Arrange & Act + string id = AgentProxyThread.CreateId(); + + // Assert + Assert.False(string.IsNullOrEmpty(id)); + Assert.Equal(32, id.Length); + Assert.Matches("^[0-9a-f]{32}$", id); + } + + /// + /// Verifies that multiple calls to CreateId produce unique identifiers. + /// + [Fact] + public void CreateId_MultipleCalls_ReturnUniqueValues() + { + // Arrange & Act + string id1 = AgentProxyThread.CreateId(); + string id2 = AgentProxyThread.CreateId(); + + // Assert + Assert.NotEqual(id1, id2); + } + + /// + /// Verifies that ManyCallsInParallel produces unique values across many calls. + /// + [Fact] + public void CreateId_ManyCallsInParallel_AllUnique() + { + // Arrange + const int NumberOfIds = 1000; + var ids = new string[NumberOfIds]; + + // Act - Create IDs in parallel to test thread safety + Parallel.For(0, NumberOfIds, i => + { + ids[i] = AgentProxyThread.CreateId(); + }); + + // Assert + var uniqueIds = ids.Distinct().Count(); + Assert.Equal(NumberOfIds, uniqueIds); + } + + /// + /// Verifies that CreateId generates IDs that pass validation. + /// + [Fact] + public void CreateId_GeneratesValidIds() + { + // Arrange & Act + for (int i = 0; i < 100; i++) + { + string id = AgentProxyThread.CreateId(); + + // Assert - Should not throw exception + var thread = new AgentProxyThread(id); + Assert.Equal(id, thread.ConversationId); + } + } + + /// + /// Verifies specific edge cases for valid IDs. + /// + [Theory] + [InlineData("a")] + [InlineData("1")] + [InlineData("_")] + [InlineData("-")] + [InlineData(".")] + [InlineData("~")] + [InlineData("a1")] + [InlineData("test-123")] + [InlineData("my_thread.id~1")] + public void Constructor_ValidIdEdgeCases_SetsIdProperty(string id) + { + // Act + var thread = new AgentProxyThread(id); + + // Assert + Assert.Equal(id, thread.ConversationId); + } + + /// + /// Verifies specific edge cases for invalid IDs. + /// + [Theory] + [InlineData(" leading-space")] + [InlineData("trailing-space ")] + [InlineData("with spaces")] + [InlineData("with\ttab")] + [InlineData("with\nnewline")] + [InlineData("with/slash")] + [InlineData("with\\backslash")] + [InlineData("with%percent")] + [InlineData("with+plus")] + [InlineData("with=equals")] + [InlineData("with?question")] + [InlineData("with#hash")] + [InlineData("with@at")] + [InlineData("with[bracket")] + [InlineData("with]bracket")] + [InlineData("with{brace")] + [InlineData("with}brace")] + [InlineData("with(paren")] + [InlineData("with)paren")] + [InlineData("with!exclamation")] + [InlineData("with*asterisk")] + [InlineData("with:colon")] + [InlineData("with;semicolon")] + [InlineData("with,comma")] + [InlineData("with\"quote")] + [InlineData("with'apostrophe")] + public void Constructor_InvalidIdEdgeCases_ThrowsArgumentException(string id) + { + // Act & Assert + var exception = Assert.Throws(() => new AgentProxyThread(id)); + Assert.Contains("Thread ID", exception.Message); + } + + /// + /// Verifies that AgentProxyThread inherits from AgentThread. + /// + [Fact] + public void AgentProxyThread_InheritsFromAgentThread() + { + // Arrange & Act + var thread = new AgentProxyThread(); + + // Assert + Assert.IsAssignableFrom(thread); + } + + /// + /// Verifies that Id property is accessible. + /// + [Fact] + public void Id_IsAccessible() + { + // Arrange & Act + var thread = new AgentProxyThread("test-id"); + + // Assert + Assert.NotNull(thread.ConversationId); + Assert.Equal("test-id", thread.ConversationId); + } + + /// + /// Verifies that thread ID remains immutable after construction. + /// + [Fact] + public void Id_IsImmutable() + { + // Arrange + const string OriginalId = "immutable-id"; + var thread = new AgentProxyThread(OriginalId); + + // Act & Assert + Assert.Equal(OriginalId, thread.ConversationId); + } + + /// + /// Verifies that default constructor creates thread with valid GUID format. + /// + [Fact] + public void Constructor_Default_AlwaysCreatesValidGuid() + { + // Arrange & Act + var thread = new AgentProxyThread(); + + // Assert + Assert.NotNull(thread.ConversationId); + Assert.Equal(32, thread.ConversationId.Length); + Assert.True(Guid.TryParseExact(thread.ConversationId, "N", out var guid)); + Assert.NotEqual(Guid.Empty, guid); + } +} diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.Hosting.UnitTests/HostApplicationBuilderAgentExtensionsTests.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.Hosting.UnitTests/HostApplicationBuilderAgentExtensionsTests.cs new file mode 100644 index 0000000000..fd4dde1d8a --- /dev/null +++ b/dotnet/tests/Microsoft.Extensions.AI.Agents.Hosting.UnitTests/HostApplicationBuilderAgentExtensionsTests.cs @@ -0,0 +1,332 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Linq; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Moq; + +namespace Microsoft.Extensions.AI.Agents.Hosting.UnitTests; + +public class HostApplicationBuilderAgentExtensionsTests +{ + /// + /// Verifies that providing a null builder to AddAIAgent throws an ArgumentNullException. + /// + [Fact] + public void AddAIAgent_NullBuilder_ThrowsArgumentNullException() + { + // Act & Assert + Assert.Throws( + () => HostApplicationBuilderAgentExtensions.AddAIAgent(null!, "agent", "instructions")); + } + + /// + /// Verifies that AddAIAgent with valid parameters returns the same builder instance. + /// + /// The chat client key to use, or null to use the default service. + [Theory] + [InlineData(null)] + [InlineData("customKey")] + public void AddAIAgent_ValidParameters_ReturnsBuilder(string? chatClientKey) + { + // Arrange + var builder = new HostApplicationBuilder(); + + // Act + var result = builder.AddAIAgent("agentName", "instructions", chatClientKey); + + // Assert + Assert.Same(builder, result); + } + + /// + /// Verifies that AddAIAgent without chat client key throws ArgumentNullException for null name. + /// + [Fact] + public void AddAIAgent_NullName_ThrowsArgumentNullException() + { + // Arrange + var builder = new HostApplicationBuilder(); + + // Act & Assert + var exception = Assert.Throws(() => + builder.AddAIAgent(null!, "instructions")); + Assert.Equal("name", exception.ParamName); + } + + /// + /// Verifies that AddAIAgent without chat client key allows null instructions. + /// + [Fact] + public void AddAIAgent_NullInstructions_AllowsNull() + { + // Arrange + var builder = new HostApplicationBuilder(); + + // Act + var result = builder.AddAIAgent("agentName", (string)null!); + + // Assert + Assert.Same(builder, result); + } + + /// + /// Verifies that AddAIAgent with chat client key throws ArgumentNullException for null name. + /// + [Fact] + public void AddAIAgentWithKey_NullName_ThrowsArgumentNullException() + { + // Arrange + var builder = new HostApplicationBuilder(); + + // Act & Assert + var exception = Assert.Throws(() => + builder.AddAIAgent(null!, "instructions", "key")); + Assert.Equal("name", exception.ParamName); + } + + /// + /// Verifies that AddAIAgent with chat client key allows null instructions. + /// + [Fact] + public void AddAIAgentWithKey_NullInstructions_AllowsNull() + { + // Arrange + var builder = new HostApplicationBuilder(); + + // Act + var result = builder.AddAIAgent("agentName", null!, "key"); + + // Assert + Assert.Same(builder, result); + } + + /// + /// Verifies that AddAIAgent with factory delegate throws ArgumentNullException for null builder. + /// + [Fact] + public void AddAIAgentWithFactory_NullBuilder_ThrowsArgumentNullException() + { + // Act & Assert + Assert.Throws(() => + HostApplicationBuilderAgentExtensions.AddAIAgent( + null!, + "agentName", + (sp, key) => new Mock().Object)); + } + + /// + /// Verifies that AddAIAgent with factory delegate throws ArgumentNullException for null name. + /// + [Fact] + public void AddAIAgentWithFactory_NullName_ThrowsArgumentNullException() + { + // Arrange + var builder = new HostApplicationBuilder(); + + // Act & Assert + var exception = Assert.Throws(() => + builder.AddAIAgent(null!, (sp, key) => new Mock().Object)); + Assert.Equal("name", exception.ParamName); + } + + /// + /// Verifies that AddAIAgent with factory delegate throws ArgumentNullException for null factory. + /// + [Fact] + public void AddAIAgentWithFactory_NullFactory_ThrowsArgumentNullException() + { + // Arrange + var builder = new HostApplicationBuilder(); + + // Act & Assert + var exception = Assert.Throws(() => + builder.AddAIAgent("agentName", (Func)null!)); + Assert.Equal("createAgentDelegate", exception.ParamName); + } + + /// + /// Verifies that AddAIAgent with factory delegate returns the same builder instance. + /// + [Fact] + public void AddAIAgentWithFactory_ValidParameters_ReturnsBuilder() + { + // Arrange + var builder = new HostApplicationBuilder(); + var mockAgent = new Mock(); + + // Act + var result = builder.AddAIAgent("agentName", (sp, key) => mockAgent.Object); + + // Assert + Assert.Same(builder, result); + } + + /// + /// Verifies that AddAIAgent registers the agent as a keyed singleton service. + /// + [Fact] + public void AddAIAgent_RegistersKeyedSingleton() + { + // Arrange + var builder = new HostApplicationBuilder(); + var mockAgent = new Mock(); + const string AgentName = "testAgent"; + + // Act + builder.AddAIAgent(AgentName, (sp, key) => mockAgent.Object); + + // Assert + var descriptor = builder.Services.FirstOrDefault( + d => d.ServiceKey as string == AgentName && + d.ServiceType == typeof(AIAgent)); + + Assert.NotNull(descriptor); + Assert.Equal(ServiceLifetime.Singleton, descriptor.Lifetime); + } + + /// + /// Verifies that AddAIAgent can be called multiple times with different agent names. + /// + [Fact] + public void AddAIAgent_MultipleCalls_RegistersMultipleAgents() + { + // Arrange + var builder = new HostApplicationBuilder(); + + // Act + builder.AddAIAgent("agent1", "instructions1") + .AddAIAgent("agent2", "instructions2") + .AddAIAgent("agent3", "instructions3"); + + // Assert + var agentDescriptors = builder.Services + .Where(d => d.ServiceType == typeof(AIAgent) && d.ServiceKey is string) + .ToList(); + + Assert.Equal(3, agentDescriptors.Count); + Assert.Contains(agentDescriptors, d => (string)d.ServiceKey! == "agent1"); + Assert.Contains(agentDescriptors, d => (string)d.ServiceKey! == "agent2"); + Assert.Contains(agentDescriptors, d => (string)d.ServiceKey! == "agent3"); + } + + /// + /// Verifies that AddAIAgent handles empty strings for name. + /// + [Fact] + public void AddAIAgent_EmptyName_ThrowsArgumentException() + { + // Arrange + var builder = new HostApplicationBuilder(); + + // Act & Assert + Assert.Throws(() => + builder.AddAIAgent("", "instructions")); + } + + /// + /// Verifies that AddAIAgent allows empty strings for instructions. + /// + [Fact] + public void AddAIAgent_EmptyInstructions_Succeeds() + { + // Arrange + var builder = new HostApplicationBuilder(); + + // Act + var result = builder.AddAIAgent("agentName", ""); + + // Assert + Assert.Same(builder, result); + } + + /// + /// Verifies that AddAIAgent with whitespace name throws ArgumentException. + /// + [Theory] + [InlineData(" ")] + [InlineData("\t")] + [InlineData(" agent ")] + public void AddAIAgent_WhitespaceName_ThrowsArgumentException(string name) + { + // Arrange + var builder = new HostApplicationBuilder(); + + // Act & Assert + var exception = Assert.Throws(() => + builder.AddAIAgent(name, "instructions")); + Assert.Contains("Invalid type", exception.Message); + } + + /// + /// Verifies that AddAIAgent without chat client key calls the overload with null key. + /// + [Fact] + public void AddAIAgent_WithoutKey_CallsOverloadWithNullKey() + { + // Arrange + var builder = new HostApplicationBuilder(); + + // Act + var result = builder.AddAIAgent("agentName", "instructions"); + + // Assert + Assert.Same(builder, result); + // The agent should be registered (proving the method chain worked) + var descriptor = builder.Services.FirstOrDefault( + d => d.ServiceKey as string == "agentName" && + d.ServiceType == typeof(AIAgent)); + Assert.NotNull(descriptor); + } + + /// + /// Verifies that AddAIAgent with special characters in name works correctly for valid names. + /// + [Theory] + [InlineData("agent_name")] // underscore is allowed + [InlineData("Agent123")] // alphanumeric is allowed + [InlineData("_agent")] // can start with underscore + [InlineData("agent-name")] // dash is allowed + [InlineData("agent.name")] // period is allowed + [InlineData("agent:type")] // colon is allowed + [InlineData("my.agent_1:type-name")] // complex valid name + public void AddAIAgent_ValidSpecialCharactersInName_Succeeds(string name) + { + // Arrange + var builder = new HostApplicationBuilder(); + + // Act + var result = builder.AddAIAgent(name, "instructions"); + + // Assert + Assert.Same(builder, result); + var descriptor = builder.Services.FirstOrDefault( + d => d.ServiceKey as string == name && + d.ServiceType == typeof(AIAgent)); + Assert.NotNull(descriptor); + } + + /// + /// Verifies that AddAIAgent with invalid special characters throws ArgumentException. + /// + [Theory] + [InlineData("特殊字符")] // non-ASCII not allowed + [InlineData("123agent")] // cannot start with number + [InlineData("agent@name")] // @ not allowed + [InlineData("agent/name")] // / not allowed + [InlineData("agent name")] // space not allowed + [InlineData(".agent")] // cannot start with period + [InlineData("-agent")] // cannot start with dash + [InlineData(":agent")] // cannot start with colon + public void AddAIAgent_InvalidSpecialCharactersInName_ThrowsArgumentException(string name) + { + // Arrange + var builder = new HostApplicationBuilder(); + + // Act & Assert + var exception = Assert.Throws(() => + builder.AddAIAgent(name, "instructions")); + Assert.Contains("Invalid type", exception.Message); + } +} diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.Hosting.UnitTests/Microsoft.Extensions.AI.Agents.Hosting.UnitTests.csproj b/dotnet/tests/Microsoft.Extensions.AI.Agents.Hosting.UnitTests/Microsoft.Extensions.AI.Agents.Hosting.UnitTests.csproj new file mode 100644 index 0000000000..3608d73854 --- /dev/null +++ b/dotnet/tests/Microsoft.Extensions.AI.Agents.Hosting.UnitTests/Microsoft.Extensions.AI.Agents.Hosting.UnitTests.csproj @@ -0,0 +1,11 @@ + + + + $(ProjectsTargetFrameworks) + + + + + + + \ No newline at end of file diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.Runtime.Abstractions.UnitTests/ActorTypeTests.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.Runtime.Abstractions.UnitTests/ActorTypeTests.cs new file mode 100644 index 0000000000..7dd328c1ad --- /dev/null +++ b/dotnet/tests/Microsoft.Extensions.AI.Agents.Runtime.Abstractions.UnitTests/ActorTypeTests.cs @@ -0,0 +1,297 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; + +namespace Microsoft.Extensions.AI.Agents.Runtime.UnitTests; + +public class ActorTypeTests +{ + /// + /// Provides valid ActorType names that conform to the regex pattern ^[a-zA-Z_][a-zA-Z._:\-0-9]*$. + /// + public static IEnumerable ValidActorTypeNames => new List + { + new object[] { "a" }, // Single letter + new object[] { "A" }, // Single uppercase letter + new object[] { "_" }, // Single underscore + new object[] { "agent" }, // Simple name + new object[] { "Agent" }, // Capitalized name + new object[] { "AGENT" }, // All caps name + new object[] { "my_agent" }, // With underscore + new object[] { "MyAgent" }, // Camel case + new object[] { "agent1" }, // With number + new object[] { "agent_1" }, // With underscore and number + new object[] { "agent:type" }, // With colon + new object[] { "agent-type" }, // With hyphen + new object[] { "my_agent:type-1" }, // Complex valid name + new object[] { "A1_test:complex-name" }, // Very complex valid name + new object[] { "_private_agent" }, // Starting with underscore + new object[] { "agent_with_many_underscores" }, // Multiple underscores + new object[] { "agent:with:colons" }, // Multiple colons + new object[] { "agent-with-hyphens" }, // Multiple hyphens + new object[] { "agent123456789" }, // With many numbers + new object[] { "agent.type" }, // With dot + new object[] { "agent.sub.type" }, // With multiple dots + new object[] { "my.agent_1:type-name" }, // Complex with dots + }; + + /// + /// Provides invalid ActorType names that violate the regex pattern ^[a-zA-Z_][a-zA-Z._:\-0-9]*$. + /// + public static IEnumerable InvalidActorTypeNames => new List + { + new object[] { "1agent" }, // Starting with number + new object[] { "9test" }, // Starting with number + new object[] { "-agent" }, // Starting with hyphen + new object[] { ":agent" }, // Starting with colon + new object[] { " agent" }, // Starting with space + new object[] { "agent " }, // Trailing space + new object[] { "agent agent" }, // Space in middle + new object[] { "agent@type" }, // Invalid character @ + new object[] { "agent#type" }, // Invalid character # + new object[] { "agent$type" }, // Invalid character $ + new object[] { "agent%type" }, // Invalid character % + new object[] { "agent^type" }, // Invalid character ^ + new object[] { "agent&type" }, // Invalid character & + new object[] { "agent*type" }, // Invalid character * + new object[] { "agent(type)" }, // Invalid characters ( ) + new object[] { "agent[type]" }, // Invalid characters [ ] + new object[] { "agent{type}" }, // Invalid characters { } + new object[] { "agent+type" }, // Invalid character + + new object[] { "agent=type" }, // Invalid character = + new object[] { "agent\\type" }, // Invalid character \ + new object[] { "agent/type" }, // Invalid character / + new object[] { "agent?type" }, // Invalid character ? + new object[] { "agent,type" }, // Invalid character , + new object[] { "agent;type" }, // Invalid character ; + new object[] { "agent\"type" }, // Invalid character " + new object[] { "agent'type" }, // Invalid character ' + new object[] { "agent`type" }, // Invalid character ` + new object[] { "agent~type" }, // Invalid character ~ + new object[] { "agent!type" }, // Invalid character ! + new object[] { "agent\ttype" }, // Tab character + new object[] { "agent\ntype" }, // Newline character + }; + + /// + /// Verifies that providing valid actor type name to constructor sets the Name property correctly. + /// + /// The valid type name to test. + [Theory] + [MemberData(nameof(ValidActorTypeNames))] + public void Constructor_ValidTypeName_SetsNameProperty(string typeName) + { + // Act + var actorType = new ActorType(typeName); + + // Assert + Assert.Equal(typeName, actorType.Name); + } + + /// + /// Verifies that providing invalid actor type name to constructor throws an . + /// + /// The invalid type name to test. + [Theory] + [MemberData(nameof(InvalidActorTypeNames))] + public void Constructor_InvalidTypeName_ThrowsArgumentException(string typeName) + { + // Act & Assert + var exception = Assert.Throws(() => new ActorType(typeName)); + Assert.Contains("Invalid type", exception.Message); + Assert.Contains("Must start with a letter or underscore, and can only contain letters, dots, underscores, colons, hyphens, and numbers", exception.Message); + } + + /// + /// Verifies that providing a null type name to constructor throws an . + /// + [Fact] + public void Constructor_NullTypeName_ThrowsArgumentNullException() + { + // Act & Assert + Assert.Throws(() => new ActorType(null!)); + } + + /// + /// Verifies that providing an empty type name to constructor throws an . + /// + [Fact] + public void Constructor_EmptyTypeName_ThrowsArgumentException() + { + // Act & Assert + Assert.Throws(() => new ActorType("")); + } + + /// + /// Verifies specific edge cases for valid type names. + /// + [Theory] + [InlineData("a")] + [InlineData("Z")] + [InlineData("_")] + [InlineData("a1")] + [InlineData("_1")] + [InlineData("agent_123")] + [InlineData("MyAgent:SubType")] + [InlineData("my-agent")] + [InlineData("agent_type:sub-type_123")] + [InlineData("agent.type")] + [InlineData("my.agent.name")] + [InlineData("complex.name_1:type-sub")] + public void Constructor_ValidTypeNameEdgeCases_SetsNameProperty(string typeName) + { + // Act + var actorType = new ActorType(typeName); + + // Assert + Assert.Equal(typeName, actorType.Name); + } + + /// + /// Verifies specific edge cases for invalid type names. + /// + [Theory] + [InlineData("1")] + [InlineData("9")] + [InlineData("-")] + [InlineData(":")] + [InlineData("1agent")] + [InlineData("-agent")] + [InlineData(":agent")] + [InlineData(" ")] + [InlineData("agent ")] + [InlineData(" agent")] + [InlineData("a b")] + [InlineData("agent@type")] + [InlineData("agent/type")] + public void Constructor_InvalidTypeNameEdgeCases_ThrowsArgumentException(string typeName) + { + // Act & Assert + var exception = Assert.Throws(() => new ActorType(typeName)); + Assert.Contains("Invalid type", exception.Message); + Assert.Contains("Must start with a letter or underscore", exception.Message); + } + + /// + /// Verifies that ToString returns the type name. + /// + [Fact] + public void ToString_ReturnsTypeName() + { + // Arrange + const string TypeName = "test_agent"; + var actorType = new ActorType(TypeName); + + // Act + string result = actorType.ToString(); + + // Assert + Assert.Equal(TypeName, result); + } + + /// + /// Verifies equality comparison between ActorType instances. + /// + [Fact] + public void Equals_SameTypeName_ReturnsTrue() + { + // Arrange + var actorType1 = new ActorType("test_agent"); + var actorType2 = new ActorType("test_agent"); + + // Act & Assert + Assert.True(actorType1.Equals(actorType2)); + Assert.True(actorType1 == actorType2); + Assert.False(actorType1 != actorType2); + } + + /// + /// Verifies inequality comparison between ActorType instances. + /// + [Fact] + public void Equals_DifferentTypeName_ReturnsFalse() + { + // Arrange + var actorType1 = new ActorType("test_agent1"); + var actorType2 = new ActorType("test_agent2"); + + // Act & Assert + Assert.False(actorType1.Equals(actorType2)); + Assert.False(actorType1 == actorType2); + Assert.True(actorType1 != actorType2); + } + + /// + /// Verifies that GetHashCode returns same value for equal instances. + /// + [Fact] + public void GetHashCode_SameTypeName_ReturnsSameHashCode() + { + // Arrange + var actorType1 = new ActorType("test_agent"); + var actorType2 = new ActorType("test_agent"); + + // Act & Assert + Assert.Equal(actorType1.GetHashCode(), actorType2.GetHashCode()); + } + + /// + /// Verifies that ActorType is case sensitive. + /// + [Fact] + public void Equality_IsCaseSensitive() + { + // Arrange + var actorType1 = new ActorType("TestAgent"); + var actorType2 = new ActorType("testagent"); + + // Act & Assert + Assert.False(actorType1.Equals(actorType2)); + Assert.False(actorType1 == actorType2); + Assert.True(actorType1 != actorType2); + Assert.NotEqual(actorType1.GetHashCode(), actorType2.GetHashCode()); + } + + /// + /// Verifies that IsValidType static method works correctly for valid names. + /// + [Theory] + [MemberData(nameof(ValidActorTypeNames))] + public void IsValidType_ValidTypeName_ReturnsTrue(string typeName) + { + // Act & Assert + Assert.True(ActorType.IsValidType(typeName)); + } + + /// + /// Verifies that IsValidType static method works correctly for invalid names. + /// + [Theory] + [MemberData(nameof(InvalidActorTypeNames))] + public void IsValidType_InvalidTypeName_ReturnsFalse(string typeName) + { + // Act & Assert + Assert.False(ActorType.IsValidType(typeName)); + } + + /// + /// Verifies that IsValidType throws for null. + /// + [Fact] + public void IsValidType_NullTypeName_ThrowsArgumentNullException() + { + // Act & Assert + Assert.Throws(() => ActorType.IsValidType(null!)); + } + + /// + /// Verifies that IsValidType throws for empty string. + /// + [Fact] + public void IsValidType_EmptyTypeName_ThrowsArgumentException() + { + // Act & Assert + Assert.Throws(() => ActorType.IsValidType("")); + } +} diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.Runtime.Abstractions.UnitTests/JsonSerializationTests.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.Runtime.Abstractions.UnitTests/JsonSerializationTests.cs index 78b71a833e..665cf5b731 100644 --- a/dotnet/tests/Microsoft.Extensions.AI.Agents.Runtime.Abstractions.UnitTests/JsonSerializationTests.cs +++ b/dotnet/tests/Microsoft.Extensions.AI.Agents.Runtime.Abstractions.UnitTests/JsonSerializationTests.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Collections.Generic; using System.Text.Json; using System.Text.Json.Serialization; @@ -19,7 +20,7 @@ public class JsonSerializationTests WriteIndented = false, // Use compact JSON for easier testing DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, Converters = { new JsonStringEnumConverter() }, - TypeInfoResolver = ActorJsonContext.Default + TypeInfoResolver = AgentRuntimeAbstractionsJsonUtilities.JsonContext.Default }; } @@ -332,4 +333,203 @@ public class JsonSerializationTests } #endregion + + #region ActorResponse Tests + + [Fact] + public void ActorResponse_SerializesAndDeserializes() + { + // Arrange + var originalResponse = new ActorResponse + { + ActorId = new ActorId("TestActor", "instance1"), + MessageId = "msg123", + Status = RequestStatus.Completed, + Data = JsonSerializer.SerializeToElement(new { result = "success" }) + }; + + // Act - Serialize to JSON + string json = JsonSerializer.Serialize(originalResponse, this._options); + + // Assert - JSON structure + Assert.Contains("\"messageId\":\"msg123\"", json); + Assert.Contains("\"status\":\"completed\"", json); + + // Act - Deserialize back + var deserializedResponse = JsonSerializer.Deserialize(json, this._options); + + // Assert - Verify deserialization + Assert.NotNull(deserializedResponse); + Assert.Equal(originalResponse.MessageId, deserializedResponse.MessageId); + Assert.Equal(originalResponse.Status, deserializedResponse.Status); + Assert.Equal(originalResponse.ActorId.Type.Name, deserializedResponse.ActorId.Type.Name); + Assert.Equal(originalResponse.ActorId.Key, deserializedResponse.ActorId.Key); + Assert.Equal(originalResponse.Data.GetRawText(), deserializedResponse.Data.GetRawText()); + } + + [Fact] + public void ActorResponse_ToString_OutputsExpectedFormat() + { + // Arrange + var testData = JsonSerializer.SerializeToElement(new { result = "success" }); + var response = new ActorResponse + { + ActorId = new ActorId("TestActor", "instance1"), + MessageId = "msg123", + Status = RequestStatus.Completed, + Data = testData + }; + + // Act + string result = response.ToString(); + + // Assert + Assert.Equal($"ActorResponse(ActorId: TestActor/instance1, Status: Completed, MessageId: msg123, Data: {testData.GetRawText()})", result); + } + + [Fact] + public void ActorResponse_ToString_WithNullMessageId_OutputsExpectedFormat() + { + // Arrange + var testData = JsonSerializer.SerializeToElement(new { error = "timeout" }); + var response = new ActorResponse + { + ActorId = new ActorId("TestActor", "instance1"), + MessageId = null, + Status = RequestStatus.Pending, + Data = testData + }; + + // Act + string result = response.ToString(); + + // Assert + Assert.Equal($"ActorResponse(ActorId: TestActor/instance1, Status: Pending, MessageId: null, Data: {testData.GetRawText()})", result); + } + + [Fact] + public void ActorResponse_ToString_WithEmptyData_OutputsExpectedFormat() + { + // Arrange + var emptyData = new JsonElement(); // Default JsonElement (empty) + var response = new ActorResponse + { + ActorId = new ActorId("TestActor", "instance1"), + MessageId = "msg456", + Status = RequestStatus.Failed, + Data = emptyData + }; + + // Act + string result = response.ToString(); + + // Assert + Assert.Equal("ActorResponse(ActorId: TestActor/instance1, Status: Failed, MessageId: msg456, Data: undefined)", result); + } + + [Fact] + public void ActorResponse_ToString_WithLargeData_TruncatesAfter250Characters() + { + // Arrange + // Create a large object that will serialize to more than 250 characters + var largeArray = new List(); + for (int i = 0; i < 20; i++) + { + largeArray.Add(new + { + id = $"item-{i:000}", + name = $"This is item number {i} with a long description to make the JSON larger", + properties = new + { + prop1 = $"value1-{i}", + prop2 = $"value2-{i}", + prop3 = $"value3-{i}", + prop4 = $"value4-{i}", + prop5 = $"value5-{i}" + } + }); + } + var largeData = JsonSerializer.SerializeToElement(largeArray); + var response = new ActorResponse + { + ActorId = new ActorId("TestActor", "instance1"), + MessageId = "msg789", + Status = RequestStatus.Completed, + Data = largeData + }; + + // Act + string result = response.ToString(); + var rawText = largeData.GetRawText(); + + // Assert + // Verify that the raw JSON is indeed larger than 250 characters + Assert.True(rawText.Length > 250, $"Test data should be larger than 250 characters, but was {rawText.Length}"); + + // The ToString should truncate the data and add "..." + Assert.EndsWith("...)", result); + + // Extract the data portion from the result + var dataStartIndex = result.IndexOf("Data: ", System.StringComparison.Ordinal) + 6; + var dataEndIndex = result.Length - 1; // Exclude the closing parenthesis + var dataInResult = result.Substring(dataStartIndex, dataEndIndex - dataStartIndex); + + // Verify truncation: data should be 253 characters (250 + "...") + Assert.Equal(253, dataInResult.Length); + + // Verify that the truncated data matches the first 250 characters of the original +#pragma warning disable CA1846 // Prefer 'AsSpan' over 'Substring' + Assert.Equal(rawText.Substring(0, 250), dataInResult.Substring(0, 250)); +#pragma warning restore CA1846 + } + + [Fact] + public void ActorResponse_ToString_WithSmallData_DoesNotTruncate() + { + // Arrange + var smallObject = new + { + id = "test-id-123", + name = "Small Test Object", + value = 42 + }; + var smallData = JsonSerializer.SerializeToElement(smallObject); + var response = new ActorResponse + { + ActorId = new ActorId("TestActor", "instance1"), + MessageId = "msg789", + Status = RequestStatus.Completed, + Data = smallData + }; + + // Act + string result = response.ToString(); + + // Assert + // The ToString should include the full JSON data without truncation + Assert.Equal($"ActorResponse(ActorId: TestActor/instance1, Status: Completed, MessageId: msg789, Data: {smallData.GetRawText()})", result); + // Verify no truncation occurred + Assert.DoesNotContain("...", result); + } + + [Fact] + public void ActorResponse_ToString_WithNullData_OutputsExpectedFormat() + { + // Arrange + var response = new ActorResponse + { + ActorId = new ActorId("TestActor", "instance1"), + MessageId = "msg999", + Status = RequestStatus.Completed, + Data = JsonSerializer.SerializeToElement((object?)null) + }; + + // Act + string result = response.ToString(); + + // Assert + Assert.Equal("ActorResponse(ActorId: TestActor/instance1, Status: Completed, MessageId: msg999, Data: null)", result); + } + + #endregion }