mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.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 <adityam@microsoft.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
8dcc8533a6
commit
e7441ee29e
@@ -14,6 +14,7 @@
|
||||
<ProjectsTargetFrameworks>net9.0;net8.0;netstandard2.0;net472</ProjectsTargetFrameworks>
|
||||
<ProjectsDebugTargetFrameworks>net9.0;net472</ProjectsDebugTargetFrameworks>
|
||||
<IsAotCompatible Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net7.0'))">true</IsAotCompatible>
|
||||
<Configurations>Debug;Release;Publish</Configurations>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
@@ -22,7 +23,7 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<!-- Disable NuGet packaging by default. Projects can override. -->
|
||||
<IsPackable>disable</IsPackable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)'=='Publish'">
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<PackageVersion Include="Azure.AI.OpenAI" Version="2.2.0-beta.5" />
|
||||
<PackageVersion Include="Azure.Identity" Version="1.14.2" />
|
||||
<PackageVersion Include="CommunityToolkit.Aspire.OllamaSharp" Version="9.6.0" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="9.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.AzureAIInference" Version="9.7.1-preview.1.25365.4" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="9.7.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.ServiceDiscovery" Version="9.3.1" />
|
||||
@@ -27,8 +28,10 @@
|
||||
<PackageVersion Include="Microsoft.Azure.Cosmos" Version="3.52.0" />
|
||||
<!-- Newtonsoft (Required by CosmosClient) -->
|
||||
<PackageVersion Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore.SwaggerUI" Version="9.0.3" />
|
||||
<!-- System.* -->
|
||||
<PackageVersion Include="System.Linq.Async" Version="6.0.3" />
|
||||
<PackageVersion Include="System.Net.ServerSentEvents" Version="9.0.7" />
|
||||
<PackageVersion Include="System.Text.Json" Version="9.0.7" />
|
||||
<PackageVersion Include="System.Diagnostics.DiagnosticSource" Version="9.0.7" />
|
||||
<PackageVersion Include="System.Threading.Channels" Version="9.0.7" />
|
||||
@@ -40,6 +43,7 @@
|
||||
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
|
||||
<!-- Microsoft.Extensions.* -->
|
||||
<PackageVersion Include="Microsoft.Bcl.HashCode" Version="6.0.0" />
|
||||
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="9.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI" Version="9.7.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.OpenAI" Version="9.7.1-preview.1.25365.4" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.Abstractions" Version="9.7.1" />
|
||||
|
||||
@@ -5,48 +5,24 @@
|
||||
<BuildType Name="Release" />
|
||||
</Configurations>
|
||||
<Folder Name="/ConformanceTests/">
|
||||
<Project Path="tests/AgentConformance.IntegrationTests/AgentConformance.IntegrationTests.csproj">
|
||||
<BuildType Solution="Publish|*" Project="Release" />
|
||||
</Project>
|
||||
<Project Path="tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistent.IntegrationTests.csproj" Id="e91d4745-7ccd-4de8-9bc0-31081f540038">
|
||||
<BuildType Solution="Publish|*" Project="Release" />
|
||||
</Project>
|
||||
<Project Path="tests/CopilotStudio.IntegrationTests/CopilotStudio.IntegrationTests.csproj">
|
||||
<BuildType Solution="Publish|*" Project="Release" />
|
||||
</Project>
|
||||
<Project Path="tests/OpenAIAssistant.IntegrationTests/OpenAIAssistant.IntegrationTests.csproj" Id="a35b6971-6f27-4904-a168-8e12b229cced">
|
||||
<BuildType Solution="Publish|*" Project="Release" />
|
||||
</Project>
|
||||
<Project Path="tests/OpenAIChatCompletion.IntegrationTests/OpenAIChatCompletion.IntegrationTests.csproj">
|
||||
<BuildType Solution="Publish|*" Project="Release" />
|
||||
</Project>
|
||||
<Project Path="tests/OpenAIResponse.IntegrationTests/OpenAIResponse.IntegrationTests.csproj">
|
||||
<BuildType Solution="Publish|*" Project="Release" />
|
||||
</Project>
|
||||
<Project Path="tests/AgentConformance.IntegrationTests/AgentConformance.IntegrationTests.csproj" />
|
||||
<Project Path="tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistent.IntegrationTests.csproj" />
|
||||
<Project Path="tests/CopilotStudio.IntegrationTests/CopilotStudio.IntegrationTests.csproj" />
|
||||
<Project Path="tests/OpenAIAssistant.IntegrationTests/OpenAIAssistant.IntegrationTests.csproj" />
|
||||
<Project Path="tests/OpenAIChatCompletion.IntegrationTests/OpenAIChatCompletion.IntegrationTests.csproj" />
|
||||
<Project Path="tests/OpenAIResponse.IntegrationTests/OpenAIResponse.IntegrationTests.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Demos/">
|
||||
<Project Path="demos/MinimalConsole/MinimalConsole.csproj">
|
||||
<BuildType Solution="Publish|*" Project="Release" />
|
||||
</Project>
|
||||
</Folder>
|
||||
<Folder Name="/Demos/HelloHttpApi/">
|
||||
<Project Path="samples/HelloHttpApi/HelloHttpApi.ApiService/HelloHttpApi.ApiService.csproj">
|
||||
<BuildType Solution="Publish|*" Project="Release" />
|
||||
</Project>
|
||||
<Project Path="samples/HelloHttpApi/HelloHttpApi.AppHost/HelloHttpApi.AppHost.csproj" Id="17db642e-bf19-413c-bdb1-ccbccc753d7b">
|
||||
<BuildType Solution="Publish|*" Project="Release" />
|
||||
</Project>
|
||||
<Project Path="samples/HelloHttpApi/HelloHttpApi.ServiceDefaults/HelloHttpApi.ServiceDefaults.csproj">
|
||||
<BuildType Solution="Publish|*" Project="Release" />
|
||||
</Project>
|
||||
<Project Path="samples/HelloHttpApi/HelloHttpApi.Web/HelloHttpApi.Web.csproj">
|
||||
<BuildType Solution="Publish|*" Project="Release" />
|
||||
</Project>
|
||||
<Project Path="demos/MinimalConsole/MinimalConsole.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/">
|
||||
<Project Path="samples/GettingStarted/GettingStarted.csproj">
|
||||
<BuildType Solution="Publish|*" Project="Debug" />
|
||||
</Project>
|
||||
<Project Path="samples/GettingStarted/GettingStarted.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/AgentWebChat/">
|
||||
<Project Path="samples/AgentWebChat/AgentWebChat.AgentHost/AgentWebChat.AgentHost.csproj" />
|
||||
<Project Path="samples/AgentWebChat/AgentWebChat.AppHost/AgentWebChat.AppHost.csproj" />
|
||||
<Project Path="samples/AgentWebChat/AgentWebChat.ServiceDefaults/AgentWebChat.ServiceDefaults.csproj" />
|
||||
<Project Path="samples/AgentWebChat/AgentWebChat.Web/AgentWebChat.Web.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Solution Items/">
|
||||
<File Path=".editorconfig" />
|
||||
@@ -143,37 +119,20 @@
|
||||
<Project Path="src/Microsoft.Extensions.AI.Agents.Abstractions/Microsoft.Extensions.AI.Agents.Abstractions.csproj" />
|
||||
<Project Path="src/Microsoft.Extensions.AI.Agents.AzureAI/Microsoft.Extensions.AI.Agents.AzureAI.csproj" />
|
||||
<Project Path="src/Microsoft.Extensions.AI.Agents.CopilotStudio/Microsoft.Extensions.AI.Agents.CopilotStudio.csproj" />
|
||||
<Project Path="src/Microsoft.Extensions.AI.Agents.Hosting/Microsoft.Extensions.AI.Agents.Hosting.csproj" />
|
||||
<Project Path="src/Microsoft.Extensions.AI.Agents.OpenAI/Microsoft.Extensions.AI.Agents.OpenAI.csproj" />
|
||||
<Project Path="src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/Microsoft.Extensions.AI.Agents.Runtime.Abstractions.csproj" />
|
||||
<Project Path="src/Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB/Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB.csproj">
|
||||
<BuildType Solution="Publish|*" Project="Release" />
|
||||
</Project>
|
||||
<Project Path="src/Microsoft.Extensions.AI.Agents.Runtime/Microsoft.Extensions.AI.Agents.Runtime.csproj" Id="35d72ad5-61e1-45cc-a9ad-fa8490dbd146">
|
||||
<BuildType Solution="Publish|*" Project="Release" />
|
||||
</Project>
|
||||
<Project Path="src/Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB/Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB.csproj" />
|
||||
<Project Path="src/Microsoft.Extensions.AI.Agents.Runtime/Microsoft.Extensions.AI.Agents.Runtime.csproj" />
|
||||
<Project Path="src/Microsoft.Extensions.AI.Agents/Microsoft.Extensions.AI.Agents.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/UnitTests/">
|
||||
<Project Path="tests/Microsoft.Agents.Orchestration.UnitTests/Microsoft.Agents.Orchestration.UnitTests.csproj">
|
||||
<BuildType Solution="Publish|*" Project="Debug" />
|
||||
</Project>
|
||||
<Project Path="tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests.csproj">
|
||||
<BuildType Solution="Publish|*" Project="Debug" />
|
||||
</Project>
|
||||
<Project Path="tests/Microsoft.Extensions.AI.Agents.Runtime.Abstractions.UnitTests/Microsoft.Extensions.AI.Agents.Runtime.Abstractions.UnitTests.csproj">
|
||||
<BuildType Solution="Publish|*" Project="Debug" />
|
||||
</Project>
|
||||
<Project Path="tests/Microsoft.Extensions.AI.Agents.UnitTests/Microsoft.Extensions.AI.Agents.UnitTests.csproj">
|
||||
<BuildType Solution="Publish|*" Project="Debug" />
|
||||
</Project>
|
||||
<Project Path="tests/CosmosDB.IntegrationTests/Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB.Tests.AppHost/Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB.Tests.AppHost.csproj">
|
||||
<BuildType Solution="Publish|*" Project="Release" />
|
||||
</Project>
|
||||
<Project Path="tests/CosmosDB.IntegrationTests/Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB.Tests/Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB.Tests.csproj">
|
||||
<BuildType Solution="Publish|*" Project="Release" />
|
||||
</Project>
|
||||
<Folder Name="/CosmosDB/">
|
||||
|
||||
</Folder>
|
||||
<Project Path="tests/Microsoft.Agents.Orchestration.UnitTests/Microsoft.Agents.Orchestration.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Extensions.AI.Agents.Hosting.UnitTests/Microsoft.Extensions.AI.Agents.Hosting.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Extensions.AI.Agents.Runtime.Abstractions.UnitTests/Microsoft.Extensions.AI.Agents.Runtime.Abstractions.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Extensions.AI.Agents.UnitTests/Microsoft.Extensions.AI.Agents.UnitTests.csproj" />
|
||||
<Project Path="tests/CosmosDB.IntegrationTests/Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB.Tests.AppHost/Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB.Tests.AppHost.csproj" />
|
||||
<Project Path="tests/CosmosDB.IntegrationTests/Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB.Tests/Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB.Tests.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
|
||||
+40
@@ -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<AgentDiscoveryCard>();
|
||||
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; }
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -4,16 +4,18 @@
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.Orchestration\Microsoft.Agents.Orchestration.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Extensions.AI.Agents.Abstractions\Microsoft.Extensions.AI.Agents.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Extensions.AI.Agents.Hosting\Microsoft.Extensions.AI.Agents.Hosting.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Extensions.AI.Agents.Runtime.Abstractions\Microsoft.Extensions.AI.Agents.Runtime.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Extensions.AI.Agents.Runtime\Microsoft.Extensions.AI.Agents.Runtime.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Extensions.AI.Agents\Microsoft.Extensions.AI.Agents.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB\Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB.csproj" />
|
||||
<ProjectReference Include="..\HelloHttpApi.ServiceDefaults\HelloHttpApi.ServiceDefaults.csproj" />
|
||||
<ProjectReference Include="..\AgentWebChat.ServiceDefaults\AgentWebChat.ServiceDefaults.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -25,6 +27,9 @@
|
||||
<PackageReference Include="Microsoft.Extensions.AI.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.AzureAIInference" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenAPI" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" />
|
||||
<PackageReference Include="System.Net.ServerSentEvents" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+72
@@ -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<IActorClient>();
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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<IResult> 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<IResult> 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<IResult> 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<IHttpResponseBodyFeature>().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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-7
@@ -2,7 +2,7 @@
|
||||
|
||||
using Microsoft.Extensions.AI.Agents.Runtime;
|
||||
|
||||
namespace HelloHttpApi.ApiService;
|
||||
namespace AgentWebChat.AgentHost;
|
||||
|
||||
/// <summary>
|
||||
/// 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,
|
||||
@@ -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<IChatClient>("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();
|
||||
+2
@@ -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"
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
using System.Data.Common;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace HelloHttpApi.ApiService.Utilities;
|
||||
namespace AgentWebChat.AgentHost.Utilities;
|
||||
|
||||
public class ChatClientConnectionInfo
|
||||
{
|
||||
+2
-2
@@ -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
|
||||
{
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Trace",
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Trace",
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
+2
-2
@@ -18,8 +18,8 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\HelloHttpApi.ApiService\HelloHttpApi.ApiService.csproj" />
|
||||
<ProjectReference Include="..\HelloHttpApi.Web\HelloHttpApi.Web.csproj" />
|
||||
<ProjectReference Include="..\AgentWebChat.AgentHost\AgentWebChat.AgentHost.csproj" />
|
||||
<ProjectReference Include="..\AgentWebChat.Web\AgentWebChat.Web.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace HelloHttpApi.AppHost;
|
||||
namespace AgentWebChat.AppHost;
|
||||
|
||||
public static class ModelExtensions
|
||||
{
|
||||
+6
-6
@@ -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<Projects.HelloHttpApi_ApiService>("apiservice")
|
||||
var agentHost = builder.AddProject<Projects.AgentWebChat_AgentHost>("agenthost")
|
||||
.WithReference(chatModel)
|
||||
.WithReference(cosmos).WaitFor(cosmos);
|
||||
|
||||
builder.AddProject<Projects.HelloHttpApi_Web>("webfrontend")
|
||||
builder.AddProject<Projects.AgentWebChat_Web>("webfrontend")
|
||||
.WithExternalHttpEndpoints()
|
||||
.WithReference(apiService)
|
||||
.WaitFor(apiService);
|
||||
.WithReference(agentHost)
|
||||
.WaitFor(agentHost);
|
||||
|
||||
builder.Build().Run();
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Trace",
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Trace",
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Aspire.Hosting.Dcp": "Warning"
|
||||
}
|
||||
@@ -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<AgentDiscoveryClient> logger)
|
||||
{
|
||||
public async Task<List<AgentDiscoveryCard>> 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<List<AgentDiscoveryCard>>(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; }
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -7,7 +7,12 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\HelloHttpApi.ServiceDefaults\HelloHttpApi.ServiceDefaults.csproj" />
|
||||
<PackageReference Include="System.Net.ServerSentEvents" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Extensions.AI.Agents.Hosting\Microsoft.Extensions.AI.Agents.Hosting.csproj" />
|
||||
<ProjectReference Include="..\AgentWebChat.ServiceDefaults\AgentWebChat.ServiceDefaults.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Extensions.AI.Agents.Abstractions\Microsoft.Extensions.AI.Agents.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
<base href="/" />
|
||||
<link rel="stylesheet" href="lib/bootstrap/dist/css/bootstrap.min.css" />
|
||||
<link rel="stylesheet" href="app.css" />
|
||||
<link rel="stylesheet" href="HelloHttpApi.Web.styles.css" />
|
||||
<link rel="stylesheet" href="AgentWebChat.Web.styles.css" />
|
||||
<link rel="icon" type="image/png" href="favicon.png" />
|
||||
<HeadOutlet />
|
||||
</head>
|
||||
@@ -0,0 +1,15 @@
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
<div class="page">
|
||||
<main>
|
||||
<article class="content">
|
||||
@Body
|
||||
</article>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div id="blazor-error-ui">
|
||||
An unhandled error has occurred.
|
||||
<a href="" class="reload">Reload</a>
|
||||
<a class="dismiss">🗙</a>
|
||||
</div>
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
@@ -0,0 +1,741 @@
|
||||
@page "/"
|
||||
@attribute [StreamRendering(true)]
|
||||
@inject AgentDiscoveryClient AgentClient
|
||||
@inject IJSRuntime JSRuntime
|
||||
@inject ILogger<Home> 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
|
||||
|
||||
<PageTitle>Agent Web Chat</PageTitle>
|
||||
|
||||
<div class="chat-app-container">
|
||||
<div class="chat-header">
|
||||
<h1 class="chat-title">
|
||||
<svg class="chat-icon" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
|
||||
</svg>
|
||||
Agent Web Chat
|
||||
</h1>
|
||||
<p class="chat-subtitle">The best hypertext-based chat on the Web!</p>
|
||||
</div>
|
||||
|
||||
<div class="agent-selection-card">
|
||||
<label for="agent-select" class="agent-select-label">Choose your AI agent:</label>
|
||||
<div class="agent-select-wrapper">
|
||||
<select id="agent-select" class="agent-select" @bind="selectedAgentName" disabled="@(isLoadingAgents || isStreaming)">
|
||||
<option value="">-- Select an agent --</option>
|
||||
@foreach (var agent in availableAgents)
|
||||
{
|
||||
<option value="@agent.Name">@GetAgentDisplayName(agent.Name!) - @agent.Description</option>
|
||||
}
|
||||
</select>
|
||||
@if (!string.IsNullOrEmpty(selectedAgentName) && currentConversation == null)
|
||||
{
|
||||
<button class="start-chat-btn" @onclick="StartNewConversation">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
Start Chat
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (conversations.Any())
|
||||
{
|
||||
<div class="conversations-section">
|
||||
<div class="conversation-tabs">
|
||||
@foreach (var conv in conversations)
|
||||
{
|
||||
<div class="conversation-tab @(conv.SessionId == currentConversation?.SessionId ? "active" : "")"
|
||||
@onclick="() => SelectConversation(conv.SessionId)">
|
||||
<span class="tab-icon">@GetAgentIcon(conv.AgentName)</span>
|
||||
<span class="tab-name">@GetAgentDisplayName(conv.AgentName)</span>
|
||||
<button type="button" class="tab-close"
|
||||
aria-label="Close"
|
||||
@onclick:stopPropagation="true"
|
||||
@onclick="() => CloseConversation(conv.SessionId)">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (currentConversation != null)
|
||||
{
|
||||
<div class="chat-container">
|
||||
<div class="chat-messages" id="chat-messages">
|
||||
@foreach (var message in currentConversation.Messages)
|
||||
{
|
||||
<div class="message-wrapper @(message.Role == ChatRole.User ? "user" : "agent")">
|
||||
@if (message.Role != ChatRole.User)
|
||||
{
|
||||
<div class="message-avatar">@GetAgentIcon(currentConversation.AgentName)</div>
|
||||
}
|
||||
<div class="message-bubble">
|
||||
<div class="message-content">@message.Text</div>
|
||||
<div class="message-meta">
|
||||
@(message.Role == ChatRole.User ? "You" : GetAgentDisplayName(currentConversation.AgentName))
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (isStreaming && currentStreamedMessage.Length > 0)
|
||||
{
|
||||
<div class="message-wrapper agent">
|
||||
<div class="message-avatar">@GetAgentIcon(currentConversation.AgentName)</div>
|
||||
<div class="message-bubble streaming">
|
||||
<div class="message-content">
|
||||
@currentStreamedMessage
|
||||
<span class="typing-indicator"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="chat-input-container">
|
||||
<div class="chat-input-wrapper">
|
||||
<input @bind="currentMessage"
|
||||
@bind:event="oninput"
|
||||
@onkeydown="HandleKeyPress"
|
||||
@onkeydown:preventDefault="ShouldPreventDefault"
|
||||
class="chat-input"
|
||||
placeholder="Type your message..."
|
||||
disabled="@isStreaming" />
|
||||
<button @onclick="SendMessage"
|
||||
class="send-button"
|
||||
disabled="@(isStreaming || string.IsNullOrWhiteSpace(currentMessage))">
|
||||
@if (isStreaming)
|
||||
{
|
||||
<div class="spinner"></div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="22" y1="2" x2="11" y2="13"></line>
|
||||
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
|
||||
</svg>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.chat-app-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.chat-title {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
color: #1a1a1a;
|
||||
margin: 0 0 0.5rem 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.chat-icon {
|
||||
color: #6366f1;
|
||||
}
|
||||
|
||||
.chat-subtitle {
|
||||
color: #6b7280;
|
||||
font-size: 1.125rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.agent-selection-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.agent-select-label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.agent-select-wrapper {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.agent-select {
|
||||
flex: 1;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 1rem;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
color: #374151;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.agent-select:hover:not(:disabled) {
|
||||
border-color: #6366f1;
|
||||
}
|
||||
|
||||
.agent-select:focus {
|
||||
outline: none;
|
||||
border-color: #6366f1;
|
||||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
|
||||
}
|
||||
|
||||
.agent-select:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.start-chat-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #6366f1;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.start-chat-btn:hover {
|
||||
background: #4f46e5;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
|
||||
}
|
||||
|
||||
.conversations-section {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.conversation-tabs {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.conversation-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: white;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.conversation-tab:hover {
|
||||
border-color: #6366f1;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.conversation-tab.active {
|
||||
background: #6366f1;
|
||||
color: white;
|
||||
border-color: #6366f1;
|
||||
}
|
||||
|
||||
.tab-icon {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.tab-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tab-close {
|
||||
margin-left: 0.5rem;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.2s;
|
||||
padding: 0.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tab-close:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.conversation-tab.active .tab-close {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.chat-container {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 600px;
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.message-wrapper {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.message-wrapper.user {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.message-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: #6366f1;
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.message-bubble {
|
||||
max-width: 70%;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.message-wrapper.user .message-bubble {
|
||||
background: #6366f1;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
line-height: 1.5;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.message-meta {
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.6;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.message-bubble.streaming {
|
||||
background: #e0e7ff;
|
||||
}
|
||||
|
||||
.typing-indicator {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #6366f1;
|
||||
margin-left: 4px;
|
||||
animation: pulse 1.4s infinite;
|
||||
}
|
||||
|
||||
@@keyframes pulse {
|
||||
0%, 60%, 100% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
30% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.chat-input-container {
|
||||
border-top: 1px solid #e5e7eb;
|
||||
padding: 1rem;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.chat-input-wrapper {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
flex: 1;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.chat-input:focus {
|
||||
outline: none;
|
||||
border-color: #6366f1;
|
||||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
|
||||
}
|
||||
|
||||
.chat-input:disabled {
|
||||
opacity: 0.5;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.send-button {
|
||||
padding: 0.75rem 1rem;
|
||||
background: #6366f1;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 50px;
|
||||
}
|
||||
|
||||
.send-button:hover:not(:disabled) {
|
||||
background: #4f46e5;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
|
||||
}
|
||||
|
||||
.send-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: white;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@@media (max-width: 768px) {
|
||||
.chat-app-container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.chat-title {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.message-bubble {
|
||||
max-width: 85%;
|
||||
}
|
||||
|
||||
.chat-container {
|
||||
height: 500px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@code {
|
||||
|
||||
|
||||
|
||||
private string currentMessage = "";
|
||||
private bool isStreaming = false;
|
||||
private bool isLoadingAgents = true;
|
||||
private string currentStreamedMessage = "";
|
||||
private string selectedAgentName = "";
|
||||
private List<AgentDiscoveryClient.AgentDiscoveryCard> availableAgents = new();
|
||||
private List<Conversation> conversations = new();
|
||||
private Conversation? currentConversation;
|
||||
|
||||
private class Conversation
|
||||
{
|
||||
public string SessionId { get; set; } = Guid.NewGuid().ToString();
|
||||
public string AgentName { get; set; } = "";
|
||||
public List<ChatMessage> 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;
|
||||
});
|
||||
}
|
||||
};
|
||||
");
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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
|
||||
@@ -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<ActorResponseHandle> 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<ActorResponseHandle> 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<ActorResponse> 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<AgentRunResponseUpdate>();
|
||||
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<ActorRequestUpdate> 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<ActorRequestUpdate> EnumerateAsync(HttpResponseMessage responseMessage, [EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
var responseStream = await responseMessage.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var sseParser = SseParser.Create<ActorRequestUpdate?>(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<ActorResponse> ReadResponseAsync(HttpResponseMessage responseMessage, CancellationToken cancellationToken)
|
||||
{
|
||||
var response = await responseMessage.Content.ReadFromJsonAsync<ActorResponse>(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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
-9
@@ -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<AgentClient>(client =>
|
||||
{
|
||||
// This URL uses "https+http://" to indicate HTTPS is preferred over HTTP.
|
||||
// Learn more about service discovery scheme resolution at https://aka.ms/dotnet/sdschemes.
|
||||
client.BaseAddress = new("https+http://apiservice");
|
||||
});
|
||||
builder.Services.AddHttpClient<IActorClient, HttpActorClient>(client => client.BaseAddress = new("https+http://agenthost"));
|
||||
builder.Services.AddHttpClient<AgentDiscoveryClient>(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<App>()
|
||||
.AddInteractiveServerRenderMode();
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Trace",
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Trace",
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
+7
-47
@@ -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."
|
||||
}
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
-174
@@ -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<Program> logger,
|
||||
IActorClient actorClient,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var streamRequested = stream == true;
|
||||
|
||||
Log.ActorInvocationStarted(logger, name, sessionId, requestId, streamRequested);
|
||||
Log.ActorRequestReceived(logger, requestId, request.GetRawText().Length, streamRequested);
|
||||
|
||||
try
|
||||
{
|
||||
var responseHandle = await actorClient.SendRequestAsync(new ActorRequest(new ActorId(name, sessionId), requestId, method: "run", @params: request), cancellationToken);
|
||||
Log.ActorRequestSent(logger, requestId, name, sessionId);
|
||||
|
||||
if (!responseHandle.TryGetResponse(out var response))
|
||||
{
|
||||
Log.ActorResponseHandleObtained(logger, requestId, false);
|
||||
|
||||
if (stream == true)
|
||||
{
|
||||
Log.SseStreamingStarted(logger, requestId);
|
||||
// If no response is available and streaming is requested, stream the response handle.
|
||||
var result = await StreamResponse(context, responseHandle, cancellationToken);
|
||||
Log.ActorInvocationCompleted(logger, name, sessionId, requestId, RequestStatus.Pending, stopwatch.ElapsedMilliseconds);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Otherwise, wait for a response to become available.
|
||||
Log.WaitingForActorResponse(logger, requestId);
|
||||
response = await responseHandle.GetResponseAsync(cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.ActorResponseHandleObtained(logger, requestId, true);
|
||||
}
|
||||
|
||||
Log.ActorResponseReceived(logger, requestId, response.Status);
|
||||
var processResult = await ProcessResponse(name, sessionId, requestId, stream, context, responseHandle, response, cancellationToken);
|
||||
Log.ActorInvocationCompleted(logger, name, sessionId, requestId, response.Status, stopwatch.ElapsedMilliseconds);
|
||||
return processResult;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.ActorInvocationFailed(logger, ex, name, sessionId, requestId, stopwatch.ElapsedMilliseconds);
|
||||
return Results.Problem("An error occurred processing the request.", statusCode: 500);
|
||||
}
|
||||
|
||||
static async Task<IResult> StreamResponse(HttpContext context, ActorResponseHandle responseHandle, CancellationToken cancellationToken)
|
||||
{
|
||||
var requestId = context.Request.RouteValues["requestId"]?.ToString() ?? "unknown";
|
||||
var logger = context.RequestServices.GetRequiredService<ILogger<Program>>();
|
||||
|
||||
Log.SseStreamingStarted(logger, requestId);
|
||||
InitializeSseResponse(context);
|
||||
await context.Response.Body.FlushAsync(cancellationToken);
|
||||
|
||||
var updateCount = 0;
|
||||
try
|
||||
{
|
||||
await foreach (var progress in responseHandle.WatchUpdatesAsync(cancellationToken))
|
||||
{
|
||||
// Properly serialize the progress data as JSON and escape for SSE
|
||||
var progressJson = JsonSerializer.Serialize(progress.Data, (JsonSerializerOptions?)null);
|
||||
var eventData = JsonSerializer.Serialize(new { @event = JsonDocument.Parse(progressJson).RootElement });
|
||||
var eventText = $"data: {eventData}\n\n";
|
||||
|
||||
await context.Response.WriteAsync(eventText, cancellationToken);
|
||||
await context.Response.Body.FlushAsync(cancellationToken);
|
||||
|
||||
updateCount++;
|
||||
Log.SseProgressUpdateSent(logger, requestId, updateCount);
|
||||
}
|
||||
|
||||
// Send completion marker
|
||||
await context.Response.WriteAsync("data: completed\n\n", cancellationToken);
|
||||
await context.Response.Body.FlushAsync(cancellationToken);
|
||||
|
||||
Log.SseStreamingCompleted(logger, requestId, updateCount);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Log.SseStreamingCancelled(logger, requestId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.SseStreamingError(logger, ex, requestId);
|
||||
}
|
||||
|
||||
// TODO: refactor the enclosing method so we don't need to return a result here.
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
static void InitializeSseResponse(HttpContext context)
|
||||
{
|
||||
context.Response.Headers.ContentType = "text/event-stream";
|
||||
context.Response.Headers.CacheControl = "no-cache,no-store";
|
||||
context.Response.Headers.Connection = "keep-alive";
|
||||
|
||||
// Make sure we disable all response buffering for SSE.
|
||||
context.Response.Headers.ContentEncoding = "identity";
|
||||
context.Features.GetRequiredFeature<IHttpResponseBodyFeature>().DisableBuffering();
|
||||
}
|
||||
|
||||
static async Task<IResult> ProcessResponse(
|
||||
string name,
|
||||
string sessionId,
|
||||
string requestId,
|
||||
bool? stream,
|
||||
HttpContext context,
|
||||
ActorResponseHandle responseHandle,
|
||||
ActorResponse response,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var logger = context.RequestServices.GetRequiredService<ILogger<Program>>();
|
||||
var isStreaming = stream != false && response.Status == RequestStatus.Pending;
|
||||
|
||||
Log.ProcessingActorResponse(logger, requestId, response.Status, isStreaming);
|
||||
|
||||
var result = response.Status switch
|
||||
{
|
||||
// If the response is pending & streaming is disabled, return a 202 Accepted with the messageId.
|
||||
RequestStatus.Pending when stream == false => Results.Accepted($"/invocations/actor/{name}/{sessionId}/{requestId}"),
|
||||
|
||||
// If streaming is not explicitly disabled, stream the response back.
|
||||
RequestStatus.Pending => await StreamResponse(context, responseHandle, cancellationToken),
|
||||
RequestStatus.Completed => Results.Ok(response.Data),
|
||||
|
||||
// If the response failed, we can return a 500 Internal Server Error.
|
||||
RequestStatus.Failed => Results.Problem("The invocation failed.", statusCode: 500),
|
||||
RequestStatus.NotFound => Results.NotFound(new { message = "Not found." }),// If the actor is not found, we can return a 404 Not Found.
|
||||
_ => throw new NotSupportedException($"Unsupported request status: {response.Status}"),
|
||||
};
|
||||
|
||||
var responseType = response.Status switch
|
||||
{
|
||||
RequestStatus.Pending when stream == false => "Accepted",
|
||||
RequestStatus.Pending => "Streaming",
|
||||
RequestStatus.Completed => "Ok",
|
||||
RequestStatus.Failed => "Problem",
|
||||
RequestStatus.NotFound => "NotFound",
|
||||
_ => "Unknown"
|
||||
};
|
||||
|
||||
Log.ActorResponseProcessed(logger, requestId, responseType);
|
||||
return result;
|
||||
}
|
||||
})
|
||||
.WithName("Invocations");
|
||||
}
|
||||
}
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace HelloHttpApi.ApiService;
|
||||
|
||||
/// <summary>
|
||||
/// Source-generated JSON type information for use by ChatClientAgentActor.
|
||||
/// </summary>
|
||||
[JsonSourceGenerationOptions(
|
||||
JsonSerializerDefaults.Web,
|
||||
UseStringEnumConverter = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false)]
|
||||
[JsonSerializable(typeof(ChatClientAgentRunRequest))]
|
||||
internal sealed partial class ChatClientAgentActorJsonContext : JsonSerializerContext;
|
||||
@@ -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<ChatMessage> Messages { get; set; } = [];
|
||||
}
|
||||
-45
@@ -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<AIAgent>(agentKey, (sp, key) =>
|
||||
{
|
||||
var chatClient = chatClientKey is null ? sp.GetRequiredService<IChatClient>() : sp.GetRequiredKeyedService<IChatClient>(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<AIAgent>(agentKey),
|
||||
ctx,
|
||||
sp.GetRequiredService<ILogger<ChatClientAgentActor>>()));
|
||||
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace HelloHttpApi.ApiService;
|
||||
|
||||
public enum PingResponseStatus
|
||||
{
|
||||
Healthy,
|
||||
HealthyBusy,
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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<AgentClient> logger)
|
||||
{
|
||||
public async IAsyncEnumerable<AgentRunResponseUpdate> SendMessageStreamAsync(
|
||||
string agentName,
|
||||
string message,
|
||||
string sessionId = "default",
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
var requestId = Guid.NewGuid().ToString();
|
||||
var request = new ChatClientAgentRunRequest
|
||||
{
|
||||
Messages = [new ChatMessage(ChatRole.User, message)]
|
||||
};
|
||||
|
||||
var content = JsonContent.Create(request, 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<AgentResponse> SendMessageAsync(
|
||||
string agentName,
|
||||
string message,
|
||||
string sessionId = "default",
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var requestId = Guid.NewGuid().ToString();
|
||||
var request = new ChatClientAgentRunRequest
|
||||
{
|
||||
Messages = [new ChatMessage(ChatRole.User, message)]
|
||||
};
|
||||
|
||||
var content = JsonContent.Create(request, 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<AgentRunResponseUpdate>(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<ChatMessage> Messages { get; set; } = [];
|
||||
}
|
||||
|
||||
public class EventData
|
||||
{
|
||||
[JsonPropertyName("event")]
|
||||
public JsonElement? Event { get; set; }
|
||||
}
|
||||
|
||||
public class AgentResponse
|
||||
{
|
||||
[JsonPropertyName("content")]
|
||||
public string Content { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Source-generated JSON type information for use by AgentClient.
|
||||
/// </summary>
|
||||
[JsonSourceGenerationOptions(
|
||||
JsonSerializerDefaults.Web,
|
||||
UseStringEnumConverter = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false)]
|
||||
[JsonSerializable(typeof(ChatClientAgentRunRequest))]
|
||||
[JsonSerializable(typeof(EventData))]
|
||||
[JsonSerializable(typeof(AgentResponse))]
|
||||
internal sealed partial class AgentClientJsonContext : JsonSerializerContext;
|
||||
@@ -1,23 +0,0 @@
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
<div class="page">
|
||||
<div class="sidebar">
|
||||
<NavMenu />
|
||||
</div>
|
||||
|
||||
<main>
|
||||
<div class="top-row px-4">
|
||||
<a href="https://learn.microsoft.com/aspnet/core/" target="_blank">About</a>
|
||||
</div>
|
||||
|
||||
<article class="content px-4">
|
||||
@Body
|
||||
</article>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div id="blazor-error-ui">
|
||||
An unhandled error has occurred.
|
||||
<a href="" class="reload">Reload</a>
|
||||
<a class="dismiss">🗙</a>
|
||||
</div>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
<div class="top-row ps-3 navbar navbar-dark">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="">HelloHttpApi</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input type="checkbox" title="Navigation menu" class="navbar-toggler" />
|
||||
|
||||
<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()">
|
||||
<nav class="nav flex-column">
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
|
||||
<span class="bi bi-house-door-fill" aria-hidden="true"></span> Home
|
||||
</NavLink>
|
||||
</div>
|
||||
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="counter">
|
||||
<span class="bi bi-plus-square-fill" aria-hidden="true"></span> Counter
|
||||
</NavLink>
|
||||
</div>
|
||||
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="pirate-talk">
|
||||
<span class="bi bi-chat-dots-fill" aria-hidden="true"></span> Pirate Talk
|
||||
</NavLink>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
@page "/counter"
|
||||
@rendermode InteractiveServer
|
||||
|
||||
<PageTitle>Counter</PageTitle>
|
||||
|
||||
<h1>Counter</h1>
|
||||
|
||||
<p role="status">Current count: @currentCount</p>
|
||||
|
||||
<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>
|
||||
|
||||
@code {
|
||||
private int currentCount = 0;
|
||||
|
||||
private void IncrementCount()
|
||||
{
|
||||
currentCount++;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
@page "/"
|
||||
|
||||
<PageTitle>Home</PageTitle>
|
||||
|
||||
<h1>Hello, world!</h1>
|
||||
|
||||
Welcome to your new app.
|
||||
@@ -1,208 +0,0 @@
|
||||
@page "/pirate-talk"
|
||||
@attribute [StreamRendering(true)]
|
||||
@inject AgentClient AgentClient
|
||||
@inject IJSRuntime JSRuntime
|
||||
@inject ILogger<PirateTalk> Logger
|
||||
@rendermode InteractiveServer
|
||||
@using System.Text
|
||||
@using System.Text.Json
|
||||
@using Microsoft.Extensions.AI
|
||||
@using Microsoft.Extensions.AI.Agents
|
||||
|
||||
<PageTitle>Pirate Talk</PageTitle>
|
||||
|
||||
<h1>🏴☠️ Pirate Talk</h1>
|
||||
|
||||
<p>Chat with a pirate agent! Send a message and get a response in pirate speak.</p>
|
||||
|
||||
<div class="chat-container">
|
||||
<div class="chat-messages" id="chat-messages" style="height: 400px; overflow-y: auto; border: 1px solid #ccc; padding: 10px; margin-bottom: 10px; background-color: #f8f9fa;">
|
||||
@foreach (var message in chatMessages)
|
||||
{
|
||||
<div class="message @(message.IsUser ? "user-message" : "pirate-message")" style="margin-bottom: 10px; padding: 8px; border-radius: 8px; @(message.IsUser ? "background-color: #007bff; color: white; text-align: right;" : "background-color: #e9ecef;")">
|
||||
<strong>@(message.IsUser ? "You" : "🏴☠️ Pirate"):</strong>
|
||||
<div style="margin-top: 4px;">@message.Content</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (isStreaming && currentStreamedMessage.Length > 0)
|
||||
{
|
||||
<div class="message pirate-message streaming" style="margin-bottom: 10px; padding: 8px; border-radius: 8px; background-color: #e9ecef;">
|
||||
<strong>🏴☠️ Pirate:</strong>
|
||||
<div style="margin-top: 4px;">@currentStreamedMessage<span class="typing-indicator">▋</span></div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<input @bind="currentMessage" @onkeypress="HandleKeyPress" class="form-control" placeholder="Type your message here..." disabled="@isStreaming" />
|
||||
<button @onclick="SendMessage" class="btn btn-primary" disabled="@(isStreaming || string.IsNullOrWhiteSpace(currentMessage))">
|
||||
@if (isStreaming)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
<span>Sending...</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Send</span>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.typing-indicator {
|
||||
animation: blink 1s infinite;
|
||||
}
|
||||
|
||||
@@keyframes blink {
|
||||
0%, 50% { opacity: 1; }
|
||||
51%, 100% { opacity: 0; }
|
||||
}
|
||||
|
||||
.chat-container {
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.user-message {
|
||||
margin-left: 20%;
|
||||
}
|
||||
|
||||
.pirate-message {
|
||||
margin-right: 20%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@code {
|
||||
private string currentMessage = "";
|
||||
private bool isStreaming = false;
|
||||
private string currentStreamedMessage = "";
|
||||
private List<ChatMessage> chatMessages = new();
|
||||
private string sessionId = Guid.NewGuid().ToString();
|
||||
private const string AgentName = "agent:pirate";
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
Logger.LogDebug("Initializing PirateTalk component with session ID: {SessionId}", sessionId);
|
||||
}
|
||||
|
||||
private async Task SendMessage()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(currentMessage) || isStreaming)
|
||||
return;
|
||||
|
||||
var userMessage = currentMessage.Trim();
|
||||
currentMessage = "";
|
||||
|
||||
Logger.LogInformation("User sending message: '{UserMessage}' in session {SessionId}", userMessage, sessionId);
|
||||
|
||||
// Add user message to chat
|
||||
chatMessages.Add(new ChatMessage { Content = userMessage, IsUser = true });
|
||||
Logger.LogDebug("Chat message added to collection. Total messages: {MessageCount}, Is user message: {IsUserMessage}", chatMessages.Count, true);
|
||||
Logger.LogTrace("StateHasChanged called during streaming for session {SessionId}", sessionId);
|
||||
StateHasChanged();
|
||||
await ScrollToBottom();
|
||||
|
||||
// Start streaming response
|
||||
isStreaming = true;
|
||||
currentStreamedMessage = "";
|
||||
Logger.LogDebug("Starting streaming response for session {SessionId}", sessionId);
|
||||
Logger.LogTrace("StateHasChanged called during streaming for session {SessionId}", sessionId);
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
var responseContent = new StringBuilder();
|
||||
|
||||
await foreach (var update in AgentClient.SendMessageStreamAsync(AgentName, userMessage, sessionId))
|
||||
{
|
||||
Logger.LogTrace("Received streaming update with text length: {TextLength} for session {SessionId}", update.Text?.Length ?? 0, sessionId);
|
||||
|
||||
// Extract text content from the AgentRunResponseUpdate
|
||||
var content = update.Text ?? "";
|
||||
if (!string.IsNullOrEmpty(content))
|
||||
{
|
||||
Logger.LogDebug("Extracted content from update: '{ExtractedContent}' for session {SessionId}", content, sessionId);
|
||||
responseContent.Append(content);
|
||||
currentStreamedMessage = responseContent.ToString();
|
||||
Logger.LogTrace("StateHasChanged called during streaming for session {SessionId}", sessionId);
|
||||
StateHasChanged();
|
||||
await ScrollToBottom();
|
||||
}
|
||||
}
|
||||
|
||||
// Add the complete pirate response to chat messages
|
||||
if (responseContent.Length > 0)
|
||||
{
|
||||
Logger.LogInformation("Streaming completed with total response length: {ResponseLength} for session {SessionId}", responseContent.Length, sessionId);
|
||||
chatMessages.Add(new ChatMessage { Content = responseContent.ToString(), IsUser = false });
|
||||
Logger.LogDebug("Chat message added to collection. Total messages: {MessageCount}, Is user message: {IsUserMessage}", chatMessages.Count, false);
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.LogWarning("Empty response received from agent for session {SessionId}", sessionId);
|
||||
chatMessages.Add(new ChatMessage { Content = "Arrr, something went wrong with me response, matey!", IsUser = false });
|
||||
Logger.LogDebug("Chat message added to collection. Total messages: {MessageCount}, Is user message: {IsUserMessage}", chatMessages.Count, false);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Error occurred while processing message in session {SessionId}: {ErrorMessage}", sessionId, ex.Message);
|
||||
chatMessages.Add(new ChatMessage { Content = $"Arrr, encountered rough seas: {ex.Message}", IsUser = false });
|
||||
Logger.LogDebug("Chat message added to collection. Total messages: {MessageCount}, Is user message: {IsUserMessage}", chatMessages.Count, false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
isStreaming = false;
|
||||
currentStreamedMessage = "";
|
||||
Logger.LogTrace("StateHasChanged called during streaming for session {SessionId}", sessionId);
|
||||
StateHasChanged();
|
||||
await ScrollToBottom();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleKeyPress(KeyboardEventArgs e)
|
||||
{
|
||||
Logger.LogDebug("Handling key press event: {Key} for session {SessionId}", e.Key, sessionId);
|
||||
if (e.Key == "Enter" && !e.ShiftKey)
|
||||
{
|
||||
await SendMessage();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ScrollToBottom()
|
||||
{
|
||||
try
|
||||
{
|
||||
Logger.LogTrace("Scrolling chat to bottom for session {SessionId}", sessionId);
|
||||
await JSRuntime.InvokeVoidAsync("scrollToBottom", "chat-messages");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "Failed to scroll to bottom due to JavaScript error for session {SessionId}", sessionId);
|
||||
// Ignore JS errors
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
Logger.LogDebug("Component first render completed, JavaScript functions initialized for session {SessionId}", sessionId);
|
||||
await JSRuntime.InvokeVoidAsync("eval", @"
|
||||
window.scrollToBottom = function(elementId) {
|
||||
const element = document.getElementById(elementId);
|
||||
if (element) {
|
||||
element.scrollTop = element.scrollHeight;
|
||||
}
|
||||
};
|
||||
");
|
||||
}
|
||||
}
|
||||
|
||||
private class ChatMessage
|
||||
{
|
||||
public string Content { get; set; } = "";
|
||||
public bool IsUser { get; set; }
|
||||
}
|
||||
}
|
||||
+40
-12
@@ -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<ChatClientAgentActor> logger) : IActor
|
||||
ILogger<AgentActor> 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<ChatMessage>? 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<AgentRunResponseUpdate>();
|
||||
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)
|
||||
{
|
||||
@@ -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";
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
|
||||
namespace Microsoft.Extensions.AI.Agents.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Provides a catalog of registered AI agents within the hosting environment.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public abstract class AgentCatalog
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentCatalog"/> class.
|
||||
/// </summary>
|
||||
protected AgentCatalog()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asynchronously retrieves all registered AI agents from the catalog.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">A cancellation token that can be used to cancel the enumeration operation.</param>
|
||||
/// <returns>
|
||||
/// An asynchronous enumerable of <see cref="AIAgent"/> instances representing all registered agents.
|
||||
/// The enumeration will only include agents that are successfully resolved from the service provider.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public abstract IAsyncEnumerable<AIAgent> GetAgentsAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>Provides a collection of utility methods for working with JSON data in the context of agent hosting.</summary>
|
||||
public static partial class AgentHostingJsonUtilities
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the <see cref="JsonSerializerOptions"/> singleton used as the default in JSON serialization operations.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// For Native AOT or applications disabling <see cref="JsonSerializer.IsReflectionEnabledByDefault"/>, this instance
|
||||
/// includes source generated contracts for all common exchange types contained in this library.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It additionally turns on the following settings:
|
||||
/// <list type="number">
|
||||
/// <item>Enables <see cref="JsonSerializerDefaults.Web"/> defaults.</item>
|
||||
/// <item>Enables <see cref="JsonIgnoreCondition.WhenWritingNull"/> as the default ignore condition for properties.</item>
|
||||
/// <item>Enables <see cref="JsonNumberHandling.AllowReadingFromString"/> as the default number handling for number types.</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static JsonSerializerOptions DefaultOptions { get; } = CreateDefaultOptions();
|
||||
|
||||
/// <summary>
|
||||
/// Creates default options to use for agent hosting-related serialization.
|
||||
/// </summary>
|
||||
/// <returns>The configured options.</returns>
|
||||
[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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a proxy for an AI agent that communicates with the agent runtime via an actor client.
|
||||
/// </summary>
|
||||
public sealed class AgentProxy : AIAgent
|
||||
{
|
||||
private readonly IActorClient _client;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentProxy"/> class with the specified agent name and actor client.
|
||||
/// </summary>
|
||||
/// <param name="name">The name of the agent.</param>
|
||||
/// <param name="client">The actor client used to communicate with the agent.</param>
|
||||
public AgentProxy(string name, IActorClient client)
|
||||
{
|
||||
Throw.IfNull(client);
|
||||
Throw.IfNullOrEmpty(name);
|
||||
this._client = client;
|
||||
this.Name = name;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string Name { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override AgentThread GetNewThread() => new AgentProxyThread();
|
||||
|
||||
/// <summary>
|
||||
/// Gets a thread by its <see cref="AgentThread.ConversationId"/>.
|
||||
/// </summary>
|
||||
/// <param name="conversationId">The thread identifier.</param>
|
||||
/// <returns>The thread.</returns>
|
||||
public AgentThread GetThread(string conversationId) => new AgentProxyThread(conversationId);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task<AgentRunResponse> RunAsync(
|
||||
IReadOnlyCollection<ChatMessage> 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);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
|
||||
IReadOnlyCollection<ChatMessage> 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<AgentRunResponse> RunAsync(IReadOnlyCollection<ChatMessage> 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<AgentRunResponseUpdate> RunStreamingAsync(
|
||||
IReadOnlyCollection<ChatMessage> 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<ActorResponseHandle> RunCoreAsync(IReadOnlyCollection<ChatMessage> messages, string threadId, CancellationToken cancellationToken)
|
||||
{
|
||||
Debug.Assert(messages is not null);
|
||||
Debug.Assert(threadId is not null);
|
||||
var newMessages = new List<ChatMessage>(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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an agent thread for a <see cref="AgentProxy"/>.
|
||||
/// </summary>
|
||||
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
|
||||
/// <summary>
|
||||
/// Regular expression pattern for valid thread IDs.
|
||||
/// Thread IDs must be alphanumeric and can contain hyphens, underscores, dots, and tildes (RFC 3986 unreserved characters).
|
||||
/// </summary>
|
||||
[GeneratedRegex(ThreadValidationRegex, RegexOptions.Compiled)]
|
||||
private static partial Regex ValidIdPattern();
|
||||
#else
|
||||
/// <summary>
|
||||
/// Regular expression pattern for valid thread IDs.
|
||||
/// Thread IDs must be alphanumeric and can contain hyphens, underscores, dots, and tildes (RFC 3986 unreserved characters).
|
||||
/// </summary>
|
||||
private static readonly Regex s_validIdPattern = new(ThreadValidationRegex, RegexOptions.Compiled);
|
||||
|
||||
/// <summary>
|
||||
/// Regular expression pattern for valid thread IDs.
|
||||
/// Thread IDs must be alphanumeric and can contain hyphens, underscores, dots, and tildes (RFC 3986 unreserved characters).
|
||||
/// </summary>
|
||||
private static Regex ValidIdPattern() => s_validIdPattern;
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentProxyThread"/> class with the specified identifier.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier for the agent proxy thread.</param>
|
||||
public AgentProxyThread(string id)
|
||||
{
|
||||
Throw.IfNullOrEmpty(id);
|
||||
ValidateId(id);
|
||||
this.ConversationId = id;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentProxyThread"/> class with the specified identifier.
|
||||
/// </summary>
|
||||
public AgentProxyThread() : this(CreateId())
|
||||
{
|
||||
}
|
||||
|
||||
internal static string CreateId() => Guid.NewGuid().ToString("N");
|
||||
|
||||
/// <summary>
|
||||
/// Validates that the provided ID matches the required pattern for thread IDs.
|
||||
/// </summary>
|
||||
/// <param name="id">The ID to validate.</param>
|
||||
/// <exception cref="ArgumentException">Thrown when the ID is not valid.</exception>
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a request to run an agent with a collection of chat messages.
|
||||
/// </summary>
|
||||
public sealed class AgentRunRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the collection of chat messages to be processed by the agent.
|
||||
/// </summary>
|
||||
[JsonPropertyName("messages")]
|
||||
public List<ChatMessage>? Messages { get; set; }
|
||||
}
|
||||
+158
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods for configuring AI agents in a host application builder.
|
||||
/// </summary>
|
||||
public static class HostApplicationBuilderAgentExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds an AI agent to the host application builder with the specified name and instructions.
|
||||
/// </summary>
|
||||
/// <param name="builder">The host application builder to configure.</param>
|
||||
/// <param name="name">The name of the agent.</param>
|
||||
/// <param name="instructions">The instructions for the agent.</param>
|
||||
/// <returns>The configured host application builder.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/>, <paramref name="name"/>, or <paramref name="instructions"/> is null.</exception>
|
||||
public static IHostApplicationBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string instructions)
|
||||
{
|
||||
Throw.IfNull(builder);
|
||||
Throw.IfNull(name);
|
||||
return builder.AddAIAgent(name, instructions, chatClientServiceKey: null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an AI agent to the host application builder with the specified name, instructions, and chat client key.
|
||||
/// </summary>
|
||||
/// <param name="builder">The host application builder to configure.</param>
|
||||
/// <param name="name">The name of the agent.</param>
|
||||
/// <param name="instructions">The instructions for the agent.</param>
|
||||
/// <param name="chatClient">The chat client which the agent will use for inference.</param>
|
||||
/// <returns>The configured host application builder.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/>, <paramref name="name"/>, or <paramref name="instructions"/> is null.</exception>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an AI agent to the host application builder with the specified name, instructions, and chat client key.
|
||||
/// </summary>
|
||||
/// <param name="builder">The host application builder to configure.</param>
|
||||
/// <param name="name">The name of the agent.</param>
|
||||
/// <param name="instructions">The instructions for the agent.</param>
|
||||
/// <param name="description">A description of the agent.</param>
|
||||
/// <param name="chatClientServiceKey">The key to use when resolving the chat client from the service provider. If null, a non-keyed service will be resolved.</param>
|
||||
/// <returns>The configured host application builder.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/>, <paramref name="name"/>, or <paramref name="instructions"/> is null.</exception>
|
||||
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<IChatClient>() : sp.GetRequiredKeyedService<IChatClient>(chatClientServiceKey);
|
||||
return new ChatClientAgent(chatClient, instructions: instructions, name: key, description: description);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an AI agent to the host application builder with the specified name, instructions, and chat client key.
|
||||
/// </summary>
|
||||
/// <param name="builder">The host application builder to configure.</param>
|
||||
/// <param name="name">The name of the agent.</param>
|
||||
/// <param name="instructions">The instructions for the agent.</param>
|
||||
/// <param name="chatClientServiceKey">The key to use when resolving the chat client from the service provider. If null, a non-keyed service will be resolved.</param>
|
||||
/// <returns>The configured host application builder.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/>, <paramref name="name"/>, or <paramref name="instructions"/> is null.</exception>
|
||||
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<IChatClient>() : sp.GetRequiredKeyedService<IChatClient>(chatClientServiceKey);
|
||||
return new ChatClientAgent(chatClient, instructions, key);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an AI agent to the host application builder using a custom factory delegate.
|
||||
/// </summary>
|
||||
/// <param name="builder">The host application builder to configure.</param>
|
||||
/// <param name="name">The name of the agent.</param>
|
||||
/// <param name="createAgentDelegate">A factory delegate that creates the AI agent instance. The delegate receives the service provider and agent key as parameters.</param>
|
||||
/// <returns>The configured host application builder.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/>, <paramref name="name"/>, or <paramref name="createAgentDelegate"/> is null.</exception>
|
||||
/// <exception cref="InvalidOperationException">Thrown when the agent factory delegate returns null or an invalid AI agent instance.</exception>
|
||||
public static IHostApplicationBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, Func<IServiceProvider, string, AIAgent> createAgentDelegate)
|
||||
{
|
||||
Throw.IfNull(builder);
|
||||
Throw.IfNull(name);
|
||||
Throw.IfNull(createAgentDelegate);
|
||||
builder.Services.AddKeyedSingleton<AIAgent>(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<AIAgent>(name),
|
||||
ctx,
|
||||
sp.GetRequiredService<ILogger<AgentActor>>()));
|
||||
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<AgentCatalog, LocalAgentCatalog>();
|
||||
}
|
||||
}
|
||||
@@ -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<string> _registeredAgents;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
|
||||
public LocalAgentCatalog(LocalAgentRegistry agentHostBuilder, IServiceProvider serviceProvider)
|
||||
{
|
||||
this._registeredAgents = [.. agentHostBuilder.AgentNames];
|
||||
this._serviceProvider = serviceProvider;
|
||||
}
|
||||
|
||||
public override async IAsyncEnumerable<AIAgent> GetAgentsAsync([EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
await Task.CompletedTask.ConfigureAwait(false);
|
||||
|
||||
foreach (var name in this._registeredAgents)
|
||||
{
|
||||
var agent = this._serviceProvider.GetKeyedService<AIAgent>(name);
|
||||
if (agent is not null)
|
||||
{
|
||||
yield return agent;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string> AgentNames { get; } = [];
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Microsoft.Extensions.AI.Agents.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// High-performance logging messages using LoggerMessage source generator.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>$(ProjectsTargetFrameworks)</TargetFrameworks>
|
||||
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsDebugTargetFrameworks)</TargetFrameworks>
|
||||
<VersionSuffix>alpha</VersionSuffix>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<InjectSharedThrow>true</InjectSharedThrow>
|
||||
<InjectDiagnosticClassesOnLegacy>true</InjectDiagnosticClassesOnLegacy>
|
||||
<InjectTrimAttributesOnLegacy>true</InjectTrimAttributesOnLegacy>
|
||||
</PropertyGroup>
|
||||
|
||||
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Microsoft.Extensions.AI.Agents.Abstractions\Microsoft.Extensions.AI.Agents.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\Microsoft.Extensions.AI.Agents.Runtime.Abstractions\Microsoft.Extensions.AI.Agents.Runtime.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\Microsoft.Extensions.AI.Agents.Runtime\Microsoft.Extensions.AI.Agents.Runtime.csproj" />
|
||||
<ProjectReference Include="..\Microsoft.Extensions.AI.Agents\Microsoft.Extensions.AI.Agents.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
|
||||
<PackageReference Include="System.Text.Json" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TargetFrameworkIdentifier)' != '.NETCoreApp'">
|
||||
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" />
|
||||
<PackageReference Include="System.Text.Json" />
|
||||
<PackageReference Include="Microsoft.Bcl.HashCode" />
|
||||
<PackageReference Include="System.Threading.Channels" />
|
||||
<PackageReference Include="System.Threading.Tasks.Extensions" />
|
||||
<PackageReference Include="System.Diagnostics.DiagnosticSource" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Microsoft.Extensions.AI.Agents.Hosting.UnitTests" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Source-generated JSON type information for use by all agent runtime abstractions.
|
||||
/// </summary>
|
||||
[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;
|
||||
@@ -33,4 +33,25 @@ public sealed class ActorResponse
|
||||
/// </summary>
|
||||
[JsonPropertyName("status")]
|
||||
public RequestStatus Status { get; init; }
|
||||
|
||||
/// <inheritdoc />
|
||||
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})";
|
||||
}
|
||||
}
|
||||
|
||||
+15
-1
@@ -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;
|
||||
/// <summary>
|
||||
/// Represents a handle to an actor response, allowing retrieval of the response data and status updates.
|
||||
/// </summary>
|
||||
public abstract class ActorResponseHandle
|
||||
public abstract class ActorResponseHandle : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Attempts to get the response from the request if it is immediately available.
|
||||
@@ -43,4 +44,17 @@ public abstract class ActorResponseHandle
|
||||
/// <param name="cancellationToken">A token to cancel the watch operation.</param>
|
||||
/// <returns>An asynchronous enumerable of request updates.</returns>
|
||||
public abstract IAsyncEnumerable<ActorRequestUpdate> WatchUpdatesAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose()
|
||||
{
|
||||
this.Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes of the resources used by the <see cref="ActorResponseHandle"/> class.
|
||||
/// </summary>
|
||||
/// <param name="disposing">A boolean indicating whether the method is being called from the <see cref="Dispose()"/> method.</param>
|
||||
protected virtual void Dispose(bool disposing) { }
|
||||
}
|
||||
|
||||
+81
@@ -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;
|
||||
|
||||
/// <summary>Provides a collection of utility methods for working with JSON data in the context of actor runtime abstractions.</summary>
|
||||
public static partial class AgentRuntimeAbstractionsJsonUtilities
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the <see cref="JsonSerializerOptions"/> singleton used as the default in JSON serialization operations.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// For Native AOT or applications disabling <see cref="JsonSerializer.IsReflectionEnabledByDefault"/>, this instance
|
||||
/// includes source generated contracts for all common exchange types contained in this library.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It additionally turns on the following settings:
|
||||
/// <list type="number">
|
||||
/// <item>Enables <see cref="JsonSerializerDefaults.Web"/> defaults.</item>
|
||||
/// <item>Enables <see cref="JsonIgnoreCondition.WhenWritingNull"/> as the default ignore condition for properties.</item>
|
||||
/// <item>Enables <see cref="JsonNumberHandling.AllowReadingFromString"/> as the default number handling for number types.</item>
|
||||
/// <item>Enables <see cref="JsonStringEnumConverter"/> for enum serialization.</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static JsonSerializerOptions DefaultOptions { get; } = CreateDefaultOptions();
|
||||
|
||||
/// <summary>
|
||||
/// Creates default options to use for actor runtime-related serialization.
|
||||
/// </summary>
|
||||
/// <returns>The configured options.</returns>
|
||||
[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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Source-generated JSON type information for use by all agent runtime abstractions.
|
||||
/// </summary>
|
||||
[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;
|
||||
}
|
||||
@@ -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<ActorType>
|
||||
{
|
||||
#if NET7_0_OR_GREATER
|
||||
[System.Diagnostics.CodeAnalysis.StringSyntax("Regex")]
|
||||
#endif
|
||||
private const string AgentTypeValidationRegex = "^[a-zA-Z_][a-zA-Z._:\\-0-9]*$";
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ActorId"/> struct.
|
||||
/// </summary>
|
||||
/// <param name="type">The actor type.</param>
|
||||
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<ActorType>
|
||||
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
|
||||
|
||||
/// <summary>
|
||||
/// Validates whether the provided type string is a valid actor type.
|
||||
/// </summary>
|
||||
public static bool IsValidType(string type)
|
||||
{
|
||||
Throw.IfNullOrEmpty(type);
|
||||
return TypeRegex().IsMatch(type);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// JSON converter for <see cref="ActorType"/>.
|
||||
/// </summary>
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Extensions.AI.Agents.Runtime;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods for the <see cref="RequestStatus"/> enumeration.
|
||||
/// </summary>
|
||||
public static class RequestStatusExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Determines if the request status indicates that the request has terminated.
|
||||
/// </summary>
|
||||
/// <param name="status">The request status to check.</param>
|
||||
/// <returns><see langword="true"/> if the request has terminated; otherwise, <see langword="false"/>.</returns>
|
||||
public static bool IsTerminated(this RequestStatus status) => status != RequestStatus.Pending;
|
||||
}
|
||||
+3
-1
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
/// <summary>Provides a collection of utility methods for working with JSON data in the context of agent runtime.</summary>
|
||||
public static partial class AgentRuntimeJsonUtilities
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the <see cref="JsonSerializerOptions"/> singleton used as the default in JSON serialization operations.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// For Native AOT or applications disabling <see cref="JsonSerializer.IsReflectionEnabledByDefault"/>, this instance
|
||||
/// includes source generated contracts for all common exchange types contained in this library.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It additionally turns on the following settings:
|
||||
/// <list type="number">
|
||||
/// <item>Enables <see cref="JsonSerializerDefaults.Web"/> defaults.</item>
|
||||
/// <item>Enables <see cref="JsonIgnoreCondition.WhenWritingNull"/> as the default ignore condition for properties.</item>
|
||||
/// <item>Enables <see cref="JsonNumberHandling.AllowReadingFromString"/> as the default number handling for number types.</item>
|
||||
/// <item>Enables <see cref="JsonStringEnumConverter"/> for enum serialization.</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static JsonSerializerOptions DefaultOptions { get; } = CreateDefaultOptions();
|
||||
|
||||
/// <summary>
|
||||
/// Creates default options to use for agent runtime-related serialization.
|
||||
/// </summary>
|
||||
/// <returns>The configured options.</returns>
|
||||
[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;
|
||||
}
|
||||
@@ -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<ActorStateWriteOperation>()];
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ActorResponseHandle> 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));
|
||||
|
||||
|
||||
@@ -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<ActorResponse> GetResponseAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return new ValueTask<ActorResponse>(this._response);
|
||||
}
|
||||
|
||||
public override bool TryGetResponse([NotNullWhen(true)] out ActorResponse? response)
|
||||
{
|
||||
response = this._response;
|
||||
return true;
|
||||
}
|
||||
|
||||
public override async IAsyncEnumerable<ActorRequestUpdate> WatchUpdatesAsync([EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
}
|
||||
+1
@@ -53,6 +53,7 @@ public class CosmosTestFixture : IAsyncLifetime
|
||||
UseSystemTextJsonSerializerWithOptions = new JsonSerializerOptions()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
TypeInfoResolver = CosmosActorStateJsonContext.Default
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="AgentActor"/>.
|
||||
/// </summary>
|
||||
public class AgentActorTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies that calling DisposeAsync completes successfully without throwing an exception.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task DisposeAsync_NoException_CompletesSuccessfullyAsync()
|
||||
{
|
||||
// Arrange
|
||||
var mockAgent = new Mock<AIAgent>();
|
||||
var mockContext = new Mock<IActorRuntimeContext>();
|
||||
var mockLogger = NullLoggerFactory.Instance.CreateLogger<AgentActor>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the <see cref="AgentProxy"/> constructor.
|
||||
/// </summary>
|
||||
public class AgentProxyTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies that the constructor assigns the Name property correctly for various valid agent names.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("agent")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("特殊字符")]
|
||||
[InlineData(" a")]
|
||||
public void Constructor_ValidName_SetsNameProperty(string name)
|
||||
{
|
||||
// Arrange
|
||||
var mockClient = new Mock<IActorClient>();
|
||||
|
||||
// Act
|
||||
var proxy = new AgentProxy(name, mockClient.Object);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(name, proxy.Name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that GetNewThread returns a non-null <see cref="AgentProxyThread"/> instance.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetNewThread_WhenCalled_ReturnsNewAgentProxyThreadInstance()
|
||||
{
|
||||
// Arrange
|
||||
var mockClient = new Mock<IActorClient>();
|
||||
var proxy = new AgentProxy("agentName", mockClient.Object);
|
||||
|
||||
// Act
|
||||
AgentThread result = proxy.GetNewThread();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.IsType<AgentProxyThread>(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that consecutive calls to GetNewThread return distinct instances.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetNewThread_MultipleCalls_ReturnsDistinctInstances()
|
||||
{
|
||||
// Arrange
|
||||
var mockClient = new Mock<IActorClient>();
|
||||
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<ChatMessage> s_emptyMessages = new List<ChatMessage>();
|
||||
|
||||
private static bool IsValidGuid(string value)
|
||||
{
|
||||
return Guid.TryParse(value, out _);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_WhenStatusIsCompleted_ReturnsDeserializedResponseAsync()
|
||||
{
|
||||
// Arrange
|
||||
var mockClient = new Mock<IActorClient>();
|
||||
var mockHandle = new Mock<ActorResponseHandle>();
|
||||
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<CancellationToken>()))
|
||||
.Returns(new ValueTask<ActorResponse>(actorResponse));
|
||||
mockClient
|
||||
.Setup(c => c.SendRequestAsync(It.IsAny<ActorRequest>(), It.IsAny<CancellationToken>()))
|
||||
.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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_WhenStatusIsFailed_ThrowsInvalidOperationExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var mockClient = new Mock<IActorClient>();
|
||||
var mockHandle = new Mock<ActorResponseHandle>();
|
||||
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<CancellationToken>()))
|
||||
.Returns(new ValueTask<ActorResponse>(actorResponse));
|
||||
mockClient
|
||||
.Setup(c => c.SendRequestAsync(It.IsAny<ActorRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(mockHandle.Object);
|
||||
|
||||
var proxy = new AgentProxy(AgentName, mockClient.Object);
|
||||
var thread = proxy.GetThread(ThreadId);
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
proxy.RunAsync(s_emptyMessages, thread));
|
||||
Assert.Equal("The agent run request failed: {}", exception.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that RunAsync throws an InvalidOperationException when the actor response status is Pending.
|
||||
/// Input: empty messages, threadId, Pending status.
|
||||
/// Expected: InvalidOperationException with pending message.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_WhenStatusIsPending_ThrowsInvalidOperationExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var mockClient = new Mock<IActorClient>();
|
||||
var mockHandle = new Mock<ActorResponseHandle>();
|
||||
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<CancellationToken>()))
|
||||
.Returns(new ValueTask<ActorResponse>(actorResponse));
|
||||
mockClient
|
||||
.Setup(c => c.SendRequestAsync(It.IsAny<ActorRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(mockHandle.Object);
|
||||
|
||||
var proxy = new AgentProxy(AgentName, mockClient.Object);
|
||||
var thread = proxy.GetThread(ThreadId);
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
proxy.RunAsync(s_emptyMessages, thread));
|
||||
Assert.Equal("The agent run request is still pending.", exception.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_WhenStatusIsUnsupported_ThrowsNotSupportedExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var mockClient = new Mock<IActorClient>();
|
||||
var mockHandle = new Mock<ActorResponseHandle>();
|
||||
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<CancellationToken>()))
|
||||
.Returns(new ValueTask<ActorResponse>(actorResponse));
|
||||
mockClient
|
||||
.Setup(c => c.SendRequestAsync(It.IsAny<ActorRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(mockHandle.Object);
|
||||
|
||||
var proxy = new AgentProxy(AgentName, mockClient.Object);
|
||||
var thread = proxy.GetThread(ThreadId);
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<NotSupportedException>(() =>
|
||||
proxy.RunAsync(s_emptyMessages, thread));
|
||||
Assert.Equal($"The agent run request returned an unsupported status: {RequestStatus.NotFound}.", exception.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that passing an AgentThread that is not an AgentProxyThread to RunStreamingAsync throws an ArgumentException.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async System.Threading.Tasks.Task RunStreamingAsync_InvalidThread_ThrowsArgumentExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var mockClient = new Mock<IActorClient>();
|
||||
var proxy = new AgentProxy("testAgent", mockClient.Object);
|
||||
AgentThread invalidThread = new Mock<AgentThread>().Object;
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentException>(async () =>
|
||||
{
|
||||
await foreach (var _ in proxy.RunStreamingAsync(Array.Empty<ChatMessage>(), invalidThread, cancellationToken: CancellationToken.None))
|
||||
{
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[Fact(Skip = "Mocking of ActorResponseHandle.WatchUpdatesAsync with IActorClient is required")]
|
||||
public async System.Threading.Tasks.Task RunStreamingAsync_ValidProxyThread_CompletesSuccessfullyAsync()
|
||||
{
|
||||
// Arrange
|
||||
var mockClient = new Mock<IActorClient>();
|
||||
var proxy = new AgentProxy("testAgent", mockClient.Object);
|
||||
var proxyThread = new AgentProxyThread();
|
||||
|
||||
// Act & Assert
|
||||
await foreach (var _ in proxy.RunStreamingAsync(Array.Empty<ChatMessage>(), proxyThread, cancellationToken: CancellationToken.None))
|
||||
{
|
||||
// No items expected
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(RequestStatus.Completed)]
|
||||
[InlineData(RequestStatus.Pending)]
|
||||
public async Task RunStreamingAsync_NonFailedStatus_YieldsAgentRunResponseUpdateAsync(RequestStatus status)
|
||||
{
|
||||
// Arrange
|
||||
var messages = Array.Empty<ChatMessage>();
|
||||
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<ChatMessage> { 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<ActorResponseHandle>();
|
||||
mockHandle
|
||||
.Setup(h => h.WatchUpdatesAsync(It.IsAny<CancellationToken>()))
|
||||
.Returns(GetAsyncEnumerableAsync(actorUpdate));
|
||||
var mockClient = new Mock<IActorClient>();
|
||||
mockClient
|
||||
.Setup(c => c.SendRequestAsync(It.IsAny<ActorRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(mockHandle.Object);
|
||||
|
||||
var proxy = new AgentProxy("agentName", mockClient.Object);
|
||||
var thread = proxy.GetThread(threadId);
|
||||
|
||||
// Act
|
||||
var results = new List<AgentRunResponseUpdate>();
|
||||
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<ActorRequestUpdate> GetAsyncEnumerableAsync(ActorRequestUpdate update)
|
||||
{
|
||||
yield return update;
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that RunStreamingAsync throws InvalidOperationException when an update status is Failed.
|
||||
/// Uses a mock IActorClient to return a Failed update. Expected: InvalidOperationException is thrown.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunStreamingAsync_FailedStatus_ThrowsInvalidOperationExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var messages = Array.Empty<ChatMessage>();
|
||||
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<ActorResponseHandle>();
|
||||
mockHandle
|
||||
.Setup(h => h.WatchUpdatesAsync(It.IsAny<CancellationToken>()))
|
||||
.Returns(GetAsyncEnumerableAsync(actorUpdate));
|
||||
var mockClient = new Mock<IActorClient>();
|
||||
mockClient
|
||||
.Setup(c => c.SendRequestAsync(It.IsAny<ActorRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(mockHandle.Object);
|
||||
|
||||
var proxy = new AgentProxy("agentName", mockClient.Object);
|
||||
var thread = proxy.GetThread(threadId);
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
|
||||
{
|
||||
await foreach (var update in proxy.RunStreamingAsync(messages, thread))
|
||||
{
|
||||
// force enumeration
|
||||
}
|
||||
});
|
||||
Assert.Contains("The agent run request failed", exception.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that constructor throws ArgumentNullException when client is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_NullClient_ThrowsArgumentNullException()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => new AgentProxy("agentName", null!));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that constructor throws ArgumentNullException when name is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_NullName_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
var mockClient = new Mock<IActorClient>();
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => new AgentProxy(null!, mockClient.Object));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that constructor throws ArgumentException when name is empty.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_EmptyName_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange
|
||||
var mockClient = new Mock<IActorClient>();
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentException>(() => new AgentProxy("", mockClient.Object));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that RunAsync with thread overload validates null messages.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_WithThread_NullMessages_ThrowsArgumentNullExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var mockClient = new Mock<IActorClient>();
|
||||
var proxy = new AgentProxy("agentName", mockClient.Object);
|
||||
var thread = new AgentProxyThread();
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||
proxy.RunAsync(messages: null!, thread, null, CancellationToken.None));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that RunAsync with thread overload throws for invalid thread type.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_WithInvalidThreadType_ThrowsArgumentExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var mockClient = new Mock<IActorClient>();
|
||||
var proxy = new AgentProxy("agentName", mockClient.Object);
|
||||
var invalidThread = new Mock<AgentThread>().Object;
|
||||
var messages = new List<ChatMessage> { new(ChatRole.User, "test") };
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<ArgumentException>(() =>
|
||||
proxy.RunAsync(messages, invalidThread, null, CancellationToken.None));
|
||||
Assert.Contains("thread must be an instance of AgentProxyThread", exception.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that RunAsync with thread overload creates new thread ID when thread is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_WithNullThread_CreatesNewThreadIdAsync()
|
||||
{
|
||||
// Arrange
|
||||
var mockClient = new Mock<IActorClient>();
|
||||
var mockHandle = new Mock<ActorResponseHandle>();
|
||||
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<CancellationToken>()))
|
||||
.Returns(new ValueTask<ActorResponse>(actorResponse));
|
||||
|
||||
mockClient.Setup(c => c.SendRequestAsync(It.IsAny<ActorRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(mockHandle.Object);
|
||||
|
||||
var proxy = new AgentProxy("agentName", mockClient.Object);
|
||||
var messages = new List<ChatMessage> { 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<ActorRequest>(r => !string.IsNullOrEmpty(r.ActorId.Key)),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that RunAsync handles cancellation properly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_CancellationRequested_ThrowsOperationCanceledExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var mockClient = new Mock<IActorClient>();
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
mockClient.Setup(c => c.SendRequestAsync(It.IsAny<ActorRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new OperationCanceledException());
|
||||
|
||||
var proxy = new AgentProxy("agentName", mockClient.Object);
|
||||
var messages = new List<ChatMessage> { new(ChatRole.User, "test") };
|
||||
var thread = proxy.GetThread(ThreadId);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<OperationCanceledException>(() =>
|
||||
proxy.RunAsync(messages, thread, cancellationToken: cts.Token));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that RunStreamingAsync with thread overload validates null messages.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunStreamingAsync_WithThread_NullMessages_ThrowsArgumentNullExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var mockClient = new Mock<IActorClient>();
|
||||
var proxy = new AgentProxy("agentName", mockClient.Object);
|
||||
var thread = new AgentProxyThread();
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(async () =>
|
||||
{
|
||||
await foreach (var _ in proxy.RunStreamingAsync(messages: null!, thread, null, CancellationToken.None))
|
||||
{
|
||||
// force enumeration
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that RunStreamingAsync with thread overload throws for invalid thread type.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunStreamingAsync_WithInvalidThreadType_ThrowsArgumentExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var mockClient = new Mock<IActorClient>();
|
||||
var proxy = new AgentProxy("agentName", mockClient.Object);
|
||||
var invalidThread = new Mock<AgentThread>().Object;
|
||||
var messages = new List<ChatMessage> { new(ChatRole.User, "test") };
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<ArgumentException>(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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that RunStreamingAsync handles cancellation during enumeration.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunStreamingAsync_CancellationDuringEnumeration_StopsEnumerationAsync()
|
||||
{
|
||||
// Arrange
|
||||
var mockClient = new Mock<IActorClient>();
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
var updates = new List<ActorRequestUpdate>
|
||||
{
|
||||
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<ActorRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(fakeHandle);
|
||||
|
||||
var proxy = new AgentProxy("agentName", mockClient.Object);
|
||||
var thread = proxy.GetThread(ThreadId);
|
||||
var messages = new List<ChatMessage> { new(ChatRole.User, "test") };
|
||||
|
||||
// Act
|
||||
var receivedUpdates = new List<AgentRunResponseUpdate>();
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(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
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that RunAsync correctly uses message ID from last message if available.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_UsesLastMessageId_WhenAvailableAsync()
|
||||
{
|
||||
// Arrange
|
||||
var mockClient = new Mock<IActorClient>();
|
||||
var mockHandle = new Mock<ActorResponseHandle>();
|
||||
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<CancellationToken>()))
|
||||
.Returns(new ValueTask<ActorResponse>(actorResponse));
|
||||
|
||||
mockClient.Setup(c => c.SendRequestAsync(It.IsAny<ActorRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(mockHandle.Object);
|
||||
|
||||
var proxy = new AgentProxy("agentName", mockClient.Object);
|
||||
var thread = proxy.GetThread(ThreadId);
|
||||
var messages = new List<ChatMessage>
|
||||
{
|
||||
new(ChatRole.User, "first"),
|
||||
new(ChatRole.User, "last") { MessageId = expectedMessageId }
|
||||
};
|
||||
|
||||
// Act
|
||||
await proxy.RunAsync(messages, thread);
|
||||
|
||||
// Assert
|
||||
mockClient.Verify(c => c.SendRequestAsync(
|
||||
It.Is<ActorRequest>(r => r.MessageId == expectedMessageId),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that RunAsync generates new message ID when last message has no ID.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_GeneratesMessageId_WhenLastMessageHasNoIdAsync()
|
||||
{
|
||||
// Arrange
|
||||
var mockClient = new Mock<IActorClient>();
|
||||
var mockHandle = new Mock<ActorResponseHandle>();
|
||||
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<CancellationToken>()))
|
||||
.Returns(new ValueTask<ActorResponse>(actorResponse));
|
||||
|
||||
mockClient.Setup(c => c.SendRequestAsync(It.IsAny<ActorRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(mockHandle.Object);
|
||||
|
||||
var proxy = new AgentProxy("agentName", mockClient.Object);
|
||||
var thread = proxy.GetThread(ThreadId);
|
||||
var messages = new List<ChatMessage> { new(ChatRole.User, "test") };
|
||||
|
||||
// Act
|
||||
await proxy.RunAsync(messages, thread);
|
||||
|
||||
// Assert
|
||||
mockClient.Verify(c => c.SendRequestAsync(
|
||||
It.Is<ActorRequest>(r => !string.IsNullOrEmpty(r.MessageId) && IsValidGuid(r.MessageId)),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that GetNewThread returns unique instances with unique IDs.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetNewThread_MultipleCalls_ReturnsUniqueThreadsWithUniqueIds()
|
||||
{
|
||||
// Arrange
|
||||
var mockClient = new Mock<IActorClient>();
|
||||
var proxy = new AgentProxy("agentName", mockClient.Object);
|
||||
var threads = new List<AgentThread>();
|
||||
|
||||
// Act
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
threads.Add(proxy.GetNewThread());
|
||||
}
|
||||
|
||||
// Assert
|
||||
var threadIds = threads.Cast<AgentProxyThread>().Select(t => t.ConversationId).ToList();
|
||||
Assert.Equal(10, threadIds.Count);
|
||||
Assert.Equal(10, threadIds.Distinct().Count()); // All IDs should be unique
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fake implementation of ActorResponseHandle for testing purposes.
|
||||
/// </summary>
|
||||
private sealed class FakeActorResponseHandle : ActorResponseHandle
|
||||
{
|
||||
private readonly List<ActorRequestUpdate> _updates;
|
||||
private readonly CancellationTokenSource _cancellationTokenSource;
|
||||
private readonly ActorResponse? _response;
|
||||
private readonly int _delayBetweenUpdates;
|
||||
|
||||
public FakeActorResponseHandle(
|
||||
List<ActorRequestUpdate> 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<ActorResponse> GetResponseAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (this._response == null)
|
||||
{
|
||||
throw new InvalidOperationException("No response configured");
|
||||
}
|
||||
return new ValueTask<ActorResponse>(this._response);
|
||||
}
|
||||
|
||||
public override ValueTask CancelAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
this._cancellationTokenSource.Cancel();
|
||||
return default;
|
||||
}
|
||||
|
||||
public override async IAsyncEnumerable<ActorRequestUpdate> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+305
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Provides valid identifier values that conform to RFC 3986 unreserved characters.
|
||||
/// </summary>
|
||||
public static IEnumerable<object[]> ValidIds => new List<object[]>
|
||||
{
|
||||
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
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Provides invalid identifier values that violate the RFC 3986 unreserved character rules.
|
||||
/// </summary>
|
||||
public static IEnumerable<object[]> InvalidIds => new List<object[]>
|
||||
{
|
||||
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
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that providing valid id to <see cref="AgentProxyThread"/> constructor sets the Id property correctly.
|
||||
/// </summary>
|
||||
/// <param name="id">The valid identifier to test.</param>
|
||||
[Theory]
|
||||
[MemberData(nameof(ValidIds))]
|
||||
public void Constructor_ValidId_SetsIdProperty(string id)
|
||||
{
|
||||
// Act
|
||||
var thread = new AgentProxyThread(id);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(id, thread.ConversationId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that providing invalid id to <see cref="AgentProxyThread"/> constructor throws an <see cref="ArgumentException"/>.
|
||||
/// </summary>
|
||||
/// <param name="id">The invalid identifier to test.</param>
|
||||
[Theory]
|
||||
[MemberData(nameof(InvalidIds))]
|
||||
public void Constructor_InvalidId_ThrowsArgumentException(string id)
|
||||
{
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<ArgumentException>(() => new AgentProxyThread(id));
|
||||
Assert.Contains("Thread ID", exception.Message);
|
||||
Assert.Contains("alphanumeric characters, hyphens, underscores, dots, and tildes", exception.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that providing a null id to <see cref="AgentProxyThread"/> constructor throws an <see cref="ArgumentNullException"/>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_NullId_ThrowsArgumentNullException()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => new AgentProxyThread(null!));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that providing an empty id to <see cref="AgentProxyThread"/> constructor throws an <see cref="ArgumentException"/>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_EmptyId_ThrowsArgumentException()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentException>(() => new AgentProxyThread(""));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the default constructor initializes the Id property with a valid non-empty GUID string in "N" format.
|
||||
/// </summary>
|
||||
[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.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that successive default constructors produce unique Id values.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_Default_CreatesUniqueIds()
|
||||
{
|
||||
// Arrange & Act
|
||||
var thread1 = new AgentProxyThread();
|
||||
var thread2 = new AgentProxyThread();
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(thread1.ConversationId, thread2.ConversationId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that CreateId returns a non-null, non-empty 32-character hexadecimal string without dashes.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that multiple calls to CreateId produce unique identifiers.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CreateId_MultipleCalls_ReturnUniqueValues()
|
||||
{
|
||||
// Arrange & Act
|
||||
string id1 = AgentProxyThread.CreateId();
|
||||
string id2 = AgentProxyThread.CreateId();
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(id1, id2);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that ManyCallsInParallel produces unique values across many calls.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that CreateId generates IDs that pass validation.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies specific edge cases for valid IDs.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies specific edge cases for invalid IDs.
|
||||
/// </summary>
|
||||
[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<ArgumentException>(() => new AgentProxyThread(id));
|
||||
Assert.Contains("Thread ID", exception.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AgentProxyThread inherits from AgentThread.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AgentProxyThread_InheritsFromAgentThread()
|
||||
{
|
||||
// Arrange & Act
|
||||
var thread = new AgentProxyThread();
|
||||
|
||||
// Assert
|
||||
Assert.IsAssignableFrom<AgentThread>(thread);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that Id property is accessible.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Id_IsAccessible()
|
||||
{
|
||||
// Arrange & Act
|
||||
var thread = new AgentProxyThread("test-id");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(thread.ConversationId);
|
||||
Assert.Equal("test-id", thread.ConversationId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that thread ID remains immutable after construction.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Id_IsImmutable()
|
||||
{
|
||||
// Arrange
|
||||
const string OriginalId = "immutable-id";
|
||||
var thread = new AgentProxyThread(OriginalId);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Equal(OriginalId, thread.ConversationId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that default constructor creates thread with valid GUID format.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
}
|
||||
+332
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies that providing a null builder to AddAIAgent throws an ArgumentNullException.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAIAgent_NullBuilder_ThrowsArgumentNullException()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(
|
||||
() => HostApplicationBuilderAgentExtensions.AddAIAgent(null!, "agent", "instructions"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent with valid parameters returns the same builder instance.
|
||||
/// </summary>
|
||||
/// <param name="chatClientKey">The chat client key to use, or null to use the default service.</param>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent without chat client key throws ArgumentNullException for null name.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAIAgent_NullName_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new HostApplicationBuilder();
|
||||
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<ArgumentNullException>(() =>
|
||||
builder.AddAIAgent(null!, "instructions"));
|
||||
Assert.Equal("name", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent without chat client key allows null instructions.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAIAgent_NullInstructions_AllowsNull()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new HostApplicationBuilder();
|
||||
|
||||
// Act
|
||||
var result = builder.AddAIAgent("agentName", (string)null!);
|
||||
|
||||
// Assert
|
||||
Assert.Same(builder, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent with chat client key throws ArgumentNullException for null name.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAIAgentWithKey_NullName_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new HostApplicationBuilder();
|
||||
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<ArgumentNullException>(() =>
|
||||
builder.AddAIAgent(null!, "instructions", "key"));
|
||||
Assert.Equal("name", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent with chat client key allows null instructions.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAIAgentWithKey_NullInstructions_AllowsNull()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new HostApplicationBuilder();
|
||||
|
||||
// Act
|
||||
var result = builder.AddAIAgent("agentName", null!, "key");
|
||||
|
||||
// Assert
|
||||
Assert.Same(builder, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent with factory delegate throws ArgumentNullException for null builder.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAIAgentWithFactory_NullBuilder_ThrowsArgumentNullException()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
HostApplicationBuilderAgentExtensions.AddAIAgent(
|
||||
null!,
|
||||
"agentName",
|
||||
(sp, key) => new Mock<AIAgent>().Object));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent with factory delegate throws ArgumentNullException for null name.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAIAgentWithFactory_NullName_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new HostApplicationBuilder();
|
||||
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<ArgumentNullException>(() =>
|
||||
builder.AddAIAgent(null!, (sp, key) => new Mock<AIAgent>().Object));
|
||||
Assert.Equal("name", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent with factory delegate throws ArgumentNullException for null factory.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAIAgentWithFactory_NullFactory_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new HostApplicationBuilder();
|
||||
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<ArgumentNullException>(() =>
|
||||
builder.AddAIAgent("agentName", (Func<IServiceProvider, string, AIAgent>)null!));
|
||||
Assert.Equal("createAgentDelegate", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent with factory delegate returns the same builder instance.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAIAgentWithFactory_ValidParameters_ReturnsBuilder()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new HostApplicationBuilder();
|
||||
var mockAgent = new Mock<AIAgent>();
|
||||
|
||||
// Act
|
||||
var result = builder.AddAIAgent("agentName", (sp, key) => mockAgent.Object);
|
||||
|
||||
// Assert
|
||||
Assert.Same(builder, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent registers the agent as a keyed singleton service.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAIAgent_RegistersKeyedSingleton()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new HostApplicationBuilder();
|
||||
var mockAgent = new Mock<AIAgent>();
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent can be called multiple times with different agent names.
|
||||
/// </summary>
|
||||
[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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent handles empty strings for name.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAIAgent_EmptyName_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new HostApplicationBuilder();
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
builder.AddAIAgent("", "instructions"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent allows empty strings for instructions.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAIAgent_EmptyInstructions_Succeeds()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new HostApplicationBuilder();
|
||||
|
||||
// Act
|
||||
var result = builder.AddAIAgent("agentName", "");
|
||||
|
||||
// Assert
|
||||
Assert.Same(builder, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent with whitespace name throws ArgumentException.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(" ")]
|
||||
[InlineData("\t")]
|
||||
[InlineData(" agent ")]
|
||||
public void AddAIAgent_WhitespaceName_ThrowsArgumentException(string name)
|
||||
{
|
||||
// Arrange
|
||||
var builder = new HostApplicationBuilder();
|
||||
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<ArgumentException>(() =>
|
||||
builder.AddAIAgent(name, "instructions"));
|
||||
Assert.Contains("Invalid type", exception.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent without chat client key calls the overload with null key.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent with special characters in name works correctly for valid names.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent with invalid special characters throws ArgumentException.
|
||||
/// </summary>
|
||||
[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<ArgumentException>(() =>
|
||||
builder.AddAIAgent(name, "instructions"));
|
||||
Assert.Contains("Invalid type", exception.Message);
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>$(ProjectsTargetFrameworks)</TargetFrameworks>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Microsoft.Extensions.AI.Agents.Hosting\Microsoft.Extensions.AI.Agents.Hosting.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+297
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Provides valid ActorType names that conform to the regex pattern ^[a-zA-Z_][a-zA-Z._:\-0-9]*$.
|
||||
/// </summary>
|
||||
public static IEnumerable<object[]> ValidActorTypeNames => new List<object[]>
|
||||
{
|
||||
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
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Provides invalid ActorType names that violate the regex pattern ^[a-zA-Z_][a-zA-Z._:\-0-9]*$.
|
||||
/// </summary>
|
||||
public static IEnumerable<object[]> InvalidActorTypeNames => new List<object[]>
|
||||
{
|
||||
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
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that providing valid actor type name to <see cref="ActorType"/> constructor sets the Name property correctly.
|
||||
/// </summary>
|
||||
/// <param name="typeName">The valid type name to test.</param>
|
||||
[Theory]
|
||||
[MemberData(nameof(ValidActorTypeNames))]
|
||||
public void Constructor_ValidTypeName_SetsNameProperty(string typeName)
|
||||
{
|
||||
// Act
|
||||
var actorType = new ActorType(typeName);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(typeName, actorType.Name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that providing invalid actor type name to <see cref="ActorType"/> constructor throws an <see cref="ArgumentException"/>.
|
||||
/// </summary>
|
||||
/// <param name="typeName">The invalid type name to test.</param>
|
||||
[Theory]
|
||||
[MemberData(nameof(InvalidActorTypeNames))]
|
||||
public void Constructor_InvalidTypeName_ThrowsArgumentException(string typeName)
|
||||
{
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<ArgumentException>(() => 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that providing a null type name to <see cref="ActorType"/> constructor throws an <see cref="ArgumentNullException"/>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_NullTypeName_ThrowsArgumentNullException()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => new ActorType(null!));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that providing an empty type name to <see cref="ActorType"/> constructor throws an <see cref="ArgumentException"/>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_EmptyTypeName_ThrowsArgumentException()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentException>(() => new ActorType(""));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies specific edge cases for valid type names.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies specific edge cases for invalid type names.
|
||||
/// </summary>
|
||||
[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<ArgumentException>(() => new ActorType(typeName));
|
||||
Assert.Contains("Invalid type", exception.Message);
|
||||
Assert.Contains("Must start with a letter or underscore", exception.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that ToString returns the type name.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies equality comparison between ActorType instances.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies inequality comparison between ActorType instances.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that GetHashCode returns same value for equal instances.
|
||||
/// </summary>
|
||||
[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());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that ActorType is case sensitive.
|
||||
/// </summary>
|
||||
[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());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that IsValidType static method works correctly for valid names.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[MemberData(nameof(ValidActorTypeNames))]
|
||||
public void IsValidType_ValidTypeName_ReturnsTrue(string typeName)
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.True(ActorType.IsValidType(typeName));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that IsValidType static method works correctly for invalid names.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[MemberData(nameof(InvalidActorTypeNames))]
|
||||
public void IsValidType_InvalidTypeName_ReturnsFalse(string typeName)
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.False(ActorType.IsValidType(typeName));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that IsValidType throws for null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void IsValidType_NullTypeName_ThrowsArgumentNullException()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => ActorType.IsValidType(null!));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that IsValidType throws for empty string.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void IsValidType_EmptyTypeName_ThrowsArgumentException()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentException>(() => ActorType.IsValidType(""));
|
||||
}
|
||||
}
|
||||
+201
-1
@@ -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<ActorResponse>(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<object>();
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user