.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:
Reuben Bond
2025-08-06 14:26:36 -07:00
committed by GitHub
Unverified
parent 8dcc8533a6
commit e7441ee29e
86 changed files with 4388 additions and 1229 deletions
+2 -1
View File
@@ -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'">
+4
View File
@@ -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" />
+24 -65
View File
@@ -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>
@@ -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; }
}
}
@@ -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>
@@ -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);
}
}
}
}
@@ -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();
@@ -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"
@@ -3,7 +3,7 @@
using System.Data.Common;
using System.Diagnostics.CodeAnalysis;
namespace HelloHttpApi.ApiService.Utilities;
namespace AgentWebChat.AgentHost.Utilities;
public class ChatClientConnectionInfo
{
@@ -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,7 +1,7 @@
{
"Logging": {
"LogLevel": {
"Default": "Trace",
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
@@ -1,7 +1,7 @@
{
"Logging": {
"LogLevel": {
"Default": "Trace",
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
@@ -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,6 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
namespace HelloHttpApi.AppHost;
namespace AgentWebChat.AppHost;
public static class ModelExtensions
{
@@ -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,7 +1,7 @@
{
"Logging": {
"LogLevel": {
"Default": "Trace",
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
@@ -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; }
}
}
@@ -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>
@@ -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;
});
}
};
");
}
}
}
@@ -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;
}
}
}
@@ -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,7 +1,7 @@
{
"Logging": {
"LogLevel": {
"Default": "Trace",
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
@@ -1,7 +1,7 @@
{
"Logging": {
"LogLevel": {
"Default": "Trace",
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
@@ -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

@@ -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");
}
}
@@ -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; } = [];
}
@@ -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; }
}
}
@@ -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; }
}
@@ -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);
}
@@ -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})";
}
}
@@ -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) { }
}
@@ -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>
@@ -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;
}
@@ -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;
}
}
@@ -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);
}
}
}
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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>
@@ -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(""));
}
}
@@ -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
}