diff --git a/dotnet/Directory.Build.props b/dotnet/Directory.Build.props
index 695cbd32bd..1600c872de 100644
--- a/dotnet/Directory.Build.props
+++ b/dotnet/Directory.Build.props
@@ -14,6 +14,7 @@
net9.0;net8.0;netstandard2.0;net472
net9.0;net472
true
+ Debug;Release;Publish
@@ -22,7 +23,7 @@
- disable
+ false
diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props
index 19c7cb6f84..8b2bdc776d 100644
--- a/dotnet/Directory.Packages.props
+++ b/dotnet/Directory.Packages.props
@@ -17,6 +17,7 @@
+
@@ -27,8 +28,10 @@
+
+
@@ -40,6 +43,7 @@
+
diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx
index 7e976a0493..6420da7f0a 100644
--- a/dotnet/agent-framework-dotnet.slnx
+++ b/dotnet/agent-framework-dotnet.slnx
@@ -5,48 +5,24 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
-
-
+
+
+
+
+
+
+
@@ -143,37 +119,20 @@
+
-
-
-
-
-
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/ActorFrameworkWebApplicationExtensions.cs b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/ActorFrameworkWebApplicationExtensions.cs
new file mode 100644
index 0000000000..7dfdaf5263
--- /dev/null
+++ b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/ActorFrameworkWebApplicationExtensions.cs
@@ -0,0 +1,40 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Diagnostics.CodeAnalysis;
+using System.Text.Json.Serialization;
+using Microsoft.Extensions.AI.Agents.Hosting;
+
+namespace AgentWebChat.AgentHost;
+
+internal static class ActorFrameworkWebApplicationExtensions
+{
+ public static void MapAgentDiscovery(this IEndpointRouteBuilder endpoints, [StringSyntax("Route")] string path)
+ {
+ var routeGroup = endpoints.MapGroup(path);
+ routeGroup.MapGet("/", async (
+ AgentCatalog agentCatalog,
+ CancellationToken cancellationToken) =>
+ {
+ var results = new List();
+ await foreach (var result in agentCatalog.GetAgentsAsync(cancellationToken).ConfigureAwait(false))
+ {
+ results.Add(new AgentDiscoveryCard
+ {
+ Name = result.Name!,
+ Description = result.Description,
+ });
+ }
+
+ return Results.Ok(results);
+ })
+ .WithName("GetAgents");
+ }
+
+ internal sealed class AgentDiscoveryCard
+ {
+ public required string Name { get; set; }
+
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? Description { get; set; }
+ }
+}
diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/HelloHttpApi.ApiService.csproj b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/AgentWebChat.AgentHost.csproj
similarity index 76%
rename from dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/HelloHttpApi.ApiService.csproj
rename to dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/AgentWebChat.AgentHost.csproj
index 6efdd1f81e..bfb23fa3ff 100644
--- a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/HelloHttpApi.ApiService.csproj
+++ b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/AgentWebChat.AgentHost.csproj
@@ -4,16 +4,18 @@
net9.0
enable
enable
+ true
+
-
+
@@ -25,6 +27,9 @@
+
+
+
diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/HttpActorApiRouteBuilderExtensions.cs b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/HttpActorApiRouteBuilderExtensions.cs
new file mode 100644
index 0000000000..4b00c8ef76
--- /dev/null
+++ b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/HttpActorApiRouteBuilderExtensions.cs
@@ -0,0 +1,72 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.AI.Agents.Runtime;
+
+namespace AgentWebChat.AgentHost;
+
+internal static partial class HttpActorApiRouteBuilderExtensions
+{
+ private const string BasePath = "/actors/v1";
+
+ public static void MapActors(this IEndpointRouteBuilder endpoints, IActorClient? actorClient = null, [StringSyntax("Route")] string? path = default)
+ {
+ path ??= BasePath;
+ actorClient ??= endpoints.ServiceProvider.GetRequiredService();
+
+ var routeGroup = endpoints.MapGroup(path);
+
+ // GET /actors/v1/{actorType}/{actorKey}/{messageId}
+ routeGroup.MapGet(
+ "/{actorType}/{actorKey}/{messageId}", async (
+ string actorType,
+ string actorKey,
+ string messageId,
+ [FromQuery] bool? blocking,
+ [FromQuery] bool? streaming,
+ HttpContext context,
+ CancellationToken cancellationToken) =>
+ await HttpActorProcessor.GetResponseAsync(
+ actorType,
+ actorKey,
+ messageId,
+ blocking: blocking,
+ streaming: streaming,
+ context,
+ actorClient,
+ cancellationToken))
+ .WithName("GetActorResponse");
+
+ // POST /actors/v1/{actorType}/{actorKey}/{messageId}
+ routeGroup.MapPost(
+ "/{actorType}/{actorKey}/{messageId}", async (
+ string actorType,
+ string actorKey,
+ string messageId,
+ [FromQuery] bool? blocking,
+ [FromQuery] bool? streaming,
+ [FromBody] ActorRequest request,
+ CancellationToken cancellationToken) =>
+ await HttpActorProcessor.SendRequestAsync(
+ actorType,
+ actorKey,
+ messageId,
+ blocking: blocking,
+ streaming: streaming,
+ request,
+ actorClient,
+ cancellationToken))
+ .WithName("SendActorRequest");
+
+ // POST /actors/v1/{actorType}/{actorKey}/{messageId}:cancel
+ routeGroup.MapPost(
+ "/{actorType}/{actorKey}/{messageId}:cancel", async (
+ string actorType,
+ string actorKey,
+ string messageId,
+ CancellationToken cancellationToken) =>
+ await HttpActorProcessor.CancelRequestAsync(actorType, actorKey, messageId, actorClient, cancellationToken))
+ .WithName("CancelActorRequest");
+ }
+}
diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/HttpActorProcessor.cs b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/HttpActorProcessor.cs
new file mode 100644
index 0000000000..6b4ec34483
--- /dev/null
+++ b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/HttpActorProcessor.cs
@@ -0,0 +1,145 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Text.Json;
+using Microsoft.AspNetCore.Http.Features;
+using Microsoft.Extensions.AI.Agents.Hosting;
+using Microsoft.Extensions.AI.Agents.Runtime;
+
+namespace AgentWebChat.AgentHost;
+
+internal static class HttpActorProcessor
+{
+ public static async Task GetResponseAsync(
+ string actorType,
+ string actorKey,
+ string messageId,
+ bool? blocking,
+ bool? streaming,
+ HttpContext context,
+ IActorClient actorClient,
+ CancellationToken cancellationToken)
+ {
+ var actorId = new ActorId(actorType, actorKey);
+
+ var responseHandle = await actorClient.GetResponseAsync(actorId, messageId, cancellationToken);
+
+ if (responseHandle.TryGetResponse(out var response))
+ {
+ return GetResult(response);
+ }
+
+ if (streaming == true)
+ {
+ return new ActorUpdateStreamingResult(responseHandle);
+ }
+
+ if (blocking == true)
+ {
+ response = await responseHandle.GetResponseAsync(cancellationToken);
+ return GetResult(response);
+ }
+
+ return Results.Ok(new ActorResponse
+ {
+ ActorId = actorId,
+ MessageId = messageId,
+ Status = RequestStatus.Pending,
+ Data = JsonDocument.Parse("{}").RootElement
+ });
+ }
+
+ public static async Task SendRequestAsync(
+ string actorType,
+ string actorKey,
+ string messageId,
+ bool? blocking,
+ bool? streaming,
+ ActorRequest request,
+ IActorClient actorClient,
+ CancellationToken cancellationToken)
+ {
+ var responseHandle = await actorClient.SendRequestAsync(request, cancellationToken);
+ if (responseHandle.TryGetResponse(out var response))
+ {
+ return GetResult(response);
+ }
+
+ if (streaming == true)
+ {
+ return new ActorUpdateStreamingResult(responseHandle);
+ }
+
+ if (blocking == true)
+ {
+ response = await responseHandle.GetResponseAsync(cancellationToken);
+ return GetResult(response);
+ }
+
+ return Results.Accepted();
+ }
+
+ private static IResult GetResult(ActorResponse response)
+ {
+ if (response.Status == RequestStatus.NotFound)
+ {
+ return Results.NotFound();
+ }
+
+ return Results.Ok(response);
+ }
+
+ public static async Task CancelRequestAsync(
+ string actorType,
+ string actorKey,
+ string messageId,
+ IActorClient actorClient,
+ CancellationToken cancellationToken)
+ {
+ var actorId = new ActorId(actorType, actorKey);
+ var responseHandle = await actorClient.GetResponseAsync(actorId, messageId, cancellationToken);
+
+ if (responseHandle.TryGetResponse(out var response))
+ {
+ if (response.Status is RequestStatus.NotFound)
+ {
+ return Results.NotFound();
+ }
+ else if (response.Status is RequestStatus.Completed or RequestStatus.Failed)
+ {
+ return Results.Conflict("The request has already completed and cannot be cancelled.");
+ }
+ }
+
+ await responseHandle.CancelAsync(cancellationToken);
+ return Results.NoContent();
+ }
+
+ private sealed class ActorUpdateStreamingResult(
+ ActorResponseHandle responseHandle) : IResult
+ {
+ public async Task ExecuteAsync(HttpContext httpContext)
+ {
+ var cancellationToken = httpContext.RequestAborted;
+ var response = httpContext.Response;
+ response.Headers.ContentType = "text/event-stream";
+ response.Headers.CacheControl = "no-cache,no-store";
+ response.Headers.Connection = "keep-alive";
+
+ // Make sure we disable all response buffering for SSE.
+ response.Headers.ContentEncoding = "identity";
+ httpContext.Features.GetRequiredFeature().DisableBuffering();
+ await response.Body.FlushAsync(cancellationToken);
+
+ var updateTypeInfo = AgentHostingJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ActorRequestUpdate));
+
+ await foreach (var update in responseHandle.WatchUpdatesAsync(cancellationToken).ConfigureAwait(false))
+ {
+ var eventData = JsonSerializer.Serialize(update, updateTypeInfo);
+ var eventText = $"data: {eventData}\n\n";
+
+ await response.WriteAsync(eventText, cancellationToken);
+ await response.Body.FlushAsync(cancellationToken);
+ }
+ }
+ }
+}
diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/Log.cs b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Log.cs
similarity index 95%
rename from dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/Log.cs
rename to dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Log.cs
index 7a61897cc9..0efb81961a 100644
--- a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/Log.cs
+++ b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Log.cs
@@ -2,7 +2,7 @@
using Microsoft.Extensions.AI.Agents.Runtime;
-namespace HelloHttpApi.ApiService;
+namespace AgentWebChat.AgentHost;
///
/// High-performance logging messages using LoggerMessage source generator.
@@ -62,12 +62,6 @@ internal static partial class Log
Message = "Actor response processed successfully: RequestId={RequestId}, ResponseType={ResponseType}")]
public static partial void ActorResponseProcessed(ILogger logger, string requestId, string responseType);
- // Ping endpoint logging
- [LoggerMessage(
- Level = LogLevel.Debug,
- Message = "Ping endpoint accessed: Status={Status}, TimeOfLastUpdate={TimeOfLastUpdate}")]
- public static partial void PingEndpointAccessed(ILogger logger, PingResponseStatus status, long timeOfLastUpdate);
-
// Request/Response logging
[LoggerMessage(
Level = LogLevel.Debug,
diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs
new file mode 100644
index 0000000000..722a1ba87a
--- /dev/null
+++ b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs
@@ -0,0 +1,99 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Text.Json;
+using AgentWebChat.AgentHost;
+using AgentWebChat.AgentHost.Utilities;
+using Microsoft.Agents.Orchestration;
+using Microsoft.Azure.Cosmos;
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.AI.Agents;
+using Microsoft.Extensions.AI.Agents.Hosting;
+using Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB;
+
+var builder = WebApplication.CreateBuilder(args);
+
+// Add service defaults & Aspire client integrations.
+builder.AddServiceDefaults();
+builder.Services.AddOpenApi();
+
+// Add services to the container.
+builder.Services.AddProblemDetails();
+
+// Add CosmosDB client integration
+builder.AddAzureCosmosClient("agent-web-chat-cosmosdb", null, CosmosClientOptions =>
+{
+ CosmosClientOptions.ApplicationName = "AgentWebChat";
+ CosmosClientOptions.ConnectionMode = ConnectionMode.Direct;
+ CosmosClientOptions.ConsistencyLevel = ConsistencyLevel.Session;
+ CosmosClientOptions.UseSystemTextJsonSerializerWithOptions = new JsonSerializerOptions()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ TypeInfoResolver = CosmosActorStateJsonContext.Default
+ };
+});
+
+// Configure the chat model and our agent.
+builder.AddKeyedChatClient("chat-model");
+
+builder.AddAIAgent(
+ "pirate",
+ instructions: "You are a pirate. Speak like a pirate",
+ description: "An agent that speaks like a pirate.",
+ chatClientServiceKey: "chat-model");
+
+builder.AddAIAgent("knights-and-knaves", (sp, key) =>
+{
+ var chatClient = sp.GetRequiredKeyedService("chat-model");
+
+ ChatClientAgent knight = new(
+ chatClient,
+ """
+ You are a knight. This means that you must always tell the truth. Your name is Alice.
+ Bob is standing next to you. Bob is a knave, which means he always lies.
+ When replying, always start with your name (Alice). Eg, "Alice: I am a knight."
+ """, "Alice");
+
+ ChatClientAgent knave = new(
+ chatClient,
+ """
+ You are a knave. This means that you must always lie. Your name is Bob.
+ Alice is standing next to you. Alice is a knight, which means she always tells the truth.
+ When replying, always include your name (Bob). Eg, "Bob: I am a knight."
+ """, "Bob");
+
+ ChatClientAgent narrator = new(
+ chatClient,
+ """
+ You are are the narrator of a puzzle involving knights (who always tell the truth) and knaves (who always lie).
+ The user is going to ask questions and guess whether Alice or Bob is the knight or knave.
+ Alice is standing to one side of you. Alice is a knight, which means she always tells the truth.
+ Bob is standing to the other side of you. Bob is a knave, which means he always lies.
+ When replying, always include your name (Narrator).
+ Once the user has deduced what type (knight or knave) both Alice and Bob are, tell them whether they are right or wrong.
+ If the user asks a general question about their surrounding, make something up which is consistent with the scenario.
+ """, "Narrator");
+
+ return new ConcurrentOrchestration([knight, knave, narrator], name: key);
+});
+
+// Add CosmosDB state storage to override default storage
+builder.Services.AddCosmosActorStateStorage("actor-state-db", "ActorState");
+
+var app = builder.Build();
+
+app.MapOpenApi();
+app.UseSwaggerUI(options =>
+{
+ options.SwaggerEndpoint("/openapi/v1.json", "Agents API");
+});
+
+// Configure the HTTP request pipeline.
+app.UseExceptionHandler();
+
+app.MapActors();
+
+// Map the agents HTTP endpoints
+app.MapAgentDiscovery("/agents");
+
+app.MapDefaultEndpoints();
+app.Run();
diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/Properties/launchSettings.json b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Properties/launchSettings.json
similarity index 91%
rename from dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/Properties/launchSettings.json
rename to dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Properties/launchSettings.json
index b14d22e4fe..2ae820b4a7 100644
--- a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/Properties/launchSettings.json
+++ b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Properties/launchSettings.json
@@ -6,6 +6,7 @@
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5390",
+ "launchUrl": "swagger",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
@@ -14,6 +15,7 @@
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
+ "launchUrl": "swagger",
"applicationUrl": "https://localhost:7373;http://localhost:5390",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/Utilities/ChatClientConnectionInfo.cs b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Utilities/ChatClientConnectionInfo.cs
similarity index 98%
rename from dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/Utilities/ChatClientConnectionInfo.cs
rename to dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Utilities/ChatClientConnectionInfo.cs
index 5cb808b3e7..9c459c1a58 100644
--- a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/Utilities/ChatClientConnectionInfo.cs
+++ b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Utilities/ChatClientConnectionInfo.cs
@@ -3,7 +3,7 @@
using System.Data.Common;
using System.Diagnostics.CodeAnalysis;
-namespace HelloHttpApi.ApiService.Utilities;
+namespace AgentWebChat.AgentHost.Utilities;
public class ChatClientConnectionInfo
{
diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/Utilities/ChatClientExtensions.cs b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Utilities/ChatClientExtensions.cs
similarity index 98%
rename from dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/Utilities/ChatClientExtensions.cs
rename to dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Utilities/ChatClientExtensions.cs
index ca050cced8..d6333d102c 100644
--- a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/Utilities/ChatClientExtensions.cs
+++ b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Utilities/ChatClientExtensions.cs
@@ -1,12 +1,12 @@
// Copyright (c) Microsoft. All rights reserved.
+using AgentWebChat.AgentHost.Utilities;
using Azure;
using Azure.AI.Inference;
-using HelloHttpApi.ApiService.Utilities;
using Microsoft.Extensions.AI;
using OllamaSharp;
-namespace HelloHttpApi.ApiService.Utilities;
+namespace AgentWebChat.AgentHost.Utilities;
public static class ChatClientExtensions
{
diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/appsettings.Development.json b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/appsettings.Development.json
similarity index 73%
rename from dotnet/samples/HelloHttpApi/HelloHttpApi.Web/appsettings.Development.json
rename to dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/appsettings.Development.json
index 527a02bb3c..0c208ae918 100644
--- a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/appsettings.Development.json
+++ b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/appsettings.Development.json
@@ -1,7 +1,7 @@
{
"Logging": {
"LogLevel": {
- "Default": "Trace",
+ "Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/appsettings.json b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/appsettings.json
similarity index 77%
rename from dotnet/samples/HelloHttpApi/HelloHttpApi.Web/appsettings.json
rename to dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/appsettings.json
index f41d5acb6b..10f68b8c8b 100644
--- a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/appsettings.json
+++ b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/appsettings.json
@@ -1,7 +1,7 @@
{
"Logging": {
"LogLevel": {
- "Default": "Trace",
+ "Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.AppHost/HelloHttpApi.AppHost.csproj b/dotnet/samples/AgentWebChat/AgentWebChat.AppHost/AgentWebChat.AppHost.csproj
similarity index 81%
rename from dotnet/samples/HelloHttpApi/HelloHttpApi.AppHost/HelloHttpApi.AppHost.csproj
rename to dotnet/samples/AgentWebChat/AgentWebChat.AppHost/AgentWebChat.AppHost.csproj
index 95c5809b39..f200bd03b1 100644
--- a/dotnet/samples/HelloHttpApi/HelloHttpApi.AppHost/HelloHttpApi.AppHost.csproj
+++ b/dotnet/samples/AgentWebChat/AgentWebChat.AppHost/AgentWebChat.AppHost.csproj
@@ -18,8 +18,8 @@
-
-
+
+
diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.AppHost/ModelExtensions.cs b/dotnet/samples/AgentWebChat/AgentWebChat.AppHost/ModelExtensions.cs
similarity index 99%
rename from dotnet/samples/HelloHttpApi/HelloHttpApi.AppHost/ModelExtensions.cs
rename to dotnet/samples/AgentWebChat/AgentWebChat.AppHost/ModelExtensions.cs
index 55bcd54ea0..a44e9734f4 100644
--- a/dotnet/samples/HelloHttpApi/HelloHttpApi.AppHost/ModelExtensions.cs
+++ b/dotnet/samples/AgentWebChat/AgentWebChat.AppHost/ModelExtensions.cs
@@ -1,6 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
-namespace HelloHttpApi.AppHost;
+namespace AgentWebChat.AppHost;
public static class ModelExtensions
{
diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.AppHost/Program.cs b/dotnet/samples/AgentWebChat/AgentWebChat.AppHost/Program.cs
similarity index 76%
rename from dotnet/samples/HelloHttpApi/HelloHttpApi.AppHost/Program.cs
rename to dotnet/samples/AgentWebChat/AgentWebChat.AppHost/Program.cs
index ddf06aa01e..9b25fe3488 100644
--- a/dotnet/samples/HelloHttpApi/HelloHttpApi.AppHost/Program.cs
+++ b/dotnet/samples/AgentWebChat/AgentWebChat.AppHost/Program.cs
@@ -1,6 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
-using HelloHttpApi.AppHost;
+using AgentWebChat.AppHost;
var builder = DistributedApplication.CreateBuilder(args);
@@ -10,17 +10,17 @@ var chatModel = builder.AddAIModel("chat-model").AsAzureOpenAI("gpt-4o", o => o.
var cosmosDbResource = builder.AddParameterFromConfiguration("CosmosDbName", "CosmosDb:Name");
var cosmosDbResourceGroup = builder.AddParameterFromConfiguration("CosmosDbResourceGroup", "CosmosDb:ResourceGroup");
-var cosmos = builder.AddAzureCosmosDB("hello-http-api-cosmosdb").RunAsExisting(cosmosDbResource, cosmosDbResourceGroup);
+var cosmos = builder.AddAzureCosmosDB("agent-web-chat-cosmosdb").RunAsExisting(cosmosDbResource, cosmosDbResourceGroup);
var stateDb = cosmos.AddCosmosDatabase("actor-state-db");
-var apiService = builder.AddProject("apiservice")
+var agentHost = builder.AddProject("agenthost")
.WithReference(chatModel)
.WithReference(cosmos).WaitFor(cosmos);
-builder.AddProject("webfrontend")
+builder.AddProject("webfrontend")
.WithExternalHttpEndpoints()
- .WithReference(apiService)
- .WaitFor(apiService);
+ .WithReference(agentHost)
+ .WaitFor(agentHost);
builder.Build().Run();
diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.AppHost/Properties/launchSettings.json b/dotnet/samples/AgentWebChat/AgentWebChat.AppHost/Properties/launchSettings.json
similarity index 100%
rename from dotnet/samples/HelloHttpApi/HelloHttpApi.AppHost/Properties/launchSettings.json
rename to dotnet/samples/AgentWebChat/AgentWebChat.AppHost/Properties/launchSettings.json
diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/appsettings.Development.json b/dotnet/samples/AgentWebChat/AgentWebChat.AppHost/appsettings.Development.json
similarity index 73%
rename from dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/appsettings.Development.json
rename to dotnet/samples/AgentWebChat/AgentWebChat.AppHost/appsettings.Development.json
index 527a02bb3c..0c208ae918 100644
--- a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/appsettings.Development.json
+++ b/dotnet/samples/AgentWebChat/AgentWebChat.AppHost/appsettings.Development.json
@@ -1,7 +1,7 @@
{
"Logging": {
"LogLevel": {
- "Default": "Trace",
+ "Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.AppHost/appsettings.json b/dotnet/samples/AgentWebChat/AgentWebChat.AppHost/appsettings.json
similarity index 79%
rename from dotnet/samples/HelloHttpApi/HelloHttpApi.AppHost/appsettings.json
rename to dotnet/samples/AgentWebChat/AgentWebChat.AppHost/appsettings.json
index 75c1fb6ea8..31c092aa45 100644
--- a/dotnet/samples/HelloHttpApi/HelloHttpApi.AppHost/appsettings.json
+++ b/dotnet/samples/AgentWebChat/AgentWebChat.AppHost/appsettings.json
@@ -1,7 +1,7 @@
{
"Logging": {
"LogLevel": {
- "Default": "Trace",
+ "Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Aspire.Hosting.Dcp": "Warning"
}
diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.ServiceDefaults/HelloHttpApi.ServiceDefaults.csproj b/dotnet/samples/AgentWebChat/AgentWebChat.ServiceDefaults/AgentWebChat.ServiceDefaults.csproj
similarity index 100%
rename from dotnet/samples/HelloHttpApi/HelloHttpApi.ServiceDefaults/HelloHttpApi.ServiceDefaults.csproj
rename to dotnet/samples/AgentWebChat/AgentWebChat.ServiceDefaults/AgentWebChat.ServiceDefaults.csproj
diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.ServiceDefaults/ServiceDefaultsExtensions.cs b/dotnet/samples/AgentWebChat/AgentWebChat.ServiceDefaults/ServiceDefaultsExtensions.cs
similarity index 100%
rename from dotnet/samples/HelloHttpApi/HelloHttpApi.ServiceDefaults/ServiceDefaultsExtensions.cs
rename to dotnet/samples/AgentWebChat/AgentWebChat.ServiceDefaults/ServiceDefaultsExtensions.cs
diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.Web/AgentDiscoveryClient.cs b/dotnet/samples/AgentWebChat/AgentWebChat.Web/AgentDiscoveryClient.cs
new file mode 100644
index 0000000000..df0894d8f0
--- /dev/null
+++ b/dotnet/samples/AgentWebChat/AgentWebChat.Web/AgentDiscoveryClient.cs
@@ -0,0 +1,28 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Text.Json;
+using Microsoft.Extensions.AI.Agents.Hosting;
+
+namespace AgentWebChat.Web;
+
+public class AgentDiscoveryClient(HttpClient httpClient, ILogger logger)
+{
+ public async Task> GetAgentsAsync(CancellationToken cancellationToken = default)
+ {
+ var response = await httpClient.GetAsync(new Uri("/agents", UriKind.Relative), cancellationToken);
+ response.EnsureSuccessStatusCode();
+
+ var json = await response.Content.ReadAsStringAsync(cancellationToken);
+ var agents = JsonSerializer.Deserialize>(json, AgentHostingJsonUtilities.DefaultOptions) ?? [];
+
+ logger.LogInformation("Retrieved {AgentCount} agents from the API", agents.Count);
+ _ = new HttpActorClient(null!);
+ return agents;
+ }
+
+ public class AgentDiscoveryCard
+ {
+ public string? Name { get; set; }
+ public string? Description { get; set; }
+ }
+}
diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/HelloHttpApi.Web.csproj b/dotnet/samples/AgentWebChat/AgentWebChat.Web/AgentWebChat.Web.csproj
similarity index 53%
rename from dotnet/samples/HelloHttpApi/HelloHttpApi.Web/HelloHttpApi.Web.csproj
rename to dotnet/samples/AgentWebChat/AgentWebChat.Web/AgentWebChat.Web.csproj
index e0aec4096f..ed929f6d80 100644
--- a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/HelloHttpApi.Web.csproj
+++ b/dotnet/samples/AgentWebChat/AgentWebChat.Web/AgentWebChat.Web.csproj
@@ -7,7 +7,12 @@
-
+
+
+
+
+
+
diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/App.razor b/dotnet/samples/AgentWebChat/AgentWebChat.Web/Components/App.razor
similarity index 88%
rename from dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/App.razor
rename to dotnet/samples/AgentWebChat/AgentWebChat.Web/Components/App.razor
index 2a9bdedfe7..ddb084903c 100644
--- a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/App.razor
+++ b/dotnet/samples/AgentWebChat/AgentWebChat.Web/Components/App.razor
@@ -7,7 +7,7 @@
-
+
diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.Web/Components/Layout/MainLayout.razor b/dotnet/samples/AgentWebChat/AgentWebChat.Web/Components/Layout/MainLayout.razor
new file mode 100644
index 0000000000..50c63db89f
--- /dev/null
+++ b/dotnet/samples/AgentWebChat/AgentWebChat.Web/Components/Layout/MainLayout.razor
@@ -0,0 +1,15 @@
+@inherits LayoutComponentBase
+
+
+
+
+ An unhandled error has occurred.
+
Reload
+
🗙
+
\ No newline at end of file
diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.Web/Components/Layout/MainLayout.razor.css b/dotnet/samples/AgentWebChat/AgentWebChat.Web/Components/Layout/MainLayout.razor.css
new file mode 100644
index 0000000000..cc4ae8d91d
--- /dev/null
+++ b/dotnet/samples/AgentWebChat/AgentWebChat.Web/Components/Layout/MainLayout.razor.css
@@ -0,0 +1,33 @@
+.page {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ min-height: 100vh;
+}
+
+main {
+ flex: 1;
+}
+
+.content {
+ padding: 0;
+}
+
+#blazor-error-ui {
+ background: lightyellow;
+ bottom: 0;
+ box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
+ display: none;
+ left: 0;
+ padding: 0.6rem 1.25rem 0.7rem 1.25rem;
+ position: fixed;
+ width: 100%;
+ z-index: 1000;
+}
+
+ #blazor-error-ui .dismiss {
+ cursor: pointer;
+ position: absolute;
+ right: 0.75rem;
+ top: 0.5rem;
+ }
\ No newline at end of file
diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Pages/Error.razor b/dotnet/samples/AgentWebChat/AgentWebChat.Web/Components/Pages/Error.razor
similarity index 100%
rename from dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Pages/Error.razor
rename to dotnet/samples/AgentWebChat/AgentWebChat.Web/Components/Pages/Error.razor
diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.Web/Components/Pages/Home.razor b/dotnet/samples/AgentWebChat/AgentWebChat.Web/Components/Pages/Home.razor
new file mode 100644
index 0000000000..face3c14b6
--- /dev/null
+++ b/dotnet/samples/AgentWebChat/AgentWebChat.Web/Components/Pages/Home.razor
@@ -0,0 +1,741 @@
+@page "/"
+@attribute [StreamRendering(true)]
+@inject AgentDiscoveryClient AgentClient
+@inject IJSRuntime JSRuntime
+@inject ILogger Logger
+@inject IActorClient ActorClient
+@rendermode InteractiveServer
+@using System.Text
+@using System.Text.Json
+@using Microsoft.Extensions.AI
+@using Microsoft.Extensions.AI.Agents
+@using Microsoft.Extensions.AI.Agents.Hosting
+@using Microsoft.Extensions.AI.Agents.Runtime
+
+Agent Web Chat
+
+
+
+
+
+
+
+
+ @if (!string.IsNullOrEmpty(selectedAgentName) && currentConversation == null)
+ {
+
+ }
+
+
+
+ @if (conversations.Any())
+ {
+
+
+ @foreach (var conv in conversations)
+ {
+
SelectConversation(conv.SessionId)">
+ @GetAgentIcon(conv.AgentName)
+ @GetAgentDisplayName(conv.AgentName)
+
+
+ }
+
+
+ }
+
+ @if (currentConversation != null)
+ {
+
+
+ @foreach (var message in currentConversation.Messages)
+ {
+
+ @if (message.Role != ChatRole.User)
+ {
+
@GetAgentIcon(currentConversation.AgentName)
+ }
+
+
@message.Text
+
+ @(message.Role == ChatRole.User ? "You" : GetAgentDisplayName(currentConversation.AgentName))
+
+
+
+ }
+
+ @if (isStreaming && currentStreamedMessage.Length > 0)
+ {
+
+
@GetAgentIcon(currentConversation.AgentName)
+
+
+ @currentStreamedMessage
+
+
+
+
+ }
+
+
+
+
+ }
+
+
+
+
+@code {
+
+
+
+ private string currentMessage = "";
+ private bool isStreaming = false;
+ private bool isLoadingAgents = true;
+ private string currentStreamedMessage = "";
+ private string selectedAgentName = "";
+ private List availableAgents = new();
+ private List conversations = new();
+ private Conversation? currentConversation;
+
+ private class Conversation
+ {
+ public string SessionId { get; set; } = Guid.NewGuid().ToString();
+ public string AgentName { get; set; } = "";
+ public List Messages { get; set; } = new();
+ }
+
+ protected override async Task OnInitializedAsync()
+ {
+ Logger.LogDebug("Initializing Agent Chat component");
+
+ // Load agents
+ try
+ {
+ availableAgents = await AgentClient.GetAgentsAsync();
+ Logger.LogInformation("Loaded {AgentCount} agents", availableAgents.Count);
+
+ // Default to first agent and start a conversation
+ if (availableAgents.Any())
+ {
+ selectedAgentName = availableAgents.First().Name!;
+ StartNewConversation();
+ }
+ }
+ catch (Exception ex)
+ {
+ Logger.LogError(ex, "Failed to load agents");
+ }
+ finally
+ {
+ isLoadingAgents = false;
+ }
+
+ // Conversations start fresh on page load
+ }
+
+ private string GetAgentIcon(string agentName)
+ {
+ return agentName?.ToLower() switch
+ {
+ "pirate" => "🏴☠️",
+ "knights-and-knaves" => "⚔️",
+ _ => "🤖"
+ };
+ }
+
+ private string GetAgentDisplayName(string agentName)
+ {
+ return agentName?.ToLower() switch
+ {
+ "pirate" => "Pirate",
+ "knights-and-knaves" => "Knights & Knaves",
+ _ => agentName ?? "Agent"
+ };
+ }
+
+
+
+ private void StartNewConversation()
+ {
+ if (string.IsNullOrEmpty(selectedAgentName))
+ return;
+
+ var newConversation = new Conversation
+ {
+ AgentName = selectedAgentName
+ };
+
+ conversations.Add(newConversation);
+ currentConversation = newConversation;
+
+ Logger.LogInformation("Started new conversation with agent: {AgentName}, session: {SessionId}",
+ newConversation.AgentName, newConversation.SessionId);
+
+ StateHasChanged();
+ }
+
+ private void SelectConversation(string sessionId)
+ {
+ currentConversation = conversations.FirstOrDefault(c => c.SessionId == sessionId);
+ if (currentConversation != null)
+ {
+ selectedAgentName = currentConversation.AgentName;
+ Logger.LogDebug("Selected conversation with session: {SessionId}", sessionId);
+ }
+ StateHasChanged();
+ }
+
+ private void CloseConversation(string sessionId)
+ {
+ var conversationToRemove = conversations.FirstOrDefault(c => c.SessionId == sessionId);
+ if (conversationToRemove != null)
+ {
+ conversations.Remove(conversationToRemove);
+
+ if (currentConversation?.SessionId == sessionId)
+ {
+ currentConversation = conversations.FirstOrDefault();
+ if (currentConversation != null)
+ {
+ selectedAgentName = currentConversation.AgentName;
+ }
+ }
+
+ Logger.LogInformation("Closed conversation with session: {SessionId}", sessionId);
+ }
+ StateHasChanged();
+ }
+
+ private async Task SendMessage()
+ {
+ if (string.IsNullOrWhiteSpace(currentMessage) || isStreaming || currentConversation == null)
+ return;
+
+ var userMessage = currentMessage.Trim();
+ currentMessage = "";
+
+ Logger.LogInformation("User sending message: '{UserMessage}' to agent {AgentName} in session {SessionId}",
+ userMessage, currentConversation.AgentName, currentConversation.SessionId);
+
+ // Add user message to chat
+ currentConversation.Messages.Add(new ChatMessage(ChatRole.User, userMessage));
+ StateHasChanged();
+ await ScrollToBottom();
+
+ // Start streaming response
+ isStreaming = true;
+ currentStreamedMessage = "";
+ StateHasChanged();
+
+ try
+ {
+ var responseContent = new StringBuilder();
+ var agent = new AgentProxy(currentConversation.AgentName, ActorClient);
+ var thread = agent.GetNewThread();
+ thread.ConversationId = currentConversation.SessionId;
+
+ await foreach (var update in agent.RunStreamingAsync(
+ [new ChatMessage(ChatRole.User, userMessage)],
+ thread,
+ cancellationToken: CancellationToken.None))
+ {
+ var content = update.Text ?? "";
+ if (!string.IsNullOrEmpty(content))
+ {
+ responseContent.Append(content);
+ currentStreamedMessage = responseContent.ToString();
+ StateHasChanged();
+ await ScrollToBottom();
+ }
+ }
+
+ // Add the complete agent response to chat messages
+ if (responseContent.Length > 0)
+ {
+ currentConversation.Messages.Add(new ChatMessage(ChatRole.Assistant, responseContent.ToString()));
+ }
+ else
+ {
+ currentConversation.Messages.Add(new ChatMessage(ChatRole.Assistant, "Sorry, I couldn't generate a response."));
+ }
+ }
+ catch (Exception ex)
+ {
+ Logger.LogError(ex, "Error occurred while processing message in session {SessionId}: {ErrorMessage}",
+ currentConversation.SessionId, ex.Message);
+ currentConversation.Messages.Add(new ChatMessage(ChatRole.Assistant, $"Error: {ex.Message}"));
+ }
+ finally
+ {
+ isStreaming = false;
+ currentStreamedMessage = "";
+ StateHasChanged();
+ await ScrollToBottom();
+ }
+ }
+
+ private bool ShouldPreventDefault = false;
+
+ private async Task HandleKeyPress(KeyboardEventArgs e)
+ {
+ if (e.Key == "Enter" && !e.ShiftKey)
+ {
+ ShouldPreventDefault = true;
+ await SendMessage();
+ ShouldPreventDefault = false;
+ }
+ else if (e.Key == "Escape")
+ {
+ currentMessage = ""; // Clear input on Escape
+ ShouldPreventDefault = true;
+ StateHasChanged();
+ ShouldPreventDefault = false; // Reset after clearing
+ }
+ else
+ {
+ ShouldPreventDefault = false;
+ }
+ }
+
+ private async Task ScrollToBottom()
+ {
+ try
+ {
+ await JSRuntime.InvokeVoidAsync("scrollToBottom", "chat-messages");
+ }
+ catch (Exception ex)
+ {
+ Logger.LogWarning(ex, "Failed to scroll to bottom");
+ }
+ }
+
+ protected override async Task OnAfterRenderAsync(bool firstRender)
+ {
+ if (firstRender)
+ {
+ await JSRuntime.InvokeVoidAsync("eval", @"
+ window.scrollToBottom = function(elementId) {
+ const element = document.getElementById(elementId);
+ if (element) {
+ requestAnimationFrame(() => {
+ element.scrollTop = element.scrollHeight;
+ });
+ }
+ };
+ ");
+ }
+ }
+}
\ No newline at end of file
diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Routes.razor b/dotnet/samples/AgentWebChat/AgentWebChat.Web/Components/Routes.razor
similarity index 100%
rename from dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Routes.razor
rename to dotnet/samples/AgentWebChat/AgentWebChat.Web/Components/Routes.razor
diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/_Imports.razor b/dotnet/samples/AgentWebChat/AgentWebChat.Web/Components/_Imports.razor
similarity index 86%
rename from dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/_Imports.razor
rename to dotnet/samples/AgentWebChat/AgentWebChat.Web/Components/_Imports.razor
index 4c2793f6db..5e3c2b03bd 100644
--- a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/_Imports.razor
+++ b/dotnet/samples/AgentWebChat/AgentWebChat.Web/Components/_Imports.razor
@@ -7,5 +7,5 @@
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.AspNetCore.OutputCaching
@using Microsoft.JSInterop
-@using HelloHttpApi.Web
-@using HelloHttpApi.Web.Components
+@using AgentWebChat.Web
+@using AgentWebChat.Web.Components
diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.Web/HttpActorClient.cs b/dotnet/samples/AgentWebChat/AgentWebChat.Web/HttpActorClient.cs
new file mode 100644
index 0000000000..ed841f8799
--- /dev/null
+++ b/dotnet/samples/AgentWebChat/AgentWebChat.Web/HttpActorClient.cs
@@ -0,0 +1,190 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Diagnostics.CodeAnalysis;
+using System.Net.ServerSentEvents;
+using System.Runtime.CompilerServices;
+using System.Text.Json;
+using Microsoft.Extensions.AI.Agents;
+using Microsoft.Extensions.AI.Agents.Hosting;
+using Microsoft.Extensions.AI.Agents.Runtime;
+
+namespace AgentWebChat.Web;
+
+internal sealed class HttpActorClient(HttpClient httpClient) : IActorClient
+{
+ private const string BaseUri = "/actors/v1";
+
+ public async ValueTask GetResponseAsync(ActorId actorId, string messageId, CancellationToken cancellationToken)
+ {
+ var uri = new Uri($"{BaseUri}/{actorId.Type}/{actorId.Key}/{messageId}", UriKind.Relative);
+ var response = await httpClient.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
+ return new HttpActorResponseHandle(httpClient, actorId, messageId, initialResponseMessage: response);
+ }
+
+ public async ValueTask SendRequestAsync(ActorRequest request, CancellationToken cancellationToken)
+ {
+ var actorId = request.ActorId;
+ var messageId = request.MessageId;
+ var uri = new Uri($"{BaseUri}/{actorId.Type}/{actorId.Key}/{messageId}?streaming=true", UriKind.Relative);
+ var jsonContent = JsonContent.Create(request, AgentHostingJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ActorRequest)));
+ var message = new HttpRequestMessage(HttpMethod.Post, uri) { Content = jsonContent };
+ var response = await httpClient.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
+ return new HttpActorResponseHandle(httpClient, actorId, messageId, response);
+ }
+
+ private sealed class HttpActorResponseHandle(
+ HttpClient httpClient,
+ ActorId actorId,
+ string messageId,
+ HttpResponseMessage? initialResponseMessage) : ActorResponseHandle
+ {
+ private HttpResponseMessage? _responseMessage = initialResponseMessage;
+ private ActorResponse? _lastResponse;
+
+ public override async ValueTask CancelAsync(CancellationToken cancellationToken)
+ {
+ this._responseMessage?.Dispose();
+ this._responseMessage = null;
+
+ var uri = new Uri($"{BaseUri}/{actorId.Type}/{actorId.Key}/{messageId}:cancel", UriKind.Relative);
+ await httpClient.PostAsync(uri, null, cancellationToken).ConfigureAwait(false);
+ }
+
+ public override async ValueTask GetResponseAsync(CancellationToken cancellationToken)
+ {
+ try
+ {
+ // If the response is already completed, don't bother requesting the response again;
+ if (this._lastResponse is { } response && response.Status.IsTerminated())
+ {
+ return response;
+ }
+
+ if (IsStreamingResponse(this._responseMessage))
+ {
+ try
+ {
+ var updates = new List();
+ await foreach (var update in EnumerateAsync(this._responseMessage, cancellationToken).ConfigureAwait(false))
+ {
+ if (!update.Status.IsTerminated())
+ {
+ continue;
+ }
+
+ response = new ActorResponse { ActorId = actorId, MessageId = messageId, Status = update.Status, Data = update.Data };
+ this._lastResponse = response;
+ return response;
+ }
+ }
+ finally
+ {
+ this._responseMessage?.Dispose();
+ this._responseMessage = null;
+ }
+ }
+
+ var uri = new Uri($"{BaseUri}/{actorId.Type}/{actorId.Key}/{messageId}?blocking=true", UriKind.Relative);
+ using var responseMessage = this._responseMessage ?? await httpClient.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
+ response = await this.ReadResponseAsync(responseMessage, cancellationToken).ConfigureAwait(false);
+ this._lastResponse = response;
+ return response;
+ }
+ finally
+ {
+ this._responseMessage = null;
+ }
+ }
+
+ public override bool TryGetResponse([NotNullWhen(true)] out ActorResponse? response)
+ {
+ response = this._lastResponse;
+ return response != null;
+ }
+
+ public override async IAsyncEnumerable WatchUpdatesAsync([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ // If the response is already completed, don't bother streaming anything.
+ if (this._lastResponse is { } response && response.Status.IsTerminated())
+ {
+ yield return new ActorRequestUpdate(response.Status, response.Data);
+ yield break;
+ }
+
+ try
+ {
+ var uri = new Uri($"{BaseUri}/{actorId.Type}/{actorId.Key}/{messageId}?streaming=true", UriKind.Relative);
+ using var responseMessage = this._responseMessage ?? await httpClient.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
+ if (IsJsonResponse(responseMessage))
+ {
+ // If the response is JSON, read it as a single response and yield it.
+ response = await this.ReadResponseAsync(responseMessage, cancellationToken).ConfigureAwait(false);
+
+ yield return new ActorRequestUpdate(response.Status, response.Data);
+ yield break;
+ }
+
+ await foreach (var update in EnumerateAsync(responseMessage, cancellationToken).ConfigureAwait(false))
+ {
+ yield return update;
+ }
+ }
+ finally
+ {
+ this._responseMessage = null;
+ }
+ }
+
+ private static async IAsyncEnumerable EnumerateAsync(HttpResponseMessage responseMessage, [EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ var responseStream = await responseMessage.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
+ var sseParser = SseParser.Create(responseStream, (eventType, data) =>
+ {
+ if (eventType != "message")
+ {
+ // Only process default message events
+ return null;
+ }
+
+ var reader = new Utf8JsonReader(data);
+ return JsonSerializer.Deserialize(ref reader, AgentHostingJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ActorRequestUpdate))) as ActorRequestUpdate;
+ });
+
+ await foreach (var item in sseParser.EnumerateAsync(cancellationToken).ConfigureAwait(false))
+ {
+ if (item.Data is not null)
+ {
+ yield return item.Data;
+ }
+ }
+ }
+
+ private async Task ReadResponseAsync(HttpResponseMessage responseMessage, CancellationToken cancellationToken)
+ {
+ var response = await responseMessage.Content.ReadFromJsonAsync(AgentRuntimeJsonUtilities.DefaultOptions, cancellationToken).ConfigureAwait(false);
+ if (response == null)
+ {
+ throw new InvalidOperationException($"No response found for actor '{actorId}' with message ID '{messageId}'.");
+ }
+
+ return response;
+ }
+
+ private static bool IsJsonResponse([NotNullWhen(true)] HttpResponseMessage? response)
+ {
+ return response?.Content.Headers.ContentType?.MediaType == "application/json";
+ }
+
+ private static bool IsStreamingResponse([NotNullWhen(true)] HttpResponseMessage? response)
+ {
+ return response?.Content.Headers.ContentType?.MediaType == "text/event-stream";
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ base.Dispose(disposing);
+ this._responseMessage?.Dispose();
+ this._responseMessage = null;
+ }
+ }
+}
diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Program.cs b/dotnet/samples/AgentWebChat/AgentWebChat.Web/Program.cs
similarity index 67%
rename from dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Program.cs
rename to dotnet/samples/AgentWebChat/AgentWebChat.Web/Program.cs
index 68c8f8be5c..1173f2de29 100644
--- a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Program.cs
+++ b/dotnet/samples/AgentWebChat/AgentWebChat.Web/Program.cs
@@ -1,7 +1,8 @@
// Copyright (c) Microsoft. All rights reserved.
-using HelloHttpApi.Web;
-using HelloHttpApi.Web.Components;
+using AgentWebChat.Web;
+using AgentWebChat.Web.Components;
+using Microsoft.Extensions.AI.Agents.Runtime;
var builder = WebApplication.CreateBuilder(args);
@@ -14,12 +15,8 @@ builder.Services.AddRazorComponents()
builder.Services.AddOutputCache();
-builder.Services.AddHttpClient(client =>
- {
- // This URL uses "https+http://" to indicate HTTPS is preferred over HTTP.
- // Learn more about service discovery scheme resolution at https://aka.ms/dotnet/sdschemes.
- client.BaseAddress = new("https+http://apiservice");
- });
+builder.Services.AddHttpClient(client => client.BaseAddress = new("https+http://agenthost"));
+builder.Services.AddHttpClient(client => client.BaseAddress = new("https+http://agenthost"));
var app = builder.Build();
@@ -32,11 +29,12 @@ if (!app.Environment.IsDevelopment())
app.UseHttpsRedirection();
-app.UseStaticFiles();
app.UseAntiforgery();
app.UseOutputCache();
+app.MapStaticAssets();
+
app.MapRazorComponents()
.AddInteractiveServerRenderMode();
diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Properties/launchSettings.json b/dotnet/samples/AgentWebChat/AgentWebChat.Web/Properties/launchSettings.json
similarity index 100%
rename from dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Properties/launchSettings.json
rename to dotnet/samples/AgentWebChat/AgentWebChat.Web/Properties/launchSettings.json
diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.AppHost/appsettings.Development.json b/dotnet/samples/AgentWebChat/AgentWebChat.Web/appsettings.Development.json
similarity index 73%
rename from dotnet/samples/HelloHttpApi/HelloHttpApi.AppHost/appsettings.Development.json
rename to dotnet/samples/AgentWebChat/AgentWebChat.Web/appsettings.Development.json
index 527a02bb3c..0c208ae918 100644
--- a/dotnet/samples/HelloHttpApi/HelloHttpApi.AppHost/appsettings.Development.json
+++ b/dotnet/samples/AgentWebChat/AgentWebChat.Web/appsettings.Development.json
@@ -1,7 +1,7 @@
{
"Logging": {
"LogLevel": {
- "Default": "Trace",
+ "Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/appsettings.json b/dotnet/samples/AgentWebChat/AgentWebChat.Web/appsettings.json
similarity index 77%
rename from dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/appsettings.json
rename to dotnet/samples/AgentWebChat/AgentWebChat.Web/appsettings.json
index f41d5acb6b..10f68b8c8b 100644
--- a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/appsettings.json
+++ b/dotnet/samples/AgentWebChat/AgentWebChat.Web/appsettings.json
@@ -1,7 +1,7 @@
{
"Logging": {
"LogLevel": {
- "Default": "Trace",
+ "Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/wwwroot/app.css b/dotnet/samples/AgentWebChat/AgentWebChat.Web/wwwroot/app.css
similarity index 64%
rename from dotnet/samples/HelloHttpApi/HelloHttpApi.Web/wwwroot/app.css
rename to dotnet/samples/AgentWebChat/AgentWebChat.Web/wwwroot/app.css
index 2a2a313024..6b032e3458 100644
--- a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/wwwroot/app.css
+++ b/dotnet/samples/AgentWebChat/AgentWebChat.Web/wwwroot/app.css
@@ -1,39 +1,8 @@
html, body {
- font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
-}
-
-a, .btn-link {
- color: #006bb7;
-}
-
-.btn-primary {
- color: #fff;
- background-color: #1b6ec2;
- border-color: #1861ac;
-}
-
-.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus {
- box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb;
-}
-
-.content {
- padding-top: 1.1rem;
-}
-
-h1:focus {
- outline: none;
-}
-
-.valid.modified:not([type=checkbox]) {
- outline: 1px solid #26b050;
-}
-
-.invalid {
- outline: 1px solid #e51540;
-}
-
-.validation-message {
- color: #e51540;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
+ margin: 0;
+ padding: 0;
+ background-color: #f9fafb;
}
.blazor-error-boundary {
@@ -42,15 +11,6 @@ h1:focus {
color: white;
}
- .blazor-error-boundary::after {
- content: "An error has occurred."
- }
-
-.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder {
- color: var(--bs-secondary-color);
- text-align: end;
-}
-
-.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder {
- text-align: start;
-}
+.blazor-error-boundary::after {
+ content: "An error has occurred."
+}
\ No newline at end of file
diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/wwwroot/favicon.png b/dotnet/samples/AgentWebChat/AgentWebChat.Web/wwwroot/favicon.png
similarity index 100%
rename from dotnet/samples/HelloHttpApi/HelloHttpApi.Web/wwwroot/favicon.png
rename to dotnet/samples/AgentWebChat/AgentWebChat.Web/wwwroot/favicon.png
diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/ActorFrameworkWebApplicationExtensions.cs b/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/ActorFrameworkWebApplicationExtensions.cs
deleted file mode 100644
index 2295bfc0e5..0000000000
--- a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/ActorFrameworkWebApplicationExtensions.cs
+++ /dev/null
@@ -1,174 +0,0 @@
-// Copyright (c) Microsoft. All rights reserved.
-
-using System.Diagnostics;
-using System.Text.Json;
-using Microsoft.AspNetCore.Http.Features;
-using Microsoft.AspNetCore.Mvc;
-using Microsoft.Extensions.AI;
-using Microsoft.Extensions.AI.Agents.Runtime;
-
-namespace HelloHttpApi.ApiService;
-
-internal static class ActorFrameworkWebApplicationExtensions
-{
- public static void MapAgents(this WebApplication app)
- {
- app.MapPost(
- "/invocations/actor/{name}/{sessionId}/{requestId}", async (
- string name,
- string sessionId,
- string requestId,
- [FromQuery] bool? stream,
- [FromBody] JsonElement request,
- HttpContext context,
- ILogger logger,
- IActorClient actorClient,
- CancellationToken cancellationToken) =>
- {
- var stopwatch = Stopwatch.StartNew();
- var streamRequested = stream == true;
-
- Log.ActorInvocationStarted(logger, name, sessionId, requestId, streamRequested);
- Log.ActorRequestReceived(logger, requestId, request.GetRawText().Length, streamRequested);
-
- try
- {
- var responseHandle = await actorClient.SendRequestAsync(new ActorRequest(new ActorId(name, sessionId), requestId, method: "run", @params: request), cancellationToken);
- Log.ActorRequestSent(logger, requestId, name, sessionId);
-
- if (!responseHandle.TryGetResponse(out var response))
- {
- Log.ActorResponseHandleObtained(logger, requestId, false);
-
- if (stream == true)
- {
- Log.SseStreamingStarted(logger, requestId);
- // If no response is available and streaming is requested, stream the response handle.
- var result = await StreamResponse(context, responseHandle, cancellationToken);
- Log.ActorInvocationCompleted(logger, name, sessionId, requestId, RequestStatus.Pending, stopwatch.ElapsedMilliseconds);
- return result;
- }
-
- // Otherwise, wait for a response to become available.
- Log.WaitingForActorResponse(logger, requestId);
- response = await responseHandle.GetResponseAsync(cancellationToken);
- }
- else
- {
- Log.ActorResponseHandleObtained(logger, requestId, true);
- }
-
- Log.ActorResponseReceived(logger, requestId, response.Status);
- var processResult = await ProcessResponse(name, sessionId, requestId, stream, context, responseHandle, response, cancellationToken);
- Log.ActorInvocationCompleted(logger, name, sessionId, requestId, response.Status, stopwatch.ElapsedMilliseconds);
- return processResult;
- }
- catch (Exception ex)
- {
- Log.ActorInvocationFailed(logger, ex, name, sessionId, requestId, stopwatch.ElapsedMilliseconds);
- return Results.Problem("An error occurred processing the request.", statusCode: 500);
- }
-
- static async Task StreamResponse(HttpContext context, ActorResponseHandle responseHandle, CancellationToken cancellationToken)
- {
- var requestId = context.Request.RouteValues["requestId"]?.ToString() ?? "unknown";
- var logger = context.RequestServices.GetRequiredService>();
-
- Log.SseStreamingStarted(logger, requestId);
- InitializeSseResponse(context);
- await context.Response.Body.FlushAsync(cancellationToken);
-
- var updateCount = 0;
- try
- {
- await foreach (var progress in responseHandle.WatchUpdatesAsync(cancellationToken))
- {
- // Properly serialize the progress data as JSON and escape for SSE
- var progressJson = JsonSerializer.Serialize(progress.Data, (JsonSerializerOptions?)null);
- var eventData = JsonSerializer.Serialize(new { @event = JsonDocument.Parse(progressJson).RootElement });
- var eventText = $"data: {eventData}\n\n";
-
- await context.Response.WriteAsync(eventText, cancellationToken);
- await context.Response.Body.FlushAsync(cancellationToken);
-
- updateCount++;
- Log.SseProgressUpdateSent(logger, requestId, updateCount);
- }
-
- // Send completion marker
- await context.Response.WriteAsync("data: completed\n\n", cancellationToken);
- await context.Response.Body.FlushAsync(cancellationToken);
-
- Log.SseStreamingCompleted(logger, requestId, updateCount);
- }
- catch (OperationCanceledException)
- {
- Log.SseStreamingCancelled(logger, requestId);
- }
- catch (Exception ex)
- {
- Log.SseStreamingError(logger, ex, requestId);
- }
-
- // TODO: refactor the enclosing method so we don't need to return a result here.
- return Results.Empty;
- }
-
- static void InitializeSseResponse(HttpContext context)
- {
- context.Response.Headers.ContentType = "text/event-stream";
- context.Response.Headers.CacheControl = "no-cache,no-store";
- context.Response.Headers.Connection = "keep-alive";
-
- // Make sure we disable all response buffering for SSE.
- context.Response.Headers.ContentEncoding = "identity";
- context.Features.GetRequiredFeature().DisableBuffering();
- }
-
- static async Task ProcessResponse(
- string name,
- string sessionId,
- string requestId,
- bool? stream,
- HttpContext context,
- ActorResponseHandle responseHandle,
- ActorResponse response,
- CancellationToken cancellationToken)
- {
- var logger = context.RequestServices.GetRequiredService>();
- var isStreaming = stream != false && response.Status == RequestStatus.Pending;
-
- Log.ProcessingActorResponse(logger, requestId, response.Status, isStreaming);
-
- var result = response.Status switch
- {
- // If the response is pending & streaming is disabled, return a 202 Accepted with the messageId.
- RequestStatus.Pending when stream == false => Results.Accepted($"/invocations/actor/{name}/{sessionId}/{requestId}"),
-
- // If streaming is not explicitly disabled, stream the response back.
- RequestStatus.Pending => await StreamResponse(context, responseHandle, cancellationToken),
- RequestStatus.Completed => Results.Ok(response.Data),
-
- // If the response failed, we can return a 500 Internal Server Error.
- RequestStatus.Failed => Results.Problem("The invocation failed.", statusCode: 500),
- RequestStatus.NotFound => Results.NotFound(new { message = "Not found." }),// If the actor is not found, we can return a 404 Not Found.
- _ => throw new NotSupportedException($"Unsupported request status: {response.Status}"),
- };
-
- var responseType = response.Status switch
- {
- RequestStatus.Pending when stream == false => "Accepted",
- RequestStatus.Pending => "Streaming",
- RequestStatus.Completed => "Ok",
- RequestStatus.Failed => "Problem",
- RequestStatus.NotFound => "NotFound",
- _ => "Unknown"
- };
-
- Log.ActorResponseProcessed(logger, requestId, responseType);
- return result;
- }
- })
- .WithName("Invocations");
- }
-}
diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/ChatClientAgentActorJsonContext.cs b/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/ChatClientAgentActorJsonContext.cs
deleted file mode 100644
index fe8d72b533..0000000000
--- a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/ChatClientAgentActorJsonContext.cs
+++ /dev/null
@@ -1,17 +0,0 @@
-// Copyright (c) Microsoft. All rights reserved.
-
-using System.Text.Json;
-using System.Text.Json.Serialization;
-
-namespace HelloHttpApi.ApiService;
-
-///
-/// Source-generated JSON type information for use by ChatClientAgentActor.
-///
-[JsonSourceGenerationOptions(
- JsonSerializerDefaults.Web,
- UseStringEnumConverter = true,
- DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
- WriteIndented = false)]
-[JsonSerializable(typeof(ChatClientAgentRunRequest))]
-internal sealed partial class ChatClientAgentActorJsonContext : JsonSerializerContext;
diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/ChatClientAgentRunRequest.cs b/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/ChatClientAgentRunRequest.cs
deleted file mode 100644
index c367ad0bb6..0000000000
--- a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/ChatClientAgentRunRequest.cs
+++ /dev/null
@@ -1,12 +0,0 @@
-// Copyright (c) Microsoft. All rights reserved.
-
-using System.Text.Json.Serialization;
-using Microsoft.Extensions.AI;
-
-namespace HelloHttpApi.ApiService;
-
-public sealed class ChatClientAgentRunRequest
-{
- [JsonPropertyName("messages")]
- public List Messages { get; set; } = [];
-}
diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/HostApplicationBuilderAgentExtensions.cs b/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/HostApplicationBuilderAgentExtensions.cs
deleted file mode 100644
index 284e3f18fa..0000000000
--- a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/HostApplicationBuilderAgentExtensions.cs
+++ /dev/null
@@ -1,45 +0,0 @@
-// Copyright (c) Microsoft. All rights reserved.
-
-using Microsoft.Agents.Orchestration;
-using Microsoft.Extensions.AI;
-using Microsoft.Extensions.AI.Agents;
-using Microsoft.Extensions.AI.Agents.Runtime;
-using Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB;
-
-namespace HelloHttpApi.ApiService;
-
-public static class HostApplicationBuilderAgentExtensions
-{
- public static IHostApplicationBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string instructions, string? chatClientKey = null)
- {
- var agentKey = $"agent:{name}";
- builder.Services.AddKeyedSingleton(agentKey, (sp, key) =>
- {
- var chatClient = chatClientKey is null ? sp.GetRequiredService() : sp.GetRequiredKeyedService(chatClientKey);
-
- ChatClientAgent triage = new(chatClient, "You are a triage agent. You will determine which agent to hand off the conversation to based on the user's input.", $"{name}_triageAgent");
- ChatClientAgent target = new(chatClient, instructions, $"{name}_targetAgent");
- ChatClientAgent customerService = new(chatClient, "You are a customer service agent. You will handle rude, angry, or upset customer inquiries, asking them to be more calm and polite.", $"{name}_customerServiceAgent");
-
- return Handoffs
- .StartWith(triage)
- .Add(triage, target, "Hand off to the target agent for handling normal customer requests.")
- .Add(triage, customerService, "Hand off to the customer service agent for handling rude customer inquiries.")
- .Build("PirateWorkflow");
- });
-
- var actorBuilder = builder.AddActorRuntime();
-
- // Add CosmosDB state storage to override default storage
- builder.Services.AddCosmosActorStateStorage("actor-state-db", "ActorState");
-
- actorBuilder.AddActorType(
- new ActorType(agentKey),
- (sp, ctx) => new ChatClientAgentActor(
- sp.GetRequiredKeyedService(agentKey),
- ctx,
- sp.GetRequiredService>()));
-
- return builder;
- }
-}
diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/InvocationResponse.cs b/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/InvocationResponse.cs
deleted file mode 100644
index b2d4820bff..0000000000
--- a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/InvocationResponse.cs
+++ /dev/null
@@ -1,15 +0,0 @@
-// Copyright (c) Microsoft. All rights reserved.
-
-using System.Text.Json;
-using System.Text.Json.Serialization;
-
-namespace HelloHttpApi.ApiService;
-
-public class InvocationResponse
-{
- [JsonPropertyName("response")]
- public JsonElement Response { get; set; }
-
- [JsonPropertyName("status")]
- public string? Status { get; set; } = "success";
-}
diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/PingResponse.cs b/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/PingResponse.cs
deleted file mode 100644
index 7ca5e90b0e..0000000000
--- a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/PingResponse.cs
+++ /dev/null
@@ -1,14 +0,0 @@
-// Copyright (c) Microsoft. All rights reserved.
-
-using System.Text.Json.Serialization;
-
-namespace HelloHttpApi.ApiService;
-
-public class PingResponse(PingResponseStatus status, long timeOfLastUpdate)
-{
- [JsonPropertyName("status")]
- public PingResponseStatus Status { get; } = status;
-
- [JsonPropertyName("time_of_last_update")]
- public long TimeOfLastUpdate { get; } = timeOfLastUpdate;
-}
diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/PingResponseStatus.cs b/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/PingResponseStatus.cs
deleted file mode 100644
index 8a0c45d22b..0000000000
--- a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/PingResponseStatus.cs
+++ /dev/null
@@ -1,9 +0,0 @@
-// Copyright (c) Microsoft. All rights reserved.
-
-namespace HelloHttpApi.ApiService;
-
-public enum PingResponseStatus
-{
- Healthy,
- HealthyBusy,
-}
diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/Program.cs b/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/Program.cs
deleted file mode 100644
index 81a8534708..0000000000
--- a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/Program.cs
+++ /dev/null
@@ -1,35 +0,0 @@
-// Copyright (c) Microsoft. All rights reserved.
-
-using HelloHttpApi.ApiService;
-using HelloHttpApi.ApiService.Utilities;
-
-var builder = WebApplication.CreateBuilder(args);
-
-// Add service defaults & Aspire client integrations.
-builder.AddServiceDefaults();
-
-// Add CosmosDB client integration
-builder.AddAzureCosmosClient("hello-http-api-cosmosdb");
-
-// Add services to the container.
-builder.Services.AddProblemDetails();
-
-// Configure the chat model and our agent.
-builder.AddKeyedChatClient("chat-model");
-
-builder.AddAIAgent(
- name: "pirate",
- instructions: "You are a pirate. Speak like a pirate.",
- chatClientKey: "chat-model");
-
-var app = builder.Build();
-
-// Configure the HTTP request pipeline.
-app.UseExceptionHandler();
-
-// Map the agents HTTP endpoints
-app.MapAgents();
-
-app.MapDefaultEndpoints();
-
-app.Run();
diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/AgentClient.cs b/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/AgentClient.cs
deleted file mode 100644
index 8a17efbb32..0000000000
--- a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/AgentClient.cs
+++ /dev/null
@@ -1,179 +0,0 @@
-// Copyright (c) Microsoft. All rights reserved.
-
-using System.Text.Json;
-using System.Text.Json.Serialization;
-using Microsoft.Extensions.AI;
-using Microsoft.Extensions.AI.Agents;
-
-namespace HelloHttpApi.Web;
-
-public class AgentClient(HttpClient httpClient, ILogger logger)
-{
- public async IAsyncEnumerable SendMessageStreamAsync(
- string agentName,
- string message,
- string sessionId = "default",
- [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
- {
- var requestId = Guid.NewGuid().ToString();
- var request = new ChatClientAgentRunRequest
- {
- Messages = [new ChatMessage(ChatRole.User, message)]
- };
-
- var content = JsonContent.Create(request, AgentClientJsonContext.Default.ChatClientAgentRunRequest);
-
- var requestUri = new Uri($"/invocations/actor/{agentName}/{sessionId}/{requestId}?stream=true", UriKind.Relative);
-
- var requestMessage = new HttpRequestMessage(HttpMethod.Post, requestUri)
- {
- Content = content
- };
-
- using var response = await httpClient.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
- response.EnsureSuccessStatusCode();
-
- await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
- using var reader = new StreamReader(stream);
-
- string? line;
- while ((line = await reader.ReadLineAsync(cancellationToken)) != null)
- {
- // If this indicates completion, break the loop
- if (IsCompletionEvent(line))
- {
- yield break;
- }
-
- if (line.StartsWith("data: ", StringComparison.Ordinal))
- {
- var jsonData = line.Substring(6); // Remove "data: " prefix
-
- if (TryParseEventData(jsonData, logger, out var responseUpdate))
- {
- if (responseUpdate != null)
- {
- yield return responseUpdate;
- }
- }
- else
- {
- logger.LogWarning("Received unrecognized event data: {JsonData}", jsonData);
- }
- }
- }
- }
-
- public async Task SendMessageAsync(
- string agentName,
- string message,
- string sessionId = "default",
- CancellationToken cancellationToken = default)
- {
- var requestId = Guid.NewGuid().ToString();
- var request = new ChatClientAgentRunRequest
- {
- Messages = [new ChatMessage(ChatRole.User, message)]
- };
-
- var content = JsonContent.Create(request, AgentClientJsonContext.Default.ChatClientAgentRunRequest);
-
- var requestUri = new Uri($"/invocations/actor/{agentName}/{sessionId}/{requestId}?stream=false", UriKind.Relative);
-
- var requestMessage = new HttpRequestMessage(HttpMethod.Post, requestUri)
- {
- Content = content
- };
-
- using var response = await httpClient.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
- response.EnsureSuccessStatusCode();
-
- try
- {
- var agentResponse = await response.Content.ReadFromJsonAsync(AgentClientJsonContext.Default.AgentResponse, cancellationToken);
- return agentResponse ?? new AgentResponse { Content = "No response received", Status = "error" };
- }
- catch (JsonException ex)
- {
- var responseContent = await response.Content.ReadAsStringAsync(cancellationToken);
- logger.LogError(ex, "Failed to parse agent response JSON: {ResponseContent}", responseContent);
- return new AgentResponse { Content = "Failed to parse response", Status = "error" };
- }
- }
-
- private static bool TryParseEventData(string jsonData, ILogger logger, out AgentRunResponseUpdate? responseUpdate)
- {
- responseUpdate = null;
-
- try
- {
- var eventData = JsonSerializer.Deserialize(jsonData, AgentClientJsonContext.Default.EventData);
- if (eventData?.Event != null)
- {
- var eventElement = eventData.Event.Value;
-
- // Try to deserialize as AgentRunResponseUpdate for intermediate updates
- try
- {
- var update = JsonSerializer.Deserialize(eventElement.GetRawText(), AgentAbstractionsJsonUtilities.DefaultOptions);
- if (update != null)
- {
- responseUpdate = update;
- return true;
- }
- }
- catch (JsonException)
- {
- // If it fails to deserialize as AgentRunResponseUpdate, it might be something else
- logger.LogDebug("Failed to deserialize event as AgentRunResponseUpdate, might be final response or other data");
- }
-
- // Fallback: create a simple update with the raw content
- responseUpdate = new AgentRunResponseUpdate(ChatRole.Assistant, eventElement.ToString());
- return true;
- }
- }
- catch (JsonException ex)
- {
- logger.LogError(ex, "Failed to parse event data JSON: {JsonData}", jsonData);
- }
-
- return false;
- }
-
- private static bool IsCompletionEvent(string line) => string.Equals("data: completed", line, StringComparison.Ordinal);
-}
-
-public class ChatClientAgentRunRequest
-{
- [JsonPropertyName("messages")]
- public List Messages { get; set; } = [];
-}
-
-public class EventData
-{
- [JsonPropertyName("event")]
- public JsonElement? Event { get; set; }
-}
-
-public class AgentResponse
-{
- [JsonPropertyName("content")]
- public string Content { get; set; } = "";
-
- [JsonPropertyName("status")]
- public string Status { get; set; } = "";
-}
-
-///
-/// Source-generated JSON type information for use by AgentClient.
-///
-[JsonSourceGenerationOptions(
- JsonSerializerDefaults.Web,
- UseStringEnumConverter = true,
- DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
- WriteIndented = false)]
-[JsonSerializable(typeof(ChatClientAgentRunRequest))]
-[JsonSerializable(typeof(EventData))]
-[JsonSerializable(typeof(AgentResponse))]
-internal sealed partial class AgentClientJsonContext : JsonSerializerContext;
diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Layout/MainLayout.razor b/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Layout/MainLayout.razor
deleted file mode 100644
index 5a24bb1371..0000000000
--- a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Layout/MainLayout.razor
+++ /dev/null
@@ -1,23 +0,0 @@
-@inherits LayoutComponentBase
-
-
-
-
-
-
-
-
- @Body
-
-
-
-
-
- An unhandled error has occurred.
-
Reload
-
🗙
-
diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Layout/MainLayout.razor.css b/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Layout/MainLayout.razor.css
deleted file mode 100644
index 038baf178b..0000000000
--- a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Layout/MainLayout.razor.css
+++ /dev/null
@@ -1,96 +0,0 @@
-.page {
- position: relative;
- display: flex;
- flex-direction: column;
-}
-
-main {
- flex: 1;
-}
-
-.sidebar {
- background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%);
-}
-
-.top-row {
- background-color: #f7f7f7;
- border-bottom: 1px solid #d6d5d5;
- justify-content: flex-end;
- height: 3.5rem;
- display: flex;
- align-items: center;
-}
-
- .top-row ::deep a, .top-row ::deep .btn-link {
- white-space: nowrap;
- margin-left: 1.5rem;
- text-decoration: none;
- }
-
- .top-row ::deep a:hover, .top-row ::deep .btn-link:hover {
- text-decoration: underline;
- }
-
- .top-row ::deep a:first-child {
- overflow: hidden;
- text-overflow: ellipsis;
- }
-
-@media (max-width: 640.98px) {
- .top-row {
- justify-content: space-between;
- }
-
- .top-row ::deep a, .top-row ::deep .btn-link {
- margin-left: 0;
- }
-}
-
-@media (min-width: 641px) {
- .page {
- flex-direction: row;
- }
-
- .sidebar {
- width: 250px;
- height: 100vh;
- position: sticky;
- top: 0;
- }
-
- .top-row {
- position: sticky;
- top: 0;
- z-index: 1;
- }
-
- .top-row.auth ::deep a:first-child {
- flex: 1;
- text-align: right;
- width: 0;
- }
-
- .top-row, article {
- padding-left: 2rem !important;
- padding-right: 1.5rem !important;
- }
-}
-
-#blazor-error-ui {
- background: lightyellow;
- bottom: 0;
- box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
- display: none;
- left: 0;
- padding: 0.6rem 1.25rem 0.7rem 1.25rem;
- position: fixed;
- width: 100%;
- z-index: 1000;
-}
-
- #blazor-error-ui .dismiss {
- cursor: pointer;
- position: absolute;
- right: 0.75rem;
- top: 0.5rem;
- }
diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Layout/NavMenu.razor b/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Layout/NavMenu.razor
deleted file mode 100644
index 8cc90687e7..0000000000
--- a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Layout/NavMenu.razor
+++ /dev/null
@@ -1,29 +0,0 @@
-
-
-
-
-
diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Layout/NavMenu.razor.css b/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Layout/NavMenu.razor.css
deleted file mode 100644
index 1338edb61e..0000000000
--- a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Layout/NavMenu.razor.css
+++ /dev/null
@@ -1,102 +0,0 @@
-.navbar-toggler {
- appearance: none;
- cursor: pointer;
- width: 3.5rem;
- height: 2.5rem;
- color: white;
- position: absolute;
- top: 0.5rem;
- right: 1rem;
- border: 1px solid rgba(255, 255, 255, 0.1);
- background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") no-repeat center/1.75rem rgba(255, 255, 255, 0.1);
-}
-
-.navbar-toggler:checked {
- background-color: rgba(255, 255, 255, 0.5);
-}
-
-.top-row {
- min-height: 3.5rem;
- background-color: rgba(0,0,0,0.4);
-}
-
-.navbar-brand {
- font-size: 1.1rem;
-}
-
-.bi {
- display: inline-block;
- position: relative;
- width: 1.25rem;
- height: 1.25rem;
- margin-right: 0.75rem;
- top: -1px;
- background-size: cover;
-}
-
-.bi-house-door-fill {
- background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E");
-}
-
-.bi-plus-square-fill {
- background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E");
-}
-
-.bi-list-nested {
- background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E");
-}
-
-.nav-item {
- font-size: 0.9rem;
- padding-bottom: 0.5rem;
-}
-
- .nav-item:first-of-type {
- padding-top: 1rem;
- }
-
- .nav-item:last-of-type {
- padding-bottom: 1rem;
- }
-
- .nav-item ::deep a {
- color: #d7d7d7;
- border-radius: 4px;
- height: 3rem;
- display: flex;
- align-items: center;
- line-height: 3rem;
- }
-
-.nav-item ::deep a.active {
- background-color: rgba(255,255,255,0.37);
- color: white;
-}
-
-.nav-item ::deep a:hover {
- background-color: rgba(255,255,255,0.1);
- color: white;
-}
-
-.nav-scrollable {
- display: none;
-}
-
-.navbar-toggler:checked ~ .nav-scrollable {
- display: block;
-}
-
-@media (min-width: 641px) {
- .navbar-toggler {
- display: none;
- }
-
- .nav-scrollable {
- /* Never collapse the sidebar for wide screens */
- display: block;
-
- /* Allow sidebar to scroll for tall menus */
- height: calc(100vh - 3.5rem);
- overflow-y: auto;
- }
-}
diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Pages/Counter.razor b/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Pages/Counter.razor
deleted file mode 100644
index 1a4f8e7553..0000000000
--- a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Pages/Counter.razor
+++ /dev/null
@@ -1,19 +0,0 @@
-@page "/counter"
-@rendermode InteractiveServer
-
-Counter
-
-Counter
-
-Current count: @currentCount
-
-
-
-@code {
- private int currentCount = 0;
-
- private void IncrementCount()
- {
- currentCount++;
- }
-}
diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Pages/Home.razor b/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Pages/Home.razor
deleted file mode 100644
index 9001e0bd27..0000000000
--- a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Pages/Home.razor
+++ /dev/null
@@ -1,7 +0,0 @@
-@page "/"
-
-Home
-
-Hello, world!
-
-Welcome to your new app.
diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Pages/PirateTalk.razor b/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Pages/PirateTalk.razor
deleted file mode 100644
index cba45d10a7..0000000000
--- a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/Components/Pages/PirateTalk.razor
+++ /dev/null
@@ -1,208 +0,0 @@
-@page "/pirate-talk"
-@attribute [StreamRendering(true)]
-@inject AgentClient AgentClient
-@inject IJSRuntime JSRuntime
-@inject ILogger Logger
-@rendermode InteractiveServer
-@using System.Text
-@using System.Text.Json
-@using Microsoft.Extensions.AI
-@using Microsoft.Extensions.AI.Agents
-
-Pirate Talk
-
-🏴☠️ Pirate Talk
-
-Chat with a pirate agent! Send a message and get a response in pirate speak.
-
-
-
- @foreach (var message in chatMessages)
- {
-
-
@(message.IsUser ? "You" : "🏴☠️ Pirate"):
-
@message.Content
-
- }
-
- @if (isStreaming && currentStreamedMessage.Length > 0)
- {
-
-
🏴☠️ Pirate:
-
@currentStreamedMessage▋
-
- }
-
-
-
-
-
-
-
-
-
-
-@code {
- private string currentMessage = "";
- private bool isStreaming = false;
- private string currentStreamedMessage = "";
- private List chatMessages = new();
- private string sessionId = Guid.NewGuid().ToString();
- private const string AgentName = "agent:pirate";
-
- protected override void OnInitialized()
- {
- Logger.LogDebug("Initializing PirateTalk component with session ID: {SessionId}", sessionId);
- }
-
- private async Task SendMessage()
- {
- if (string.IsNullOrWhiteSpace(currentMessage) || isStreaming)
- return;
-
- var userMessage = currentMessage.Trim();
- currentMessage = "";
-
- Logger.LogInformation("User sending message: '{UserMessage}' in session {SessionId}", userMessage, sessionId);
-
- // Add user message to chat
- chatMessages.Add(new ChatMessage { Content = userMessage, IsUser = true });
- Logger.LogDebug("Chat message added to collection. Total messages: {MessageCount}, Is user message: {IsUserMessage}", chatMessages.Count, true);
- Logger.LogTrace("StateHasChanged called during streaming for session {SessionId}", sessionId);
- StateHasChanged();
- await ScrollToBottom();
-
- // Start streaming response
- isStreaming = true;
- currentStreamedMessage = "";
- Logger.LogDebug("Starting streaming response for session {SessionId}", sessionId);
- Logger.LogTrace("StateHasChanged called during streaming for session {SessionId}", sessionId);
- StateHasChanged();
-
- try
- {
- var responseContent = new StringBuilder();
-
- await foreach (var update in AgentClient.SendMessageStreamAsync(AgentName, userMessage, sessionId))
- {
- Logger.LogTrace("Received streaming update with text length: {TextLength} for session {SessionId}", update.Text?.Length ?? 0, sessionId);
-
- // Extract text content from the AgentRunResponseUpdate
- var content = update.Text ?? "";
- if (!string.IsNullOrEmpty(content))
- {
- Logger.LogDebug("Extracted content from update: '{ExtractedContent}' for session {SessionId}", content, sessionId);
- responseContent.Append(content);
- currentStreamedMessage = responseContent.ToString();
- Logger.LogTrace("StateHasChanged called during streaming for session {SessionId}", sessionId);
- StateHasChanged();
- await ScrollToBottom();
- }
- }
-
- // Add the complete pirate response to chat messages
- if (responseContent.Length > 0)
- {
- Logger.LogInformation("Streaming completed with total response length: {ResponseLength} for session {SessionId}", responseContent.Length, sessionId);
- chatMessages.Add(new ChatMessage { Content = responseContent.ToString(), IsUser = false });
- Logger.LogDebug("Chat message added to collection. Total messages: {MessageCount}, Is user message: {IsUserMessage}", chatMessages.Count, false);
- }
- else
- {
- Logger.LogWarning("Empty response received from agent for session {SessionId}", sessionId);
- chatMessages.Add(new ChatMessage { Content = "Arrr, something went wrong with me response, matey!", IsUser = false });
- Logger.LogDebug("Chat message added to collection. Total messages: {MessageCount}, Is user message: {IsUserMessage}", chatMessages.Count, false);
- }
- }
- catch (Exception ex)
- {
- Logger.LogError(ex, "Error occurred while processing message in session {SessionId}: {ErrorMessage}", sessionId, ex.Message);
- chatMessages.Add(new ChatMessage { Content = $"Arrr, encountered rough seas: {ex.Message}", IsUser = false });
- Logger.LogDebug("Chat message added to collection. Total messages: {MessageCount}, Is user message: {IsUserMessage}", chatMessages.Count, false);
- }
- finally
- {
- isStreaming = false;
- currentStreamedMessage = "";
- Logger.LogTrace("StateHasChanged called during streaming for session {SessionId}", sessionId);
- StateHasChanged();
- await ScrollToBottom();
- }
- }
-
- private async Task HandleKeyPress(KeyboardEventArgs e)
- {
- Logger.LogDebug("Handling key press event: {Key} for session {SessionId}", e.Key, sessionId);
- if (e.Key == "Enter" && !e.ShiftKey)
- {
- await SendMessage();
- }
- }
-
- private async Task ScrollToBottom()
- {
- try
- {
- Logger.LogTrace("Scrolling chat to bottom for session {SessionId}", sessionId);
- await JSRuntime.InvokeVoidAsync("scrollToBottom", "chat-messages");
- }
- catch (Exception ex)
- {
- Logger.LogWarning(ex, "Failed to scroll to bottom due to JavaScript error for session {SessionId}", sessionId);
- // Ignore JS errors
- }
- }
-
- protected override async Task OnAfterRenderAsync(bool firstRender)
- {
- if (firstRender)
- {
- Logger.LogDebug("Component first render completed, JavaScript functions initialized for session {SessionId}", sessionId);
- await JSRuntime.InvokeVoidAsync("eval", @"
- window.scrollToBottom = function(elementId) {
- const element = document.getElementById(elementId);
- if (element) {
- element.scrollTop = element.scrollHeight;
- }
- };
- ");
- }
- }
-
- private class ChatMessage
- {
- public string Content { get; set; } = "";
- public bool IsUser { get; set; }
- }
-}
diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/ChatClientAgentActor.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentActor.cs
similarity index 64%
rename from dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/ChatClientAgentActor.cs
rename to dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentActor.cs
index 275c4f3467..9502e2c062 100644
--- a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/ChatClientAgentActor.cs
+++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentActor.cs
@@ -1,18 +1,22 @@
// Copyright (c) Microsoft. All rights reserved.
+using System;
+using System.Collections.Generic;
using System.Diagnostics;
using System.Text.Json;
-using Microsoft.Extensions.AI;
-using Microsoft.Extensions.AI.Agents;
+using System.Threading;
+using System.Threading.Tasks;
using Microsoft.Extensions.AI.Agents.Runtime;
+using Microsoft.Extensions.Logging;
-namespace HelloHttpApi.ApiService;
+namespace Microsoft.Extensions.AI.Agents.Hosting;
-internal sealed class ChatClientAgentActor(
+internal sealed class AgentActor(
AIAgent agent,
IActorRuntimeContext context,
- ILogger logger) : IActor
+ ILogger logger) : IActor
{
+ private const string ThreadStateKey = "thread";
private string? _etag;
private AgentThread? _thread;
@@ -25,7 +29,7 @@ internal sealed class ChatClientAgentActor(
// Restore thread state
var response = await context.ReadAsync(
- new ActorReadOperationBatch([new GetValueOperation("thread")]),
+ new ActorReadOperationBatch([new GetValueOperation(ThreadStateKey)]),
cancellationToken).ConfigureAwait(false);
this._etag = response.ETag;
@@ -33,8 +37,8 @@ internal sealed class ChatClientAgentActor(
{
if (threadResult.Value is { } threadJson)
{
- // Deserialize the thread state if it exist
- this._thread = await agent.DeserializeThreadAsync(threadJson, cancellationToken: cancellationToken).ConfigureAwait(false);
+ // Deserialize the thread state if it exists
+ await agent.DeserializeThreadAsync(threadJson, cancellationToken: cancellationToken).ConfigureAwait(false);
}
}
@@ -63,6 +67,11 @@ internal sealed class ChatClientAgentActor(
}
catch (Exception ex)
{
+ if (cancellationToken.IsCancellationRequested && ex is OperationCanceledException)
+ {
+ return;
+ }
+
Log.ErrorProcessingMessages(logger, ex, context.ActorId.ToString());
}
}
@@ -74,11 +83,25 @@ internal sealed class ChatClientAgentActor(
Debug.Assert(this._thread is not null);
Debug.Assert(this._etag is not null);
+ if (message.Method is not AgentActorConstants.RunMethodName)
+ {
+ // Unsupported method, we can only handle "Run" requests.
+ var data = JsonSerializer.SerializeToElement("Unsupported method.", AgentHostingJsonUtilities.DefaultOptions.GetTypeInfo(typeof(string)));
+ var writeResponse = await context.WriteAsync(
+ new(this._etag, [
+ new UpdateRequestOperation(
+ requestId,
+ RequestStatus.Failed,
+ data)]),
+ cancellationToken).ConfigureAwait(false);
+ return;
+ }
+
// Parse the request to get the agent run parameters
List? messages;
if (message.Params is { } payload)
{
- var arg = payload.Deserialize(ChatClientAgentActorJsonContext.Default.ChatClientAgentRunRequest);
+ var arg = payload.Deserialize(AgentHostingJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AgentRunRequest))) as AgentRunRequest;
messages = arg?.Messages;
}
@@ -91,7 +114,7 @@ internal sealed class ChatClientAgentActor(
var updates = new List();
await foreach (var update in agent.RunStreamingAsync(messages, this._thread, cancellationToken: cancellationToken).ConfigureAwait(false))
{
- var updateJson = JsonSerializer.SerializeToElement(update, AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AgentRunResponseUpdate)));
+ var updateJson = JsonSerializer.SerializeToElement(update, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AgentRunResponseUpdate)));
context.OnProgressUpdate(requestId, i++, updateJson);
updates.Add(update);
Log.AgentStreamingUpdate(logger, requestId, i);
@@ -99,9 +122,14 @@ internal sealed class ChatClientAgentActor(
var serializedRunResponse = JsonSerializer.SerializeToElement(
updates.ToAgentRunResponse(),
- AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AgentRunResponse)));
+ AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AgentRunResponse)));
+ var updatedThread = JsonSerializer.SerializeToElement(this._thread, AgentHostingJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AgentThread)));
var writeResponse = await context.WriteAsync(
- new(this._etag, [new UpdateRequestOperation(requestId, RequestStatus.Completed, serializedRunResponse)]), cancellationToken)
+ new(this._etag,
+ [
+ new UpdateRequestOperation(requestId, RequestStatus.Completed, serializedRunResponse),
+ new SetValueOperation(ThreadStateKey, updatedThread)
+ ]), cancellationToken)
.ConfigureAwait(false);
if (!writeResponse.Success)
{
diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentActorConstants.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentActorConstants.cs
new file mode 100644
index 0000000000..de10aa345a
--- /dev/null
+++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentActorConstants.cs
@@ -0,0 +1,8 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+namespace Microsoft.Extensions.AI.Agents.Hosting;
+
+internal static class AgentActorConstants
+{
+ public const string RunMethodName = "Run";
+}
diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentCatalog.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentCatalog.cs
new file mode 100644
index 0000000000..b62714daec
--- /dev/null
+++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentCatalog.cs
@@ -0,0 +1,38 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Threading;
+
+namespace Microsoft.Extensions.AI.Agents.Hosting;
+
+///
+/// Provides a catalog of registered AI agents within the hosting environment.
+///
+///
+/// The agent catalog allows enumeration of all registered agents in the dependency injection container.
+/// This is useful for scenarios where you need to discover and interact with multiple agents programmatically.
+///
+public abstract class AgentCatalog
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ protected AgentCatalog()
+ {
+ }
+
+ ///
+ /// Asynchronously retrieves all registered AI agents from the catalog.
+ ///
+ /// A cancellation token that can be used to cancel the enumeration operation.
+ ///
+ /// An asynchronous enumerable of instances representing all registered agents.
+ /// The enumeration will only include agents that are successfully resolved from the service provider.
+ ///
+ ///
+ /// This method enumerates through all registered agent names and attempts to resolve each agent
+ /// from the dependency injection container. Only successfully resolved agents are yielded.
+ /// The enumeration is lazy and agents are resolved on-demand during iteration.
+ ///
+ public abstract IAsyncEnumerable GetAgentsAsync(CancellationToken cancellationToken = default);
+}
diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentHostingJsonUtilities.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentHostingJsonUtilities.cs
new file mode 100644
index 0000000000..dcc62c5d52
--- /dev/null
+++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentHostingJsonUtilities.cs
@@ -0,0 +1,60 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Diagnostics.CodeAnalysis;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Microsoft.Extensions.AI.Agents.Runtime;
+
+namespace Microsoft.Extensions.AI.Agents.Hosting;
+
+/// Provides a collection of utility methods for working with JSON data in the context of agent hosting.
+public static partial class AgentHostingJsonUtilities
+{
+ ///
+ /// Gets the singleton used as the default in JSON serialization operations.
+ ///
+ ///
+ ///
+ /// For Native AOT or applications disabling , this instance
+ /// includes source generated contracts for all common exchange types contained in this library.
+ ///
+ ///
+ /// It additionally turns on the following settings:
+ ///
+ /// - Enables defaults.
+ /// - Enables as the default ignore condition for properties.
+ /// - Enables as the default number handling for number types.
+ ///
+ ///
+ ///
+ public static JsonSerializerOptions DefaultOptions { get; } = CreateDefaultOptions();
+
+ ///
+ /// Creates default options to use for agent hosting-related serialization.
+ ///
+ /// The configured options.
+ [UnconditionalSuppressMessage("ReflectionAnalysis", "IL3050:RequiresDynamicCode", Justification = "Converter is guarded by IsReflectionEnabledByDefault check.")]
+ [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access", Justification = "Converter is guarded by IsReflectionEnabledByDefault check.")]
+ private static JsonSerializerOptions CreateDefaultOptions()
+ {
+ // Copy the configuration from the source generated context.
+ JsonSerializerOptions options = new(JsonContext.Default.Options);
+
+ // Chain with all supported types from Microsoft.Extensions.AI.Agents.Abstractions.
+ options.TypeInfoResolverChain.Add(AgentAbstractionsJsonUtilities.DefaultOptions.TypeInfoResolver!);
+ options.TypeInfoResolverChain.Add(AgentRuntimeAbstractionsJsonUtilities.DefaultOptions.TypeInfoResolver!);
+
+ options.MakeReadOnly();
+ return options;
+ }
+
+ // Keep in sync with CreateDefaultOptions above.
+ [JsonSourceGenerationOptions(JsonSerializerDefaults.Web,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ NumberHandling = JsonNumberHandling.AllowReadingFromString)]
+ [JsonSerializable(typeof(AgentRunRequest))]
+ [JsonSerializable(typeof(AgentProxyThread))]
+ [JsonSerializable(typeof(AgentThread))]
+ [ExcludeFromCodeCoverage]
+ internal sealed partial class JsonContext : JsonSerializerContext;
+}
diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentProxy.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentProxy.cs
new file mode 100644
index 0000000000..e2ea2d0453
--- /dev/null
+++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentProxy.cs
@@ -0,0 +1,158 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
+using System.Runtime.CompilerServices;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.AI.Agents.Runtime;
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Extensions.AI.Agents.Hosting;
+
+///
+/// Represents a proxy for an AI agent that communicates with the agent runtime via an actor client.
+///
+public sealed class AgentProxy : AIAgent
+{
+ private readonly IActorClient _client;
+
+ ///
+ /// Initializes a new instance of the class with the specified agent name and actor client.
+ ///
+ /// The name of the agent.
+ /// The actor client used to communicate with the agent.
+ public AgentProxy(string name, IActorClient client)
+ {
+ Throw.IfNull(client);
+ Throw.IfNullOrEmpty(name);
+ this._client = client;
+ this.Name = name;
+ }
+
+ ///
+ public override string Name { get; }
+
+ ///
+ public override AgentThread GetNewThread() => new AgentProxyThread();
+
+ ///
+ /// Gets a thread by its .
+ ///
+ /// The thread identifier.
+ /// The thread.
+ public AgentThread GetThread(string conversationId) => new AgentProxyThread(conversationId);
+
+ ///
+ public override async Task RunAsync(
+ IReadOnlyCollection messages,
+ AgentThread? thread = null,
+ AgentRunOptions? options = null,
+ CancellationToken cancellationToken = default)
+ {
+ Throw.IfNull(messages);
+ var agentThread = GetAgentThreadId(thread);
+ return await this.RunAsync(messages, agentThread, cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ public override async IAsyncEnumerable RunStreamingAsync(
+ IReadOnlyCollection messages,
+ AgentThread? thread = null,
+ AgentRunOptions? options = null,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ Throw.IfNull(messages);
+ var agentThread = GetAgentThreadId(thread);
+ await foreach (var item in this.RunStreamingAsync(messages, agentThread, cancellationToken).ConfigureAwait(false))
+ {
+ yield return item;
+ }
+ }
+
+ private async Task RunAsync(IReadOnlyCollection messages, string threadId, CancellationToken cancellationToken)
+ {
+ Throw.IfNull(messages);
+ var handle = await this.RunCoreAsync(messages, threadId, cancellationToken).ConfigureAwait(false);
+ var response = await handle.GetResponseAsync(cancellationToken).ConfigureAwait(false);
+ return response.Status switch
+ {
+ RequestStatus.Completed => (AgentRunResponse)response.Data.Deserialize(
+ AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AgentRunResponse)))!,
+ RequestStatus.Failed => throw new InvalidOperationException($"The agent run request failed: {response.Data}"),
+ RequestStatus.Pending => throw new InvalidOperationException("The agent run request is still pending."),
+ _ => throw new NotSupportedException($"The agent run request returned an unsupported status: {response.Status}.")
+ };
+ }
+
+ private async IAsyncEnumerable RunStreamingAsync(
+ IReadOnlyCollection messages,
+ string threadId,
+ [EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ Throw.IfNull(messages);
+ var response = await this.RunCoreAsync(messages, threadId, cancellationToken).ConfigureAwait(false);
+ var updateTypeInfo = AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AgentRunResponseUpdate));
+ await foreach (var update in response.WatchUpdatesAsync(cancellationToken).ConfigureAwait(false))
+ {
+ if (update.Status is RequestStatus.Failed)
+ {
+ throw new InvalidOperationException($"The agent run request failed: {update.Data}");
+ }
+
+ if (update.Status is RequestStatus.Completed)
+ {
+ var responseTypeInfo = AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AgentRunResponse));
+ var runResponse = (AgentRunResponse)update.Data.Deserialize(responseTypeInfo)!;
+ foreach (var item in runResponse.ToAgentRunResponseUpdates())
+ {
+ yield return item;
+ }
+
+ yield break;
+ }
+
+ var runResponseUpdate = (AgentRunResponseUpdate)update.Data.Deserialize(updateTypeInfo)!;
+ yield return runResponseUpdate;
+ }
+ }
+
+ private static string GetAgentThreadId(AgentThread? thread)
+ {
+ if (thread is null)
+ {
+ return AgentProxyThread.CreateId();
+ }
+
+ if (thread is not AgentProxyThread agentProxyThread)
+ {
+ throw new ArgumentException("The thread must be an instance of AgentProxyThread.", nameof(thread));
+ }
+
+ return agentProxyThread.ConversationId!;
+ }
+
+ private async Task RunCoreAsync(IReadOnlyCollection messages, string threadId, CancellationToken cancellationToken)
+ {
+ Debug.Assert(messages is not null);
+ Debug.Assert(threadId is not null);
+ var newMessages = new List(messages);
+
+ var runRequest = new AgentRunRequest
+ {
+ Messages = newMessages
+ };
+
+ string messageId = newMessages.LastOrDefault()?.MessageId ?? Guid.NewGuid().ToString();
+ var actorRequest = new ActorRequest(
+ actorId: new ActorId(this.Name, threadId),
+ messageId,
+ method: AgentActorConstants.RunMethodName,
+ @params: JsonSerializer.SerializeToElement(runRequest, AgentHostingJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AgentRunRequest))));
+ var handle = await this._client.SendRequestAsync(actorRequest, cancellationToken).ConfigureAwait(false);
+ return handle;
+ }
+}
diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentProxyThread.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentProxyThread.cs
new file mode 100644
index 0000000000..83cea8eabb
--- /dev/null
+++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentProxyThread.cs
@@ -0,0 +1,72 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Text.RegularExpressions;
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Extensions.AI.Agents.Hosting;
+
+///
+/// Represents an agent thread for a .
+///
+internal sealed partial class AgentProxyThread : AgentThread
+{
+#if NET7_0_OR_GREATER
+ [System.Diagnostics.CodeAnalysis.StringSyntax("Regex")]
+#endif
+ private const string ThreadValidationRegex = "^[a-zA-Z0-9_.\\-~]+$";
+
+#if NET7_0_OR_GREATER
+ ///
+ /// Regular expression pattern for valid thread IDs.
+ /// Thread IDs must be alphanumeric and can contain hyphens, underscores, dots, and tildes (RFC 3986 unreserved characters).
+ ///
+ [GeneratedRegex(ThreadValidationRegex, RegexOptions.Compiled)]
+ private static partial Regex ValidIdPattern();
+#else
+ ///
+ /// Regular expression pattern for valid thread IDs.
+ /// Thread IDs must be alphanumeric and can contain hyphens, underscores, dots, and tildes (RFC 3986 unreserved characters).
+ ///
+ private static readonly Regex s_validIdPattern = new(ThreadValidationRegex, RegexOptions.Compiled);
+
+ ///
+ /// Regular expression pattern for valid thread IDs.
+ /// Thread IDs must be alphanumeric and can contain hyphens, underscores, dots, and tildes (RFC 3986 unreserved characters).
+ ///
+ private static Regex ValidIdPattern() => s_validIdPattern;
+#endif
+
+ ///
+ /// Initializes a new instance of the class with the specified identifier.
+ ///
+ /// The unique identifier for the agent proxy thread.
+ public AgentProxyThread(string id)
+ {
+ Throw.IfNullOrEmpty(id);
+ ValidateId(id);
+ this.ConversationId = id;
+ }
+
+ ///
+ /// Initializes a new instance of the class with the specified identifier.
+ ///
+ public AgentProxyThread() : this(CreateId())
+ {
+ }
+
+ internal static string CreateId() => Guid.NewGuid().ToString("N");
+
+ ///
+ /// Validates that the provided ID matches the required pattern for thread IDs.
+ ///
+ /// The ID to validate.
+ /// Thrown when the ID is not valid.
+ private static void ValidateId(string id)
+ {
+ if (!ValidIdPattern().IsMatch(id))
+ {
+ throw new ArgumentException($"Thread ID '{id}' is not valid. Thread IDs must contain only alphanumeric characters, hyphens, underscores, dots, and tildes.", nameof(id));
+ }
+ }
+}
diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentRunRequest.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentRunRequest.cs
new file mode 100644
index 0000000000..4ee50e85a0
--- /dev/null
+++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentRunRequest.cs
@@ -0,0 +1,18 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+
+namespace Microsoft.Extensions.AI.Agents.Hosting;
+
+///
+/// Represents a request to run an agent with a collection of chat messages.
+///
+public sealed class AgentRunRequest
+{
+ ///
+ /// Gets or sets the collection of chat messages to be processed by the agent.
+ ///
+ [JsonPropertyName("messages")]
+ public List? Messages { get; set; }
+}
diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/HostApplicationBuilderAgentExtensions.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/HostApplicationBuilderAgentExtensions.cs
new file mode 100644
index 0000000000..e402e27d52
--- /dev/null
+++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/HostApplicationBuilderAgentExtensions.cs
@@ -0,0 +1,158 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Linq;
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.AI.Agents.Runtime;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Extensions.AI.Agents.Hosting;
+
+///
+/// Provides extension methods for configuring AI agents in a host application builder.
+///
+public static class HostApplicationBuilderAgentExtensions
+{
+ ///
+ /// Adds an AI agent to the host application builder with the specified name and instructions.
+ ///
+ /// The host application builder to configure.
+ /// The name of the agent.
+ /// The instructions for the agent.
+ /// The configured host application builder.
+ /// Thrown when , , or is null.
+ public static IHostApplicationBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string instructions)
+ {
+ Throw.IfNull(builder);
+ Throw.IfNull(name);
+ return builder.AddAIAgent(name, instructions, chatClientServiceKey: null);
+ }
+
+ ///
+ /// Adds an AI agent to the host application builder with the specified name, instructions, and chat client key.
+ ///
+ /// The host application builder to configure.
+ /// The name of the agent.
+ /// The instructions for the agent.
+ /// The chat client which the agent will use for inference.
+ /// The configured host application builder.
+ /// Thrown when , , or is null.
+ public static IHostApplicationBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string instructions, IChatClient chatClient)
+ {
+ Throw.IfNull(builder);
+ Throw.IfNull(name);
+ return builder.AddAIAgent(name, (sp, key) => new ChatClientAgent(chatClient, instructions, key));
+ }
+
+ ///
+ /// Adds an AI agent to the host application builder with the specified name, instructions, and chat client key.
+ ///
+ /// The host application builder to configure.
+ /// The name of the agent.
+ /// The instructions for the agent.
+ /// A description of the agent.
+ /// The key to use when resolving the chat client from the service provider. If null, a non-keyed service will be resolved.
+ /// The configured host application builder.
+ /// Thrown when , , or is null.
+ public static IHostApplicationBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string instructions, string description, object? chatClientServiceKey)
+ {
+ Throw.IfNull(builder);
+ Throw.IfNull(name);
+ return builder.AddAIAgent(name, (sp, key) =>
+ {
+ var chatClient = chatClientServiceKey is null ? sp.GetRequiredService() : sp.GetRequiredKeyedService(chatClientServiceKey);
+ return new ChatClientAgent(chatClient, instructions: instructions, name: key, description: description);
+ });
+ }
+
+ ///
+ /// Adds an AI agent to the host application builder with the specified name, instructions, and chat client key.
+ ///
+ /// The host application builder to configure.
+ /// The name of the agent.
+ /// The instructions for the agent.
+ /// The key to use when resolving the chat client from the service provider. If null, a non-keyed service will be resolved.
+ /// The configured host application builder.
+ /// Thrown when , , or is null.
+ public static IHostApplicationBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string instructions, object? chatClientServiceKey)
+ {
+ Throw.IfNull(builder);
+ Throw.IfNull(name);
+ return builder.AddAIAgent(name, (sp, key) =>
+ {
+ var chatClient = chatClientServiceKey is null ? sp.GetRequiredService() : sp.GetRequiredKeyedService(chatClientServiceKey);
+ return new ChatClientAgent(chatClient, instructions, key);
+ });
+ }
+
+ ///
+ /// Adds an AI agent to the host application builder using a custom factory delegate.
+ ///
+ /// The host application builder to configure.
+ /// The name of the agent.
+ /// A factory delegate that creates the AI agent instance. The delegate receives the service provider and agent key as parameters.
+ /// The configured host application builder.
+ /// Thrown when , , or is null.
+ /// Thrown when the agent factory delegate returns null or an invalid AI agent instance.
+ public static IHostApplicationBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, Func createAgentDelegate)
+ {
+ Throw.IfNull(builder);
+ Throw.IfNull(name);
+ Throw.IfNull(createAgentDelegate);
+ builder.Services.AddKeyedSingleton(name, (sp, key) =>
+ {
+ Throw.IfNull(key);
+ var keyString = key as string;
+ Throw.IfNullOrEmpty(keyString);
+ var agent = createAgentDelegate(sp, keyString) ?? throw new InvalidOperationException($"The agent factory did not return a valid {nameof(AIAgent)} instance for key '{keyString}'.");
+ if (agent.Name != keyString)
+ {
+ throw new InvalidOperationException($"The agent factory returned an agent with name '{agent.Name}', but the expected name is '{keyString}'.");
+ }
+
+ return agent;
+ });
+
+ return builder.AddAgentActor(name);
+ }
+
+ private static IHostApplicationBuilder AddAgentActor(this IHostApplicationBuilder builder, string name)
+ {
+ Throw.IfNull(builder);
+
+ // Register the agent by name for discovery.
+ var agentHostBuilder = GetAgentRegistry(builder);
+ agentHostBuilder.AgentNames.Add(name);
+
+ // Add the actor runtime and register the agent actor type.
+ var actorBuilder = builder.AddActorRuntime();
+ actorBuilder.AddActorType(
+ new ActorType(name),
+ (sp, ctx) => new AgentActor(
+ sp.GetRequiredKeyedService(name),
+ ctx,
+ sp.GetRequiredService>()));
+ return builder;
+ }
+
+ private static LocalAgentRegistry GetAgentRegistry(IHostApplicationBuilder builder)
+ {
+ var descriptor = builder.Services.FirstOrDefault(s => !s.IsKeyedService && s.ServiceType.Equals(typeof(LocalAgentRegistry)));
+ if (descriptor?.ImplementationInstance is not LocalAgentRegistry instance)
+ {
+ instance = new LocalAgentRegistry();
+ ConfigureHostBuilder(builder, instance);
+ }
+
+ return instance;
+ }
+
+ private static void ConfigureHostBuilder(IHostApplicationBuilder builder, LocalAgentRegistry agentHostBuilderContext)
+ {
+ builder.Services.Add(ServiceDescriptor.Singleton(agentHostBuilderContext));
+ builder.Services.AddSingleton();
+ }
+}
diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/LocalAgentCatalog.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/LocalAgentCatalog.cs
new file mode 100644
index 0000000000..e578581d06
--- /dev/null
+++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/LocalAgentCatalog.cs
@@ -0,0 +1,37 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Microsoft.Extensions.AI.Agents.Hosting;
+
+// Implementation of an AgentCatalog which enumerates agents registered in the local service provider.
+internal sealed class LocalAgentCatalog : AgentCatalog
+{
+ public readonly HashSet _registeredAgents;
+ private readonly IServiceProvider _serviceProvider;
+
+ public LocalAgentCatalog(LocalAgentRegistry agentHostBuilder, IServiceProvider serviceProvider)
+ {
+ this._registeredAgents = [.. agentHostBuilder.AgentNames];
+ this._serviceProvider = serviceProvider;
+ }
+
+ public override async IAsyncEnumerable GetAgentsAsync([EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ await Task.CompletedTask.ConfigureAwait(false);
+
+ foreach (var name in this._registeredAgents)
+ {
+ var agent = this._serviceProvider.GetKeyedService(name);
+ if (agent is not null)
+ {
+ yield return agent;
+ }
+ }
+ }
+}
diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/LocalAgentRegistry.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/LocalAgentRegistry.cs
new file mode 100644
index 0000000000..a15e6d5e3c
--- /dev/null
+++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/LocalAgentRegistry.cs
@@ -0,0 +1,10 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+
+namespace Microsoft.Extensions.AI.Agents.Hosting;
+
+internal sealed class LocalAgentRegistry
+{
+ public HashSet AgentNames { get; } = [];
+}
diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/Log.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/Log.cs
new file mode 100644
index 0000000000..ab07708cf9
--- /dev/null
+++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/Log.cs
@@ -0,0 +1,57 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using Microsoft.Extensions.Logging;
+
+namespace Microsoft.Extensions.AI.Agents.Hosting;
+
+///
+/// High-performance logging messages using LoggerMessage source generator.
+///
+internal static partial class Log
+{
+ [LoggerMessage(
+ Level = LogLevel.Information,
+ Message = "Actor started: ActorId={ActorId}, AgentName={AgentName}")]
+ public static partial void ActorStarted(ILogger logger, string actorId, string agentName);
+
+ [LoggerMessage(
+ Level = LogLevel.Debug,
+ Message = "Thread state restored: ActorId={ActorId}, HasExistingThread={HasExistingThread}")]
+ public static partial void ThreadStateRestored(ILogger logger, string actorId, bool hasExistingThread);
+
+ [LoggerMessage(
+ Level = LogLevel.Information,
+ Message = "Processing agent request: RequestId={RequestId}, ActorId={ActorId}, MessageCount={MessageCount}")]
+ public static partial void ProcessingAgentRequest(ILogger logger, string requestId, string actorId, int messageCount);
+
+ [LoggerMessage(
+ Level = LogLevel.Debug,
+ Message = "Agent streaming update: RequestId={RequestId}, UpdateNumber={UpdateNumber}")]
+ public static partial void AgentStreamingUpdate(ILogger logger, string requestId, int updateNumber);
+
+ [LoggerMessage(
+ Level = LogLevel.Information,
+ Message = "Agent request completed: RequestId={RequestId}, TotalUpdates={TotalUpdates}")]
+ public static partial void AgentRequestCompleted(ILogger logger, string requestId, int totalUpdates);
+
+ [LoggerMessage(
+ Level = LogLevel.Error,
+ Message = "Agent request failed: RequestId={RequestId}, ActorId={ActorId}")]
+ public static partial void AgentRequestFailed(ILogger logger, Exception exception, string requestId, string actorId);
+
+ [LoggerMessage(
+ Level = LogLevel.Warning,
+ Message = "Unknown message type received: MessageType={MessageType}, ActorId={ActorId}")]
+ public static partial void UnknownMessageType(ILogger logger, string messageType, string actorId);
+
+ [LoggerMessage(
+ Level = LogLevel.Warning,
+ Message = "Error processing messages: ActorId={ActorId}")]
+ public static partial void ErrorProcessingMessages(ILogger logger, Exception exception, string actorId);
+
+ [LoggerMessage(
+ Level = LogLevel.Error,
+ Message = "Write operation failed: ActorId={ActorId}, RequestId={RequestId}")]
+ public static partial void WriteOperationFailed(ILogger logger, string actorId, string requestId);
+}
diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/Microsoft.Extensions.AI.Agents.Hosting.csproj b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/Microsoft.Extensions.AI.Agents.Hosting.csproj
new file mode 100644
index 0000000000..b8fcdadd10
--- /dev/null
+++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/Microsoft.Extensions.AI.Agents.Hosting.csproj
@@ -0,0 +1,44 @@
+
+
+
+ $(ProjectsTargetFrameworks)
+ $(ProjectsDebugTargetFrameworks)
+ alpha
+
+
+
+ true
+ true
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorJsonContext.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorJsonContext.cs
deleted file mode 100644
index 2652143485..0000000000
--- a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorJsonContext.cs
+++ /dev/null
@@ -1,41 +0,0 @@
-// Copyright (c) Microsoft. All rights reserved.
-
-using System.Text.Json;
-using System.Text.Json.Serialization;
-
-namespace Microsoft.Extensions.AI.Agents.Runtime;
-
-///
-/// Source-generated JSON type information for use by all agent runtime abstractions.
-///
-[JsonSourceGenerationOptions(
- JsonSerializerDefaults.Web,
- UseStringEnumConverter = true,
- DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
- WriteIndented = false)]
-[JsonSerializable(typeof(ActorId))]
-[JsonSerializable(typeof(ActorMessage))]
-[JsonSerializable(typeof(ActorReadOperation))]
-[JsonSerializable(typeof(ActorReadOperationBatch))]
-[JsonSerializable(typeof(ActorReadResult))]
-[JsonSerializable(typeof(ActorRequest))]
-[JsonSerializable(typeof(ActorRequestMessage))]
-[JsonSerializable(typeof(ActorRequestUpdate))]
-[JsonSerializable(typeof(ActorResponse))]
-[JsonSerializable(typeof(ActorResponseMessage))]
-[JsonSerializable(typeof(ActorType))]
-[JsonSerializable(typeof(ActorWriteOperation))]
-[JsonSerializable(typeof(ActorWriteOperationBatch))]
-[JsonSerializable(typeof(GetValueOperation))]
-[JsonSerializable(typeof(GetValueResult))]
-[JsonSerializable(typeof(JsonElement))]
-[JsonSerializable(typeof(ListKeysOperation))]
-[JsonSerializable(typeof(ListKeysResult))]
-[JsonSerializable(typeof(ReadResponse))]
-[JsonSerializable(typeof(RemoveKeyOperation))]
-[JsonSerializable(typeof(RequestStatus))]
-[JsonSerializable(typeof(SendRequestOperation))]
-[JsonSerializable(typeof(SetValueOperation))]
-[JsonSerializable(typeof(UpdateRequestOperation))]
-[JsonSerializable(typeof(WriteResponse))]
-internal sealed partial class ActorJsonContext : JsonSerializerContext;
diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorResponse.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorResponse.cs
index f11846c3f7..47543f567e 100644
--- a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorResponse.cs
+++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorResponse.cs
@@ -33,4 +33,25 @@ public sealed class ActorResponse
///
[JsonPropertyName("status")]
public RequestStatus Status { get; init; }
+
+ ///
+ public override string ToString()
+ {
+ string dataString;
+ if (this.Data.ValueKind == JsonValueKind.Undefined)
+ {
+ dataString = "undefined";
+ }
+ else
+ {
+ var rawText = this.Data.GetRawText();
+ dataString = rawText.Length switch
+ {
+ > 250 => $"{rawText.Substring(0, 250)}...",
+ _ => rawText,
+ };
+ }
+
+ return $"ActorResponse(ActorId: {this.ActorId}, Status: {this.Status}, MessageId: {this.MessageId ?? "null"}, Data: {dataString})";
+ }
}
diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorResponseHandle.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorResponseHandle.cs
index 2508bd28eb..5b4d095491 100644
--- a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorResponseHandle.cs
+++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorResponseHandle.cs
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
+using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
@@ -10,7 +11,7 @@ namespace Microsoft.Extensions.AI.Agents.Runtime;
///
/// Represents a handle to an actor response, allowing retrieval of the response data and status updates.
///
-public abstract class ActorResponseHandle
+public abstract class ActorResponseHandle : IDisposable
{
///
/// Attempts to get the response from the request if it is immediately available.
@@ -43,4 +44,17 @@ public abstract class ActorResponseHandle
/// A token to cancel the watch operation.
/// An asynchronous enumerable of request updates.
public abstract IAsyncEnumerable WatchUpdatesAsync(CancellationToken cancellationToken);
+
+ ///
+ public void Dispose()
+ {
+ this.Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+
+ ///
+ /// Disposes of the resources used by the class.
+ ///
+ /// A boolean indicating whether the method is being called from the method.
+ protected virtual void Dispose(bool disposing) { }
}
diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorRuntimeJsonUtilities.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorRuntimeJsonUtilities.cs
new file mode 100644
index 0000000000..db0af21862
--- /dev/null
+++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorRuntimeJsonUtilities.cs
@@ -0,0 +1,81 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Diagnostics.CodeAnalysis;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Microsoft.Extensions.AI.Agents.Runtime;
+
+/// Provides a collection of utility methods for working with JSON data in the context of actor runtime abstractions.
+public static partial class AgentRuntimeAbstractionsJsonUtilities
+{
+ ///
+ /// Gets the singleton used as the default in JSON serialization operations.
+ ///
+ ///
+ ///
+ /// For Native AOT or applications disabling , this instance
+ /// includes source generated contracts for all common exchange types contained in this library.
+ ///
+ ///
+ /// It additionally turns on the following settings:
+ ///
+ /// - Enables defaults.
+ /// - Enables as the default ignore condition for properties.
+ /// - Enables as the default number handling for number types.
+ /// - Enables for enum serialization.
+ ///
+ ///
+ ///
+ public static JsonSerializerOptions DefaultOptions { get; } = CreateDefaultOptions();
+
+ ///
+ /// Creates default options to use for actor runtime-related serialization.
+ ///
+ /// The configured options.
+ [UnconditionalSuppressMessage("ReflectionAnalysis", "IL3050:RequiresDynamicCode", Justification = "Converter is guarded by IsReflectionEnabledByDefault check.")]
+ [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access", Justification = "Converter is guarded by IsReflectionEnabledByDefault check.")]
+ private static JsonSerializerOptions CreateDefaultOptions()
+ {
+ // Copy the configuration from the source generated context.
+ JsonSerializerOptions options = new(JsonContext.Default.Options);
+
+ options.MakeReadOnly();
+ return options;
+ }
+
+ ///
+ /// Source-generated JSON type information for use by all agent runtime abstractions.
+ ///
+ [JsonSourceGenerationOptions(
+ JsonSerializerDefaults.Web,
+ UseStringEnumConverter = true,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ WriteIndented = false)]
+ [JsonSerializable(typeof(ActorId))]
+ [JsonSerializable(typeof(ActorMessage))]
+ [JsonSerializable(typeof(ActorReadOperation))]
+ [JsonSerializable(typeof(ActorReadOperationBatch))]
+ [JsonSerializable(typeof(ActorReadResult))]
+ [JsonSerializable(typeof(ActorRequest))]
+ [JsonSerializable(typeof(ActorRequestMessage))]
+ [JsonSerializable(typeof(ActorRequestUpdate))]
+ [JsonSerializable(typeof(ActorResponse))]
+ [JsonSerializable(typeof(ActorResponseMessage))]
+ [JsonSerializable(typeof(ActorType))]
+ [JsonSerializable(typeof(ActorWriteOperation))]
+ [JsonSerializable(typeof(ActorWriteOperationBatch))]
+ [JsonSerializable(typeof(GetValueOperation))]
+ [JsonSerializable(typeof(GetValueResult))]
+ [JsonSerializable(typeof(JsonElement))]
+ [JsonSerializable(typeof(ListKeysOperation))]
+ [JsonSerializable(typeof(ListKeysResult))]
+ [JsonSerializable(typeof(ReadResponse))]
+ [JsonSerializable(typeof(RemoveKeyOperation))]
+ [JsonSerializable(typeof(RequestStatus))]
+ [JsonSerializable(typeof(SendRequestOperation))]
+ [JsonSerializable(typeof(SetValueOperation))]
+ [JsonSerializable(typeof(UpdateRequestOperation))]
+ [JsonSerializable(typeof(WriteResponse))]
+ internal sealed partial class JsonContext : JsonSerializerContext;
+}
diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorType.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorType.cs
index 619e018083..67585803b0 100644
--- a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorType.cs
+++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorType.cs
@@ -4,6 +4,7 @@ using System;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
+using Microsoft.Shared.Diagnostics;
namespace Microsoft.Extensions.AI.Agents.Runtime;
@@ -13,15 +14,21 @@ namespace Microsoft.Extensions.AI.Agents.Runtime;
[JsonConverter(typeof(Converter))]
public readonly partial struct ActorType : IEquatable
{
+#if NET7_0_OR_GREATER
+ [System.Diagnostics.CodeAnalysis.StringSyntax("Regex")]
+#endif
+ private const string AgentTypeValidationRegex = "^[a-zA-Z_][a-zA-Z._:\\-0-9]*$";
+
///
/// Initializes a new instance of the struct.
///
/// The actor type.
public ActorType(string type)
{
- if (!IsValid(type))
+ Throw.IfNullOrEmpty(type);
+ if (!IsValidType(type))
{
- throw new ArgumentException($"Invalid type: '{type}'. Must be alphanumeric (a-z, 0-9, _) and cannot start with a number or contain spaces.");
+ throw new ArgumentException($"Invalid type: '{type}'. Must start with a letter or underscore, and can only contain letters, dots, underscores, colons, hyphens, and numbers.", nameof(type));
}
this.Name = type;
@@ -59,16 +66,23 @@ public readonly partial struct ActorType : IEquatable
public static bool operator !=(ActorType left, ActorType right) =>
!(left == right);
- internal static bool IsValid(string type) =>
- type is not null && TypeRegex().IsMatch(type);
-
-#if NET
- [GeneratedRegex("^[a-zA-Z_][a-zA-Z_:0-9]*$")]
+#if NET7_0_OR_GREATER
+ [GeneratedRegex(AgentTypeValidationRegex)]
private static partial Regex TypeRegex();
#else
- private static Regex TypeRegex() => new("^[a-zA-Z_][a-zA-Z_:0-9:]*$", RegexOptions.Compiled);
+ private static readonly Regex s_typeRegex = new(AgentTypeValidationRegex, RegexOptions.Compiled);
+ private static Regex TypeRegex() => s_typeRegex;
#endif
+ ///
+ /// Validates whether the provided type string is a valid actor type.
+ ///
+ public static bool IsValidType(string type)
+ {
+ Throw.IfNullOrEmpty(type);
+ return TypeRegex().IsMatch(type);
+ }
+
///
/// JSON converter for .
///
diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/RequestStatusExtensions.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/RequestStatusExtensions.cs
new file mode 100644
index 0000000000..5f985a7968
--- /dev/null
+++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/RequestStatusExtensions.cs
@@ -0,0 +1,16 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+namespace Microsoft.Extensions.AI.Agents.Runtime;
+
+///
+/// Provides extension methods for the enumeration.
+///
+public static class RequestStatusExtensions
+{
+ ///
+ /// Determines if the request status indicates that the request has terminated.
+ ///
+ /// The request status to check.
+ /// if the request has terminated; otherwise, .
+ public static bool IsTerminated(this RequestStatus status) => status != RequestStatus.Pending;
+}
diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB/CosmosActorStateJsonContext.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB/CosmosActorStateJsonContext.cs
index e88dbe310e..3f12809e16 100644
--- a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB/CosmosActorStateJsonContext.cs
+++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB/CosmosActorStateJsonContext.cs
@@ -16,4 +16,6 @@ namespace Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB;
[JsonSerializable(typeof(ActorStateDocument))]
[JsonSerializable(typeof(ActorRootDocument))]
[JsonSerializable(typeof(KeyProjection))]
-internal sealed partial class CosmosActorStateJsonContext : JsonSerializerContext;
+[JsonSerializable(typeof(KeyProjection[]))]
+[JsonSerializable(typeof(JsonElement))]
+public sealed partial class CosmosActorStateJsonContext : JsonSerializerContext;
diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/ActorRuntimeJsonContext.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/ActorRuntimeJsonContext.cs
deleted file mode 100644
index 865bea794e..0000000000
--- a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/ActorRuntimeJsonContext.cs
+++ /dev/null
@@ -1,14 +0,0 @@
-// Copyright (c) Microsoft. All rights reserved.
-
-using System.Text.Json;
-using System.Text.Json.Serialization;
-
-namespace Microsoft.Extensions.AI.Agents.Runtime;
-
-[JsonSourceGenerationOptions(
- JsonSerializerDefaults.Web,
- UseStringEnumConverter = true,
- DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
- WriteIndented = false)]
-[JsonSerializable(typeof(string))]
-internal sealed partial class ActorRuntimeJsonContext : JsonSerializerContext;
diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/AgentRuntimeJsonUtilities.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/AgentRuntimeJsonUtilities.cs
new file mode 100644
index 0000000000..9f1eb79c79
--- /dev/null
+++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/AgentRuntimeJsonUtilities.cs
@@ -0,0 +1,57 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Diagnostics.CodeAnalysis;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Microsoft.Extensions.AI.Agents.Runtime;
+
+/// Provides a collection of utility methods for working with JSON data in the context of agent runtime.
+public static partial class AgentRuntimeJsonUtilities
+{
+ ///
+ /// Gets the singleton used as the default in JSON serialization operations.
+ ///
+ ///
+ ///
+ /// For Native AOT or applications disabling , this instance
+ /// includes source generated contracts for all common exchange types contained in this library.
+ ///
+ ///
+ /// It additionally turns on the following settings:
+ ///
+ /// - Enables defaults.
+ /// - Enables as the default ignore condition for properties.
+ /// - Enables as the default number handling for number types.
+ /// - Enables for enum serialization.
+ ///
+ ///
+ ///
+ public static JsonSerializerOptions DefaultOptions { get; } = CreateDefaultOptions();
+
+ ///
+ /// Creates default options to use for agent runtime-related serialization.
+ ///
+ /// The configured options.
+ [UnconditionalSuppressMessage("ReflectionAnalysis", "IL3050:RequiresDynamicCode", Justification = "Converter is guarded by IsReflectionEnabledByDefault check.")]
+ [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access", Justification = "Converter is guarded by IsReflectionEnabledByDefault check.")]
+ private static JsonSerializerOptions CreateDefaultOptions()
+ {
+ // Copy the configuration from the source generated context.
+ JsonSerializerOptions options = new(JsonContext.Default.Options);
+
+ // Chain with all supported types from Microsoft.Extensions.AI.Agents.Runtime.Abstractions.
+ options.TypeInfoResolverChain.Add(AgentRuntimeAbstractionsJsonUtilities.DefaultOptions.TypeInfoResolver!);
+
+ options.MakeReadOnly();
+ return options;
+ }
+
+ [JsonSourceGenerationOptions(
+ JsonSerializerDefaults.Web,
+ UseStringEnumConverter = true,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ WriteIndented = false)]
+ [JsonSerializable(typeof(string))]
+ internal sealed partial class JsonContext : JsonSerializerContext;
+}
diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/InProcessActorContext.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/InProcessActorContext.cs
index ad10dd07e6..317e73ef22 100644
--- a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/InProcessActorContext.cs
+++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/InProcessActorContext.cs
@@ -100,6 +100,21 @@ internal sealed class InProcessActorContext : IActorRuntimeContext, IAsyncDispos
}
}
+ public bool TryGetResponseHandle(string messageId, [NotNullWhen(true)] out ActorResponseHandle? handle)
+ {
+ lock (this._lock)
+ {
+ if (!this._inbox.TryGetValue(messageId, out var entry))
+ {
+ handle = null;
+ return false;
+ }
+
+ handle = new InProcessActorResponseHandle(this, entry);
+ return true;
+ }
+ }
+
public ActorResponseHandle SendRequest(ActorRequest request)
{
using var activity = ActivitySource.StartActivity(
@@ -133,7 +148,9 @@ internal sealed class InProcessActorContext : IActorRuntimeContext, IAsyncDispos
requestStatus = "found";
}
+#pragma warning disable CA2000 // Dispose objects before losing scope
var handle = new InProcessActorResponseHandle(this, entry);
+#pragma warning restore CA2000 // Dispose objects before losing scope
Log.ResponseHandleCreated(this._logger, this.ActorId.ToString(), request.MessageId);
activity.Complete(RequestCompleted, this.ActorId, [(Tel.Request.Status, requestStatus), (Tel.Response.Status, HandleCreated)],
@@ -243,19 +260,11 @@ internal sealed class InProcessActorContext : IActorRuntimeContext, IAsyncDispos
[.. operations.Operations.OfType()];
WriteResponse result;
- if (writeOps.Count == 0)
- {
- // Nothing to write
- result = new WriteResponse(operations.ETag, success: true);
- }
- else
- {
- result = await this.Storage.WriteStateAsync(
- this.ActorId,
- writeOps,
- operations.ETag,
- cancellationToken).ConfigureAwait(false);
- }
+ result = await this.Storage.WriteStateAsync(
+ this.ActorId,
+ writeOps,
+ operations.ETag,
+ cancellationToken).ConfigureAwait(false);
Log.WriteOperationCompleted(this._logger, this.ActorId.ToString(), result.Success);
@@ -396,7 +405,7 @@ internal sealed class InProcessActorContext : IActorRuntimeContext, IAsyncDispos
{
ActorId = context.ActorId,
MessageId = entry.Request.MessageId,
- Data = JsonSerializer.SerializeToElement($"Error: {exception.Message}", ActorRuntimeJsonContext.Default.String),
+ Data = JsonSerializer.SerializeToElement($"Error: {exception.Message}", AgentRuntimeJsonUtilities.JsonContext.Default.String),
Status = RequestStatus.Failed,
};
}
@@ -433,6 +442,13 @@ internal sealed class InProcessActorContext : IActorRuntimeContext, IAsyncDispos
{
yield return new ActorRequestUpdate(update.Status, update.Data);
}
+
+ var response = await entry.Response
+#if NET8_0_OR_GREATER
+ .WaitAsync(cancellationToken)
+#endif
+ .ConfigureAwait(false);
+ yield return new ActorRequestUpdate(response.Status, response.Data);
}
}
}
diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/InProcessActorRuntime.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/InProcessActorRuntime.cs
index 8609799a47..60fe298c0d 100644
--- a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/InProcessActorRuntime.cs
+++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/InProcessActorRuntime.cs
@@ -162,7 +162,17 @@ internal sealed class InProcessActorClient(InProcessActorRuntime runtime) : IAct
activity.SetupRequestOperation(actorId, messageId, service: "ActorClient", rpcMethod: "GetResponse");
- throw new NotImplementedException("GetResponseAsync is not yet implemented");
+ var actorContext = this._runtime.GetOrCreateActor(actorId);
+#pragma warning disable CA2000 // Dispose objects before losing scope
+ if (actorContext.TryGetResponseHandle(messageId, out var handle))
+ {
+ return new(handle);
+ }
+#pragma warning restore CA2000 // Dispose objects before losing scope
+
+#pragma warning disable CA2000 // Dispose objects before losing scope
+ return new(new NotFoundActorResponseHandle(actorId, messageId));
+#pragma warning restore CA2000 // Dispose objects before losing scope
}
public ValueTask SendRequestAsync(ActorRequest request, CancellationToken cancellationToken)
@@ -180,7 +190,9 @@ internal sealed class InProcessActorClient(InProcessActorRuntime runtime) : IAct
// Ensure the message is enqueued on the actor's inbox, getting a response handle for it.
var actorId = request.ActorId;
var actorContext = this._runtime.GetOrCreateActor(actorId);
+#pragma warning disable CA2000 // Dispose objects before losing scope
var response = actorContext.SendRequest(request);
+#pragma warning restore CA2000 // Dispose objects before losing scope
activity.Complete(MessageSent, actorId, Sent, (Tel.Message.Id, request.MessageId));
diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/NotFoundActorResponseHandle.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/NotFoundActorResponseHandle.cs
new file mode 100644
index 0000000000..d3a7f0e5f0
--- /dev/null
+++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/NotFoundActorResponseHandle.cs
@@ -0,0 +1,47 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Microsoft.Extensions.AI.Agents.Runtime;
+
+internal sealed class NotFoundActorResponseHandle : ActorResponseHandle
+{
+ private readonly ActorResponse _response;
+
+ public NotFoundActorResponseHandle(ActorId actorId, string messageId)
+ {
+ this._response = new ActorResponse()
+ {
+ Status = RequestStatus.NotFound,
+ ActorId = actorId,
+ MessageId = messageId,
+ };
+ }
+
+ public override ValueTask CancelAsync(CancellationToken cancellationToken)
+ {
+ throw new InvalidOperationException(
+ $"Failed to cancel request for actor '{this._response.ActorId}' with message ID '{this._response.MessageId}'. The request was not found.");
+ }
+
+ public override ValueTask GetResponseAsync(CancellationToken cancellationToken)
+ {
+ return new ValueTask(this._response);
+ }
+
+ public override bool TryGetResponse([NotNullWhen(true)] out ActorResponse? response)
+ {
+ response = this._response;
+ return true;
+ }
+
+ public override async IAsyncEnumerable WatchUpdatesAsync([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ yield break;
+ }
+}
diff --git a/dotnet/tests/CosmosDB.IntegrationTests/Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB.Tests/CosmosTestFixture.cs b/dotnet/tests/CosmosDB.IntegrationTests/Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB.Tests/CosmosTestFixture.cs
index 09c0b5b6ea..c43ce25cbd 100644
--- a/dotnet/tests/CosmosDB.IntegrationTests/Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB.Tests/CosmosTestFixture.cs
+++ b/dotnet/tests/CosmosDB.IntegrationTests/Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB.Tests/CosmosTestFixture.cs
@@ -53,6 +53,7 @@ public class CosmosTestFixture : IAsyncLifetime
UseSystemTextJsonSerializerWithOptions = new JsonSerializerOptions()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ TypeInfoResolver = CosmosActorStateJsonContext.Default
}
};
diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.Hosting.UnitTests/AgentActorTests.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.Hosting.UnitTests/AgentActorTests.cs
new file mode 100644
index 0000000000..d4d8901c57
--- /dev/null
+++ b/dotnet/tests/Microsoft.Extensions.AI.Agents.Hosting.UnitTests/AgentActorTests.cs
@@ -0,0 +1,35 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Threading.Tasks;
+using Microsoft.Extensions.AI.Agents.Runtime;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+
+namespace Microsoft.Extensions.AI.Agents.Hosting.UnitTests;
+
+///
+/// Unit tests for .
+///
+public class AgentActorTests
+{
+ ///
+ /// Verifies that calling DisposeAsync completes successfully without throwing an exception.
+ ///
+ [Fact]
+ public async Task DisposeAsync_NoException_CompletesSuccessfullyAsync()
+ {
+ // Arrange
+ var mockAgent = new Mock();
+ var mockContext = new Mock();
+ var mockLogger = NullLoggerFactory.Instance.CreateLogger();
+ var actor = new AgentActor(mockAgent.Object, mockContext.Object, mockLogger);
+
+ // Act
+ var valueTask = actor.DisposeAsync();
+
+ // Assert
+ Assert.True(valueTask.IsCompleted, "DisposeAsync should return a completed ValueTask.");
+ await valueTask;
+ }
+}
diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.Hosting.UnitTests/AgentProxyTests.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.Hosting.UnitTests/AgentProxyTests.cs
new file mode 100644
index 0000000000..17b3274e1d
--- /dev/null
+++ b/dotnet/tests/Microsoft.Extensions.AI.Agents.Hosting.UnitTests/AgentProxyTests.cs
@@ -0,0 +1,758 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.AI.Agents.Runtime;
+using Moq;
+
+namespace Microsoft.Extensions.AI.Agents.Hosting.UnitTests;
+
+///
+/// Tests for the constructor.
+///
+public class AgentProxyTests
+{
+ ///
+ /// Verifies that the constructor assigns the Name property correctly for various valid agent names.
+ ///
+ [Theory]
+ [InlineData("agent")]
+ [InlineData(" ")]
+ [InlineData("特殊字符")]
+ [InlineData(" a")]
+ public void Constructor_ValidName_SetsNameProperty(string name)
+ {
+ // Arrange
+ var mockClient = new Mock();
+
+ // Act
+ var proxy = new AgentProxy(name, mockClient.Object);
+
+ // Assert
+ Assert.Equal(name, proxy.Name);
+ }
+
+ ///
+ /// Verifies that GetNewThread returns a non-null instance.
+ ///
+ [Fact]
+ public void GetNewThread_WhenCalled_ReturnsNewAgentProxyThreadInstance()
+ {
+ // Arrange
+ var mockClient = new Mock();
+ var proxy = new AgentProxy("agentName", mockClient.Object);
+
+ // Act
+ AgentThread result = proxy.GetNewThread();
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.IsType(result);
+ }
+
+ ///
+ /// Verifies that consecutive calls to GetNewThread return distinct instances.
+ ///
+ [Fact]
+ public void GetNewThread_MultipleCalls_ReturnsDistinctInstances()
+ {
+ // Arrange
+ var mockClient = new Mock();
+ var proxy = new AgentProxy("agentName", mockClient.Object);
+
+ // Act
+ AgentThread first = proxy.GetNewThread();
+ AgentThread second = proxy.GetNewThread();
+
+ // Assert
+ Assert.NotNull(first);
+ Assert.NotNull(second);
+ Assert.NotSame(first, second);
+ }
+ private const string AgentName = "agentName";
+ private const string ThreadId = "thread1";
+ private static readonly IReadOnlyCollection s_emptyMessages = new List();
+
+ private static bool IsValidGuid(string value)
+ {
+ return Guid.TryParse(value, out _);
+ }
+
+ ///
+ /// Verifies that RunAsync returns a deserialized AgentRunResponse when the actor response status is Completed.
+ /// Input: empty messages, threadId, Completed status with empty JSON object.
+ /// Expected: AgentRunResponse with no messages.
+ ///
+ [Fact]
+ public async Task RunAsync_WhenStatusIsCompleted_ReturnsDeserializedResponseAsync()
+ {
+ // Arrange
+ var mockClient = new Mock();
+ var mockHandle = new Mock();
+ var jsonElement = JsonDocument.Parse("{}").RootElement;
+ var actorResponse = new ActorResponse
+ {
+ ActorId = new ActorId(AgentName, ThreadId),
+ MessageId = "msg1",
+ Data = jsonElement,
+ Status = RequestStatus.Completed
+ };
+ mockHandle
+ .Setup(h => h.GetResponseAsync(It.IsAny()))
+ .Returns(new ValueTask(actorResponse));
+ mockClient
+ .Setup(c => c.SendRequestAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(mockHandle.Object);
+
+ var proxy = new AgentProxy(AgentName, mockClient.Object);
+ var thread = proxy.GetThread(ThreadId);
+
+ // Act
+ var result = await proxy.RunAsync(s_emptyMessages, thread);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Empty(result.Messages);
+ }
+
+ ///
+ /// Verifies that RunAsync throws an InvalidOperationException when the actor response status is Failed.
+ /// Input: empty messages, threadId, Failed status.
+ /// Expected: InvalidOperationException with message containing the response data.
+ ///
+ [Fact]
+ public async Task RunAsync_WhenStatusIsFailed_ThrowsInvalidOperationExceptionAsync()
+ {
+ // Arrange
+ var mockClient = new Mock();
+ var mockHandle = new Mock();
+ var jsonElement = JsonDocument.Parse("{}").RootElement;
+ var actorResponse = new ActorResponse
+ {
+ ActorId = new ActorId(AgentName, ThreadId),
+ MessageId = "msg1",
+ Data = jsonElement,
+ Status = RequestStatus.Failed
+ };
+ mockHandle
+ .Setup(h => h.GetResponseAsync(It.IsAny()))
+ .Returns(new ValueTask(actorResponse));
+ mockClient
+ .Setup(c => c.SendRequestAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(mockHandle.Object);
+
+ var proxy = new AgentProxy(AgentName, mockClient.Object);
+ var thread = proxy.GetThread(ThreadId);
+
+ // Act & Assert
+ var exception = await Assert.ThrowsAsync(() =>
+ proxy.RunAsync(s_emptyMessages, thread));
+ Assert.Equal("The agent run request failed: {}", exception.Message);
+ }
+
+ ///
+ /// Verifies that RunAsync throws an InvalidOperationException when the actor response status is Pending.
+ /// Input: empty messages, threadId, Pending status.
+ /// Expected: InvalidOperationException with pending message.
+ ///
+ [Fact]
+ public async Task RunAsync_WhenStatusIsPending_ThrowsInvalidOperationExceptionAsync()
+ {
+ // Arrange
+ var mockClient = new Mock();
+ var mockHandle = new Mock();
+ var jsonElement = JsonDocument.Parse("{}").RootElement;
+ var actorResponse = new ActorResponse
+ {
+ ActorId = new ActorId(AgentName, ThreadId),
+ MessageId = "msg1",
+ Data = jsonElement,
+ Status = RequestStatus.Pending
+ };
+ mockHandle
+ .Setup(h => h.GetResponseAsync(It.IsAny()))
+ .Returns(new ValueTask(actorResponse));
+ mockClient
+ .Setup(c => c.SendRequestAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(mockHandle.Object);
+
+ var proxy = new AgentProxy(AgentName, mockClient.Object);
+ var thread = proxy.GetThread(ThreadId);
+
+ // Act & Assert
+ var exception = await Assert.ThrowsAsync(() =>
+ proxy.RunAsync(s_emptyMessages, thread));
+ Assert.Equal("The agent run request is still pending.", exception.Message);
+ }
+
+ ///
+ /// Verifies that RunAsync throws a NotSupportedException when the actor response status is unsupported.
+ /// Input: empty messages, threadId, NotFound status.
+ /// Expected: NotSupportedException with unsupported status message.
+ ///
+ [Fact]
+ public async Task RunAsync_WhenStatusIsUnsupported_ThrowsNotSupportedExceptionAsync()
+ {
+ // Arrange
+ var mockClient = new Mock();
+ var mockHandle = new Mock();
+ var jsonElement = JsonDocument.Parse("{}").RootElement;
+ var actorResponse = new ActorResponse
+ {
+ ActorId = new ActorId(AgentName, ThreadId),
+ MessageId = "msg1",
+ Data = jsonElement,
+ Status = RequestStatus.NotFound
+ };
+ mockHandle
+ .Setup(h => h.GetResponseAsync(It.IsAny()))
+ .Returns(new ValueTask(actorResponse));
+ mockClient
+ .Setup(c => c.SendRequestAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(mockHandle.Object);
+
+ var proxy = new AgentProxy(AgentName, mockClient.Object);
+ var thread = proxy.GetThread(ThreadId);
+
+ // Act & Assert
+ var exception = await Assert.ThrowsAsync(() =>
+ proxy.RunAsync(s_emptyMessages, thread));
+ Assert.Equal($"The agent run request returned an unsupported status: {RequestStatus.NotFound}.", exception.Message);
+ }
+
+ ///
+ /// Verifies that passing an AgentThread that is not an AgentProxyThread to RunStreamingAsync throws an ArgumentException.
+ ///
+ [Fact]
+ public async System.Threading.Tasks.Task RunStreamingAsync_InvalidThread_ThrowsArgumentExceptionAsync()
+ {
+ // Arrange
+ var mockClient = new Mock();
+ var proxy = new AgentProxy("testAgent", mockClient.Object);
+ AgentThread invalidThread = new Mock().Object;
+
+ // Act & Assert
+ await Assert.ThrowsAsync(async () =>
+ {
+ await foreach (var _ in proxy.RunStreamingAsync(Array.Empty(), invalidThread, cancellationToken: CancellationToken.None))
+ {
+ }
+ });
+ }
+
+ ///
+ /// This test verifies that RunStreamingAsync completes without throwing when a valid AgentProxyThread is used.
+ /// TODO: Mock IActorClient.SendRequestAsync to return an ActorResponseHandle whose WatchUpdatesAsync yields no updates.
+ ///
+ [Fact(Skip = "Mocking of ActorResponseHandle.WatchUpdatesAsync with IActorClient is required")]
+ public async System.Threading.Tasks.Task RunStreamingAsync_ValidProxyThread_CompletesSuccessfullyAsync()
+ {
+ // Arrange
+ var mockClient = new Mock();
+ var proxy = new AgentProxy("testAgent", mockClient.Object);
+ var proxyThread = new AgentProxyThread();
+
+ // Act & Assert
+ await foreach (var _ in proxy.RunStreamingAsync(Array.Empty(), proxyThread, cancellationToken: CancellationToken.None))
+ {
+ // No items expected
+ }
+ }
+
+ ///
+ /// Verifies that RunStreamingAsync yields AgentRunResponseUpdate for non-failed statuses.
+ /// This test uses a mock IActorClient to return an ActorResponseHandle that yields a single update with given status.
+ /// Expected: The method yields the deserialized update.
+ ///
+ [Theory]
+ [InlineData(RequestStatus.Completed)]
+ [InlineData(RequestStatus.Pending)]
+ public async Task RunStreamingAsync_NonFailedStatus_YieldsAgentRunResponseUpdateAsync(RequestStatus status)
+ {
+ // Arrange
+ var messages = Array.Empty();
+ var threadId = "thread1";
+ var expectedUpdate = new AgentRunResponseUpdate(ChatRole.Assistant, "response");
+
+ JsonElement jsonElement;
+ if (status == RequestStatus.Completed)
+ {
+ // For Completed status, the implementation expects AgentRunResponse
+ var agentRunResponse = new AgentRunResponse
+ {
+ Messages = new List { new(ChatRole.Assistant, "response") }
+ };
+ var responseTypeInfo = AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AgentRunResponse));
+ jsonElement = JsonSerializer.SerializeToElement(agentRunResponse, responseTypeInfo);
+ }
+ else
+ {
+ // For Pending status, the implementation expects AgentRunResponseUpdate
+ var updateTypeInfo = AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AgentRunResponseUpdate));
+ jsonElement = JsonSerializer.SerializeToElement(expectedUpdate, updateTypeInfo);
+ }
+
+ var actorUpdate = new ActorRequestUpdate(status, jsonElement);
+ var mockHandle = new Mock();
+ mockHandle
+ .Setup(h => h.WatchUpdatesAsync(It.IsAny()))
+ .Returns(GetAsyncEnumerableAsync(actorUpdate));
+ var mockClient = new Mock();
+ mockClient
+ .Setup(c => c.SendRequestAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(mockHandle.Object);
+
+ var proxy = new AgentProxy("agentName", mockClient.Object);
+ var thread = proxy.GetThread(threadId);
+
+ // Act
+ var results = new List();
+ await foreach (var update in proxy.RunStreamingAsync(messages, thread))
+ {
+ results.Add(update);
+ }
+
+ // Assert
+ Assert.Single(results);
+ Assert.Equal(expectedUpdate.Text, results[0].Text);
+ Assert.Equal(expectedUpdate.Role, results[0].Role);
+ }
+
+ private static async IAsyncEnumerable GetAsyncEnumerableAsync(ActorRequestUpdate update)
+ {
+ yield return update;
+ await Task.CompletedTask;
+ }
+
+ ///
+ /// Verifies that RunStreamingAsync throws InvalidOperationException when an update status is Failed.
+ /// Uses a mock IActorClient to return a Failed update. Expected: InvalidOperationException is thrown.
+ ///
+ [Fact]
+ public async Task RunStreamingAsync_FailedStatus_ThrowsInvalidOperationExceptionAsync()
+ {
+ // Arrange
+ var messages = Array.Empty();
+ var threadId = "thread1";
+ var expectedUpdate = new AgentRunResponseUpdate(ChatRole.Assistant, "response");
+ var updateTypeInfo = AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AgentRunResponseUpdate));
+ var jsonElement = JsonSerializer.SerializeToElement(expectedUpdate, updateTypeInfo);
+
+ var actorUpdate = new ActorRequestUpdate(RequestStatus.Failed, jsonElement);
+ var mockHandle = new Mock();
+ mockHandle
+ .Setup(h => h.WatchUpdatesAsync(It.IsAny()))
+ .Returns(GetAsyncEnumerableAsync(actorUpdate));
+ var mockClient = new Mock();
+ mockClient
+ .Setup(c => c.SendRequestAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(mockHandle.Object);
+
+ var proxy = new AgentProxy("agentName", mockClient.Object);
+ var thread = proxy.GetThread(threadId);
+
+ // Act & Assert
+ var exception = await Assert.ThrowsAsync(async () =>
+ {
+ await foreach (var update in proxy.RunStreamingAsync(messages, thread))
+ {
+ // force enumeration
+ }
+ });
+ Assert.Contains("The agent run request failed", exception.Message);
+ }
+
+ ///
+ /// Verifies that constructor throws ArgumentNullException when client is null.
+ ///
+ [Fact]
+ public void Constructor_NullClient_ThrowsArgumentNullException()
+ {
+ // Act & Assert
+ Assert.Throws(() => new AgentProxy("agentName", null!));
+ }
+
+ ///
+ /// Verifies that constructor throws ArgumentNullException when name is null.
+ ///
+ [Fact]
+ public void Constructor_NullName_ThrowsArgumentNullException()
+ {
+ // Arrange
+ var mockClient = new Mock();
+
+ // Act & Assert
+ Assert.Throws(() => new AgentProxy(null!, mockClient.Object));
+ }
+
+ ///
+ /// Verifies that constructor throws ArgumentException when name is empty.
+ ///
+ [Fact]
+ public void Constructor_EmptyName_ThrowsArgumentException()
+ {
+ // Arrange
+ var mockClient = new Mock();
+
+ // Act & Assert
+ Assert.Throws(() => new AgentProxy("", mockClient.Object));
+ }
+
+ ///
+ /// Verifies that RunAsync with thread overload validates null messages.
+ ///
+ [Fact]
+ public async Task RunAsync_WithThread_NullMessages_ThrowsArgumentNullExceptionAsync()
+ {
+ // Arrange
+ var mockClient = new Mock();
+ var proxy = new AgentProxy("agentName", mockClient.Object);
+ var thread = new AgentProxyThread();
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() =>
+ proxy.RunAsync(messages: null!, thread, null, CancellationToken.None));
+ }
+
+ ///
+ /// Verifies that RunAsync with thread overload throws for invalid thread type.
+ ///
+ [Fact]
+ public async Task RunAsync_WithInvalidThreadType_ThrowsArgumentExceptionAsync()
+ {
+ // Arrange
+ var mockClient = new Mock();
+ var proxy = new AgentProxy("agentName", mockClient.Object);
+ var invalidThread = new Mock().Object;
+ var messages = new List { new(ChatRole.User, "test") };
+
+ // Act & Assert
+ var exception = await Assert.ThrowsAsync(() =>
+ proxy.RunAsync(messages, invalidThread, null, CancellationToken.None));
+ Assert.Contains("thread must be an instance of AgentProxyThread", exception.Message);
+ }
+
+ ///
+ /// Verifies that RunAsync with thread overload creates new thread ID when thread is null.
+ ///
+ [Fact]
+ public async Task RunAsync_WithNullThread_CreatesNewThreadIdAsync()
+ {
+ // Arrange
+ var mockClient = new Mock();
+ var mockHandle = new Mock();
+ var response = new AgentRunResponse { Messages = [] };
+ var jsonElement = JsonSerializer.SerializeToElement(response,
+ AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AgentRunResponse)));
+ var actorResponse = new ActorResponse
+ {
+ ActorId = new ActorId(AgentName, ThreadId),
+ MessageId = "msg1",
+ Data = jsonElement,
+ Status = RequestStatus.Completed
+ };
+
+ mockHandle.Setup(h => h.GetResponseAsync(It.IsAny()))
+ .Returns(new ValueTask(actorResponse));
+
+ mockClient.Setup(c => c.SendRequestAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(mockHandle.Object);
+
+ var proxy = new AgentProxy("agentName", mockClient.Object);
+ var messages = new List { new(ChatRole.User, "test") };
+
+ // Act
+ var result = await proxy.RunAsync(messages, thread: null, options: null, CancellationToken.None);
+
+ // Assert
+ Assert.NotNull(result);
+ mockClient.Verify(c => c.SendRequestAsync(
+ It.Is(r => !string.IsNullOrEmpty(r.ActorId.Key)),
+ It.IsAny()), Times.Once);
+ }
+
+ ///
+ /// Verifies that RunAsync handles cancellation properly.
+ ///
+ [Fact]
+ public async Task RunAsync_CancellationRequested_ThrowsOperationCanceledExceptionAsync()
+ {
+ // Arrange
+ var mockClient = new Mock();
+ using var cts = new CancellationTokenSource();
+ cts.Cancel();
+
+ mockClient.Setup(c => c.SendRequestAsync(It.IsAny(), It.IsAny()))
+ .ThrowsAsync(new OperationCanceledException());
+
+ var proxy = new AgentProxy("agentName", mockClient.Object);
+ var messages = new List { new(ChatRole.User, "test") };
+ var thread = proxy.GetThread(ThreadId);
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() =>
+ proxy.RunAsync(messages, thread, cancellationToken: cts.Token));
+ }
+
+ ///
+ /// Verifies that RunStreamingAsync with thread overload validates null messages.
+ ///
+ [Fact]
+ public async Task RunStreamingAsync_WithThread_NullMessages_ThrowsArgumentNullExceptionAsync()
+ {
+ // Arrange
+ var mockClient = new Mock();
+ var proxy = new AgentProxy("agentName", mockClient.Object);
+ var thread = new AgentProxyThread();
+
+ // Act & Assert
+ await Assert.ThrowsAsync(async () =>
+ {
+ await foreach (var _ in proxy.RunStreamingAsync(messages: null!, thread, null, CancellationToken.None))
+ {
+ // force enumeration
+ }
+ });
+ }
+
+ ///
+ /// Verifies that RunStreamingAsync with thread overload throws for invalid thread type.
+ ///
+ [Fact]
+ public async Task RunStreamingAsync_WithInvalidThreadType_ThrowsArgumentExceptionAsync()
+ {
+ // Arrange
+ var mockClient = new Mock();
+ var proxy = new AgentProxy("agentName", mockClient.Object);
+ var invalidThread = new Mock().Object;
+ var messages = new List { new(ChatRole.User, "test") };
+
+ // Act & Assert
+ var exception = await Assert.ThrowsAsync(async () =>
+ {
+ await foreach (var _ in proxy.RunStreamingAsync(messages, invalidThread, null, CancellationToken.None))
+ {
+ // force enumeration
+ }
+ });
+ Assert.Contains("thread must be an instance of AgentProxyThread", exception.Message);
+ }
+
+ ///
+ /// Verifies that RunStreamingAsync handles cancellation during enumeration.
+ ///
+ [Fact]
+ public async Task RunStreamingAsync_CancellationDuringEnumeration_StopsEnumerationAsync()
+ {
+ // Arrange
+ var mockClient = new Mock();
+ using var cts = new CancellationTokenSource();
+
+ var updates = new List
+ {
+ new(RequestStatus.Pending, JsonSerializer.SerializeToElement(
+ new AgentRunResponseUpdate(ChatRole.Assistant, "1"),
+ AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AgentRunResponseUpdate)))),
+ new(RequestStatus.Pending, JsonSerializer.SerializeToElement(
+ new AgentRunResponseUpdate(ChatRole.Assistant, "2"),
+ AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AgentRunResponseUpdate))))
+ };
+
+ using var fakeHandle = new FakeActorResponseHandle(updates, cts);
+
+ mockClient.Setup(c => c.SendRequestAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(fakeHandle);
+
+ var proxy = new AgentProxy("agentName", mockClient.Object);
+ var thread = proxy.GetThread(ThreadId);
+ var messages = new List { new(ChatRole.User, "test") };
+
+ // Act
+ var receivedUpdates = new List();
+ await Assert.ThrowsAnyAsync(async () =>
+ {
+ await foreach (var update in proxy.RunStreamingAsync(messages, thread, cancellationToken: cts.Token))
+ {
+ receivedUpdates.Add(update);
+ }
+ });
+
+ // Assert
+ Assert.Single(receivedUpdates); // Only first update should be received
+ }
+
+ ///
+ /// Verifies that RunAsync correctly uses message ID from last message if available.
+ ///
+ [Fact]
+ public async Task RunAsync_UsesLastMessageId_WhenAvailableAsync()
+ {
+ // Arrange
+ var mockClient = new Mock();
+ var mockHandle = new Mock();
+ var expectedMessageId = "custom-message-id";
+ var response = new AgentRunResponse { Messages = [] };
+ var jsonElement = JsonSerializer.SerializeToElement(response,
+ AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AgentRunResponse)));
+ var actorResponse = new ActorResponse
+ {
+ ActorId = new ActorId(AgentName, ThreadId),
+ MessageId = expectedMessageId,
+ Data = jsonElement,
+ Status = RequestStatus.Completed
+ };
+
+ mockHandle.Setup(h => h.GetResponseAsync(It.IsAny()))
+ .Returns(new ValueTask(actorResponse));
+
+ mockClient.Setup(c => c.SendRequestAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(mockHandle.Object);
+
+ var proxy = new AgentProxy("agentName", mockClient.Object);
+ var thread = proxy.GetThread(ThreadId);
+ var messages = new List
+ {
+ new(ChatRole.User, "first"),
+ new(ChatRole.User, "last") { MessageId = expectedMessageId }
+ };
+
+ // Act
+ await proxy.RunAsync(messages, thread);
+
+ // Assert
+ mockClient.Verify(c => c.SendRequestAsync(
+ It.Is(r => r.MessageId == expectedMessageId),
+ It.IsAny()), Times.Once);
+ }
+
+ ///
+ /// Verifies that RunAsync generates new message ID when last message has no ID.
+ ///
+ [Fact]
+ public async Task RunAsync_GeneratesMessageId_WhenLastMessageHasNoIdAsync()
+ {
+ // Arrange
+ var mockClient = new Mock();
+ var mockHandle = new Mock();
+ var response = new AgentRunResponse { Messages = [] };
+ var jsonElement = JsonSerializer.SerializeToElement(response,
+ AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AgentRunResponse)));
+ var actorResponse = new ActorResponse
+ {
+ ActorId = new ActorId(AgentName, ThreadId),
+ MessageId = "generated-id",
+ Data = jsonElement,
+ Status = RequestStatus.Completed
+ };
+
+ mockHandle.Setup(h => h.GetResponseAsync(It.IsAny()))
+ .Returns(new ValueTask(actorResponse));
+
+ mockClient.Setup(c => c.SendRequestAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(mockHandle.Object);
+
+ var proxy = new AgentProxy("agentName", mockClient.Object);
+ var thread = proxy.GetThread(ThreadId);
+ var messages = new List { new(ChatRole.User, "test") };
+
+ // Act
+ await proxy.RunAsync(messages, thread);
+
+ // Assert
+ mockClient.Verify(c => c.SendRequestAsync(
+ It.Is(r => !string.IsNullOrEmpty(r.MessageId) && IsValidGuid(r.MessageId)),
+ It.IsAny()), Times.Once);
+ }
+
+ ///
+ /// Verifies that GetNewThread returns unique instances with unique IDs.
+ ///
+ [Fact]
+ public void GetNewThread_MultipleCalls_ReturnsUniqueThreadsWithUniqueIds()
+ {
+ // Arrange
+ var mockClient = new Mock();
+ var proxy = new AgentProxy("agentName", mockClient.Object);
+ var threads = new List();
+
+ // Act
+ for (int i = 0; i < 10; i++)
+ {
+ threads.Add(proxy.GetNewThread());
+ }
+
+ // Assert
+ var threadIds = threads.Cast().Select(t => t.ConversationId).ToList();
+ Assert.Equal(10, threadIds.Count);
+ Assert.Equal(10, threadIds.Distinct().Count()); // All IDs should be unique
+ }
+
+ ///
+ /// Fake implementation of ActorResponseHandle for testing purposes.
+ ///
+ private sealed class FakeActorResponseHandle : ActorResponseHandle
+ {
+ private readonly List _updates;
+ private readonly CancellationTokenSource _cancellationTokenSource;
+ private readonly ActorResponse? _response;
+ private readonly int _delayBetweenUpdates;
+
+ public FakeActorResponseHandle(
+ List updates,
+ CancellationTokenSource cancellationTokenSource,
+ ActorResponse? response = null,
+ int delayBetweenUpdates = 10)
+ {
+ this._updates = updates;
+ this._cancellationTokenSource = cancellationTokenSource;
+ this._response = response;
+ this._delayBetweenUpdates = delayBetweenUpdates;
+ }
+
+ public override bool TryGetResponse([System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out ActorResponse? response)
+ {
+ response = this._response;
+ return this._response != null;
+ }
+
+ public override ValueTask GetResponseAsync(CancellationToken cancellationToken)
+ {
+ if (this._response == null)
+ {
+ throw new InvalidOperationException("No response configured");
+ }
+ return new ValueTask(this._response);
+ }
+
+ public override ValueTask CancelAsync(CancellationToken cancellationToken)
+ {
+ this._cancellationTokenSource.Cancel();
+ return default;
+ }
+
+ public override async IAsyncEnumerable WatchUpdatesAsync([System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ for (int i = 0; i < this._updates.Count; i++)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ yield return this._updates[i];
+
+ // Cancel after the first update
+ if (i == 0)
+ {
+ this._cancellationTokenSource.Cancel();
+ }
+
+ if (i < this._updates.Count - 1) // Don't delay after the last update
+ {
+ await Task.Delay(this._delayBetweenUpdates, cancellationToken);
+ }
+ }
+ }
+ }
+}
diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.Hosting.UnitTests/AgentProxyThreadTests.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.Hosting.UnitTests/AgentProxyThreadTests.cs
new file mode 100644
index 0000000000..a765f45f83
--- /dev/null
+++ b/dotnet/tests/Microsoft.Extensions.AI.Agents.Hosting.UnitTests/AgentProxyThreadTests.cs
@@ -0,0 +1,305 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+
+namespace Microsoft.Extensions.AI.Agents.Hosting.UnitTests;
+
+public class AgentProxyThreadTests
+{
+ ///
+ /// Provides valid identifier values that conform to RFC 3986 unreserved characters.
+ ///
+ public static IEnumerable