.NET: Add agent hosting package and update sample (#296)

* Add agent hosting package and update sample

* Review feedback and cleanup

* Include the narrator

* wip

* wip

* Remove workaround for empty state writes.

* Handle changes to AgentThread.

* One more.

* Fix.

---------

Co-authored-by: Aditya Mandaleeka <adityam@microsoft.com>
This commit is contained in:
Reuben Bond
2025-08-06 21:26:36 +00:00
committed by GitHub
co-authored by Aditya Mandaleeka
parent 8dcc8533a6
commit e7441ee29e
86 changed files with 4388 additions and 1229 deletions
@@ -0,0 +1,152 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI.Agents.Runtime;
using Microsoft.Extensions.Logging;
namespace Microsoft.Extensions.AI.Agents.Hosting;
internal sealed class AgentActor(
AIAgent agent,
IActorRuntimeContext context,
ILogger<AgentActor> logger) : IActor
{
private const string ThreadStateKey = "thread";
private string? _etag;
private AgentThread? _thread;
public ValueTask DisposeAsync() => default;
public async ValueTask RunAsync(CancellationToken cancellationToken)
{
Log.ActorStarted(logger, context.ActorId.ToString(), agent.Name ?? "Unknown");
await Task.Yield();
// Restore thread state
var response = await context.ReadAsync(
new ActorReadOperationBatch([new GetValueOperation(ThreadStateKey)]),
cancellationToken).ConfigureAwait(false);
this._etag = response.ETag;
if (response.Results[0] is GetValueResult threadResult)
{
if (threadResult.Value is { } threadJson)
{
// Deserialize the thread state if it exists
await agent.DeserializeThreadAsync(threadJson, cancellationToken: cancellationToken).ConfigureAwait(false);
}
}
this._thread ??= agent.GetNewThread();
Log.ThreadStateRestored(logger, context.ActorId.ToString(), response.Results[0] is GetValueResult { Value: not null });
while (!cancellationToken.IsCancellationRequested)
{
try
{
await foreach (var message in context.WatchMessagesAsync(cancellationToken).ConfigureAwait(false))
{
switch (message.Type)
{
case ActorMessageType.Request:
await this.HandleAgentRequestAsync((ActorRequestMessage)message, cancellationToken).ConfigureAwait(false);
break;
case ActorMessageType.Response:
// Handle response messages if needed
break;
default:
Log.UnknownMessageType(logger, message.Type.ToString(), context.ActorId.ToString());
break;
}
}
}
catch (Exception ex)
{
if (cancellationToken.IsCancellationRequested && ex is OperationCanceledException)
{
return;
}
Log.ErrorProcessingMessages(logger, ex, context.ActorId.ToString());
}
}
}
private async Task HandleAgentRequestAsync(ActorRequestMessage message, CancellationToken cancellationToken)
{
var requestId = message.MessageId;
Debug.Assert(this._thread is not null);
Debug.Assert(this._etag is not null);
if (message.Method is not AgentActorConstants.RunMethodName)
{
// Unsupported method, we can only handle "Run" requests.
var data = JsonSerializer.SerializeToElement("Unsupported method.", AgentHostingJsonUtilities.DefaultOptions.GetTypeInfo(typeof(string)));
var writeResponse = await context.WriteAsync(
new(this._etag, [
new UpdateRequestOperation(
requestId,
RequestStatus.Failed,
data)]),
cancellationToken).ConfigureAwait(false);
return;
}
// Parse the request to get the agent run parameters
List<ChatMessage>? messages;
if (message.Params is { } payload)
{
var arg = payload.Deserialize(AgentHostingJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AgentRunRequest))) as AgentRunRequest;
messages = arg?.Messages;
}
messages ??= [];
Log.ProcessingAgentRequest(logger, requestId, context.ActorId.ToString(), messages.Count);
try
{
var i = 0;
var updates = new List<AgentRunResponseUpdate>();
await foreach (var update in agent.RunStreamingAsync(messages, this._thread, cancellationToken: cancellationToken).ConfigureAwait(false))
{
var updateJson = JsonSerializer.SerializeToElement(update, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AgentRunResponseUpdate)));
context.OnProgressUpdate(requestId, i++, updateJson);
updates.Add(update);
Log.AgentStreamingUpdate(logger, requestId, i);
}
var serializedRunResponse = JsonSerializer.SerializeToElement(
updates.ToAgentRunResponse(),
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),
new SetValueOperation(ThreadStateKey, updatedThread)
]), cancellationToken)
.ConfigureAwait(false);
if (!writeResponse.Success)
{
Log.WriteOperationFailed(logger, context.ActorId.ToString(), requestId);
}
else
{
Log.AgentRequestCompleted(logger, requestId, updates.Count);
}
this._etag = writeResponse.ETag;
}
catch (Exception exception)
{
Log.AgentRequestFailed(logger, exception, requestId, context.ActorId.ToString());
// TODO: Retry later?
}
}
}
@@ -0,0 +1,8 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Extensions.AI.Agents.Hosting;
internal static class AgentActorConstants
{
public const string RunMethodName = "Run";
}
@@ -0,0 +1,38 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading;
namespace Microsoft.Extensions.AI.Agents.Hosting;
/// <summary>
/// Provides a catalog of registered AI agents within the hosting environment.
/// </summary>
/// <remarks>
/// The agent catalog allows enumeration of all registered agents in the dependency injection container.
/// This is useful for scenarios where you need to discover and interact with multiple agents programmatically.
/// </remarks>
public abstract class AgentCatalog
{
/// <summary>
/// Initializes a new instance of the <see cref="AgentCatalog"/> class.
/// </summary>
protected AgentCatalog()
{
}
/// <summary>
/// Asynchronously retrieves all registered AI agents from the catalog.
/// </summary>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the enumeration operation.</param>
/// <returns>
/// An asynchronous enumerable of <see cref="AIAgent"/> instances representing all registered agents.
/// The enumeration will only include agents that are successfully resolved from the service provider.
/// </returns>
/// <remarks>
/// This method enumerates through all registered agent names and attempts to resolve each agent
/// from the dependency injection container. Only successfully resolved agents are yielded.
/// The enumeration is lazy and agents are resolved on-demand during iteration.
/// </remarks>
public abstract IAsyncEnumerable<AIAgent> GetAgentsAsync(CancellationToken cancellationToken = default);
}
@@ -0,0 +1,60 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.AI.Agents.Runtime;
namespace Microsoft.Extensions.AI.Agents.Hosting;
/// <summary>Provides a collection of utility methods for working with JSON data in the context of agent hosting.</summary>
public static partial class AgentHostingJsonUtilities
{
/// <summary>
/// Gets the <see cref="JsonSerializerOptions"/> singleton used as the default in JSON serialization operations.
/// </summary>
/// <remarks>
/// <para>
/// For Native AOT or applications disabling <see cref="JsonSerializer.IsReflectionEnabledByDefault"/>, this instance
/// includes source generated contracts for all common exchange types contained in this library.
/// </para>
/// <para>
/// It additionally turns on the following settings:
/// <list type="number">
/// <item>Enables <see cref="JsonSerializerDefaults.Web"/> defaults.</item>
/// <item>Enables <see cref="JsonIgnoreCondition.WhenWritingNull"/> as the default ignore condition for properties.</item>
/// <item>Enables <see cref="JsonNumberHandling.AllowReadingFromString"/> as the default number handling for number types.</item>
/// </list>
/// </para>
/// </remarks>
public static JsonSerializerOptions DefaultOptions { get; } = CreateDefaultOptions();
/// <summary>
/// Creates default options to use for agent hosting-related serialization.
/// </summary>
/// <returns>The configured options.</returns>
[UnconditionalSuppressMessage("ReflectionAnalysis", "IL3050:RequiresDynamicCode", Justification = "Converter is guarded by IsReflectionEnabledByDefault check.")]
[UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access", Justification = "Converter is guarded by IsReflectionEnabledByDefault check.")]
private static JsonSerializerOptions CreateDefaultOptions()
{
// Copy the configuration from the source generated context.
JsonSerializerOptions options = new(JsonContext.Default.Options);
// Chain with all supported types from Microsoft.Extensions.AI.Agents.Abstractions.
options.TypeInfoResolverChain.Add(AgentAbstractionsJsonUtilities.DefaultOptions.TypeInfoResolver!);
options.TypeInfoResolverChain.Add(AgentRuntimeAbstractionsJsonUtilities.DefaultOptions.TypeInfoResolver!);
options.MakeReadOnly();
return options;
}
// Keep in sync with CreateDefaultOptions above.
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
NumberHandling = JsonNumberHandling.AllowReadingFromString)]
[JsonSerializable(typeof(AgentRunRequest))]
[JsonSerializable(typeof(AgentProxyThread))]
[JsonSerializable(typeof(AgentThread))]
[ExcludeFromCodeCoverage]
internal sealed partial class JsonContext : JsonSerializerContext;
}
@@ -0,0 +1,158 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI.Agents.Runtime;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Extensions.AI.Agents.Hosting;
/// <summary>
/// Represents a proxy for an AI agent that communicates with the agent runtime via an actor client.
/// </summary>
public sealed class AgentProxy : AIAgent
{
private readonly IActorClient _client;
/// <summary>
/// Initializes a new instance of the <see cref="AgentProxy"/> class with the specified agent name and actor client.
/// </summary>
/// <param name="name">The name of the agent.</param>
/// <param name="client">The actor client used to communicate with the agent.</param>
public AgentProxy(string name, IActorClient client)
{
Throw.IfNull(client);
Throw.IfNullOrEmpty(name);
this._client = client;
this.Name = name;
}
/// <inheritdoc/>
public override string Name { get; }
/// <inheritdoc/>
public override AgentThread GetNewThread() => new AgentProxyThread();
/// <summary>
/// Gets a thread by its <see cref="AgentThread.ConversationId"/>.
/// </summary>
/// <param name="conversationId">The thread identifier.</param>
/// <returns>The thread.</returns>
public AgentThread GetThread(string conversationId) => new AgentProxyThread(conversationId);
/// <inheritdoc/>
public override async Task<AgentRunResponse> RunAsync(
IReadOnlyCollection<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
{
Throw.IfNull(messages);
var agentThread = GetAgentThreadId(thread);
return await this.RunAsync(messages, agentThread, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc/>
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
IReadOnlyCollection<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
Throw.IfNull(messages);
var agentThread = GetAgentThreadId(thread);
await foreach (var item in this.RunStreamingAsync(messages, agentThread, cancellationToken).ConfigureAwait(false))
{
yield return item;
}
}
private async Task<AgentRunResponse> RunAsync(IReadOnlyCollection<ChatMessage> messages, string threadId, CancellationToken cancellationToken)
{
Throw.IfNull(messages);
var handle = await this.RunCoreAsync(messages, threadId, cancellationToken).ConfigureAwait(false);
var response = await handle.GetResponseAsync(cancellationToken).ConfigureAwait(false);
return response.Status switch
{
RequestStatus.Completed => (AgentRunResponse)response.Data.Deserialize(
AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AgentRunResponse)))!,
RequestStatus.Failed => throw new InvalidOperationException($"The agent run request failed: {response.Data}"),
RequestStatus.Pending => throw new InvalidOperationException("The agent run request is still pending."),
_ => throw new NotSupportedException($"The agent run request returned an unsupported status: {response.Status}.")
};
}
private async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
IReadOnlyCollection<ChatMessage> messages,
string threadId,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
Throw.IfNull(messages);
var response = await this.RunCoreAsync(messages, threadId, cancellationToken).ConfigureAwait(false);
var updateTypeInfo = AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AgentRunResponseUpdate));
await foreach (var update in response.WatchUpdatesAsync(cancellationToken).ConfigureAwait(false))
{
if (update.Status is RequestStatus.Failed)
{
throw new InvalidOperationException($"The agent run request failed: {update.Data}");
}
if (update.Status is RequestStatus.Completed)
{
var responseTypeInfo = AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AgentRunResponse));
var runResponse = (AgentRunResponse)update.Data.Deserialize(responseTypeInfo)!;
foreach (var item in runResponse.ToAgentRunResponseUpdates())
{
yield return item;
}
yield break;
}
var runResponseUpdate = (AgentRunResponseUpdate)update.Data.Deserialize(updateTypeInfo)!;
yield return runResponseUpdate;
}
}
private static string GetAgentThreadId(AgentThread? thread)
{
if (thread is null)
{
return AgentProxyThread.CreateId();
}
if (thread is not AgentProxyThread agentProxyThread)
{
throw new ArgumentException("The thread must be an instance of AgentProxyThread.", nameof(thread));
}
return agentProxyThread.ConversationId!;
}
private async Task<ActorResponseHandle> RunCoreAsync(IReadOnlyCollection<ChatMessage> messages, string threadId, CancellationToken cancellationToken)
{
Debug.Assert(messages is not null);
Debug.Assert(threadId is not null);
var newMessages = new List<ChatMessage>(messages);
var runRequest = new AgentRunRequest
{
Messages = newMessages
};
string messageId = newMessages.LastOrDefault()?.MessageId ?? Guid.NewGuid().ToString();
var actorRequest = new ActorRequest(
actorId: new ActorId(this.Name, threadId),
messageId,
method: AgentActorConstants.RunMethodName,
@params: JsonSerializer.SerializeToElement(runRequest, AgentHostingJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AgentRunRequest))));
var handle = await this._client.SendRequestAsync(actorRequest, cancellationToken).ConfigureAwait(false);
return handle;
}
}
@@ -0,0 +1,72 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Text.RegularExpressions;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Extensions.AI.Agents.Hosting;
/// <summary>
/// Represents an agent thread for a <see cref="AgentProxy"/>.
/// </summary>
internal sealed partial class AgentProxyThread : AgentThread
{
#if NET7_0_OR_GREATER
[System.Diagnostics.CodeAnalysis.StringSyntax("Regex")]
#endif
private const string ThreadValidationRegex = "^[a-zA-Z0-9_.\\-~]+$";
#if NET7_0_OR_GREATER
/// <summary>
/// Regular expression pattern for valid thread IDs.
/// Thread IDs must be alphanumeric and can contain hyphens, underscores, dots, and tildes (RFC 3986 unreserved characters).
/// </summary>
[GeneratedRegex(ThreadValidationRegex, RegexOptions.Compiled)]
private static partial Regex ValidIdPattern();
#else
/// <summary>
/// Regular expression pattern for valid thread IDs.
/// Thread IDs must be alphanumeric and can contain hyphens, underscores, dots, and tildes (RFC 3986 unreserved characters).
/// </summary>
private static readonly Regex s_validIdPattern = new(ThreadValidationRegex, RegexOptions.Compiled);
/// <summary>
/// Regular expression pattern for valid thread IDs.
/// Thread IDs must be alphanumeric and can contain hyphens, underscores, dots, and tildes (RFC 3986 unreserved characters).
/// </summary>
private static Regex ValidIdPattern() => s_validIdPattern;
#endif
/// <summary>
/// Initializes a new instance of the <see cref="AgentProxyThread"/> class with the specified identifier.
/// </summary>
/// <param name="id">The unique identifier for the agent proxy thread.</param>
public AgentProxyThread(string id)
{
Throw.IfNullOrEmpty(id);
ValidateId(id);
this.ConversationId = id;
}
/// <summary>
/// Initializes a new instance of the <see cref="AgentProxyThread"/> class with the specified identifier.
/// </summary>
public AgentProxyThread() : this(CreateId())
{
}
internal static string CreateId() => Guid.NewGuid().ToString("N");
/// <summary>
/// Validates that the provided ID matches the required pattern for thread IDs.
/// </summary>
/// <param name="id">The ID to validate.</param>
/// <exception cref="ArgumentException">Thrown when the ID is not valid.</exception>
private static void ValidateId(string id)
{
if (!ValidIdPattern().IsMatch(id))
{
throw new ArgumentException($"Thread ID '{id}' is not valid. Thread IDs must contain only alphanumeric characters, hyphens, underscores, dots, and tildes.", nameof(id));
}
}
}
@@ -0,0 +1,18 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Microsoft.Extensions.AI.Agents.Hosting;
/// <summary>
/// Represents a request to run an agent with a collection of chat messages.
/// </summary>
public sealed class AgentRunRequest
{
/// <summary>
/// Gets or sets the collection of chat messages to be processed by the agent.
/// </summary>
[JsonPropertyName("messages")]
public List<ChatMessage>? Messages { get; set; }
}
@@ -0,0 +1,158 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Linq;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.AI.Agents.Runtime;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Extensions.AI.Agents.Hosting;
/// <summary>
/// Provides extension methods for configuring AI agents in a host application builder.
/// </summary>
public static class HostApplicationBuilderAgentExtensions
{
/// <summary>
/// Adds an AI agent to the host application builder with the specified name and instructions.
/// </summary>
/// <param name="builder">The host application builder to configure.</param>
/// <param name="name">The name of the agent.</param>
/// <param name="instructions">The instructions for the agent.</param>
/// <returns>The configured host application builder.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/>, <paramref name="name"/>, or <paramref name="instructions"/> is null.</exception>
public static IHostApplicationBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string instructions)
{
Throw.IfNull(builder);
Throw.IfNull(name);
return builder.AddAIAgent(name, instructions, chatClientServiceKey: null);
}
/// <summary>
/// Adds an AI agent to the host application builder with the specified name, instructions, and chat client key.
/// </summary>
/// <param name="builder">The host application builder to configure.</param>
/// <param name="name">The name of the agent.</param>
/// <param name="instructions">The instructions for the agent.</param>
/// <param name="chatClient">The chat client which the agent will use for inference.</param>
/// <returns>The configured host application builder.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/>, <paramref name="name"/>, or <paramref name="instructions"/> is null.</exception>
public static IHostApplicationBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string instructions, IChatClient chatClient)
{
Throw.IfNull(builder);
Throw.IfNull(name);
return builder.AddAIAgent(name, (sp, key) => new ChatClientAgent(chatClient, instructions, key));
}
/// <summary>
/// Adds an AI agent to the host application builder with the specified name, instructions, and chat client key.
/// </summary>
/// <param name="builder">The host application builder to configure.</param>
/// <param name="name">The name of the agent.</param>
/// <param name="instructions">The instructions for the agent.</param>
/// <param name="description">A description of the agent.</param>
/// <param name="chatClientServiceKey">The key to use when resolving the chat client from the service provider. If null, a non-keyed service will be resolved.</param>
/// <returns>The configured host application builder.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/>, <paramref name="name"/>, or <paramref name="instructions"/> is null.</exception>
public static IHostApplicationBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string instructions, string description, object? chatClientServiceKey)
{
Throw.IfNull(builder);
Throw.IfNull(name);
return builder.AddAIAgent(name, (sp, key) =>
{
var chatClient = chatClientServiceKey is null ? sp.GetRequiredService<IChatClient>() : sp.GetRequiredKeyedService<IChatClient>(chatClientServiceKey);
return new ChatClientAgent(chatClient, instructions: instructions, name: key, description: description);
});
}
/// <summary>
/// Adds an AI agent to the host application builder with the specified name, instructions, and chat client key.
/// </summary>
/// <param name="builder">The host application builder to configure.</param>
/// <param name="name">The name of the agent.</param>
/// <param name="instructions">The instructions for the agent.</param>
/// <param name="chatClientServiceKey">The key to use when resolving the chat client from the service provider. If null, a non-keyed service will be resolved.</param>
/// <returns>The configured host application builder.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/>, <paramref name="name"/>, or <paramref name="instructions"/> is null.</exception>
public static IHostApplicationBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string instructions, object? chatClientServiceKey)
{
Throw.IfNull(builder);
Throw.IfNull(name);
return builder.AddAIAgent(name, (sp, key) =>
{
var chatClient = chatClientServiceKey is null ? sp.GetRequiredService<IChatClient>() : sp.GetRequiredKeyedService<IChatClient>(chatClientServiceKey);
return new ChatClientAgent(chatClient, instructions, key);
});
}
/// <summary>
/// Adds an AI agent to the host application builder using a custom factory delegate.
/// </summary>
/// <param name="builder">The host application builder to configure.</param>
/// <param name="name">The name of the agent.</param>
/// <param name="createAgentDelegate">A factory delegate that creates the AI agent instance. The delegate receives the service provider and agent key as parameters.</param>
/// <returns>The configured host application builder.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/>, <paramref name="name"/>, or <paramref name="createAgentDelegate"/> is null.</exception>
/// <exception cref="InvalidOperationException">Thrown when the agent factory delegate returns null or an invalid AI agent instance.</exception>
public static IHostApplicationBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, Func<IServiceProvider, string, AIAgent> createAgentDelegate)
{
Throw.IfNull(builder);
Throw.IfNull(name);
Throw.IfNull(createAgentDelegate);
builder.Services.AddKeyedSingleton<AIAgent>(name, (sp, key) =>
{
Throw.IfNull(key);
var keyString = key as string;
Throw.IfNullOrEmpty(keyString);
var agent = createAgentDelegate(sp, keyString) ?? throw new InvalidOperationException($"The agent factory did not return a valid {nameof(AIAgent)} instance for key '{keyString}'.");
if (agent.Name != keyString)
{
throw new InvalidOperationException($"The agent factory returned an agent with name '{agent.Name}', but the expected name is '{keyString}'.");
}
return agent;
});
return builder.AddAgentActor(name);
}
private static IHostApplicationBuilder AddAgentActor(this IHostApplicationBuilder builder, string name)
{
Throw.IfNull(builder);
// Register the agent by name for discovery.
var agentHostBuilder = GetAgentRegistry(builder);
agentHostBuilder.AgentNames.Add(name);
// Add the actor runtime and register the agent actor type.
var actorBuilder = builder.AddActorRuntime();
actorBuilder.AddActorType(
new ActorType(name),
(sp, ctx) => new AgentActor(
sp.GetRequiredKeyedService<AIAgent>(name),
ctx,
sp.GetRequiredService<ILogger<AgentActor>>()));
return builder;
}
private static LocalAgentRegistry GetAgentRegistry(IHostApplicationBuilder builder)
{
var descriptor = builder.Services.FirstOrDefault(s => !s.IsKeyedService && s.ServiceType.Equals(typeof(LocalAgentRegistry)));
if (descriptor?.ImplementationInstance is not LocalAgentRegistry instance)
{
instance = new LocalAgentRegistry();
ConfigureHostBuilder(builder, instance);
}
return instance;
}
private static void ConfigureHostBuilder(IHostApplicationBuilder builder, LocalAgentRegistry agentHostBuilderContext)
{
builder.Services.Add(ServiceDescriptor.Singleton(agentHostBuilderContext));
builder.Services.AddSingleton<AgentCatalog, LocalAgentCatalog>();
}
}
@@ -0,0 +1,37 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
namespace Microsoft.Extensions.AI.Agents.Hosting;
// Implementation of an AgentCatalog which enumerates agents registered in the local service provider.
internal sealed class LocalAgentCatalog : AgentCatalog
{
public readonly HashSet<string> _registeredAgents;
private readonly IServiceProvider _serviceProvider;
public LocalAgentCatalog(LocalAgentRegistry agentHostBuilder, IServiceProvider serviceProvider)
{
this._registeredAgents = [.. agentHostBuilder.AgentNames];
this._serviceProvider = serviceProvider;
}
public override async IAsyncEnumerable<AIAgent> GetAgentsAsync([EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await Task.CompletedTask.ConfigureAwait(false);
foreach (var name in this._registeredAgents)
{
var agent = this._serviceProvider.GetKeyedService<AIAgent>(name);
if (agent is not null)
{
yield return agent;
}
}
}
}
@@ -0,0 +1,10 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
namespace Microsoft.Extensions.AI.Agents.Hosting;
internal sealed class LocalAgentRegistry
{
public HashSet<string> AgentNames { get; } = [];
}
@@ -0,0 +1,57 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using Microsoft.Extensions.Logging;
namespace Microsoft.Extensions.AI.Agents.Hosting;
/// <summary>
/// High-performance logging messages using LoggerMessage source generator.
/// </summary>
internal static partial class Log
{
[LoggerMessage(
Level = LogLevel.Information,
Message = "Actor started: ActorId={ActorId}, AgentName={AgentName}")]
public static partial void ActorStarted(ILogger logger, string actorId, string agentName);
[LoggerMessage(
Level = LogLevel.Debug,
Message = "Thread state restored: ActorId={ActorId}, HasExistingThread={HasExistingThread}")]
public static partial void ThreadStateRestored(ILogger logger, string actorId, bool hasExistingThread);
[LoggerMessage(
Level = LogLevel.Information,
Message = "Processing agent request: RequestId={RequestId}, ActorId={ActorId}, MessageCount={MessageCount}")]
public static partial void ProcessingAgentRequest(ILogger logger, string requestId, string actorId, int messageCount);
[LoggerMessage(
Level = LogLevel.Debug,
Message = "Agent streaming update: RequestId={RequestId}, UpdateNumber={UpdateNumber}")]
public static partial void AgentStreamingUpdate(ILogger logger, string requestId, int updateNumber);
[LoggerMessage(
Level = LogLevel.Information,
Message = "Agent request completed: RequestId={RequestId}, TotalUpdates={TotalUpdates}")]
public static partial void AgentRequestCompleted(ILogger logger, string requestId, int totalUpdates);
[LoggerMessage(
Level = LogLevel.Error,
Message = "Agent request failed: RequestId={RequestId}, ActorId={ActorId}")]
public static partial void AgentRequestFailed(ILogger logger, Exception exception, string requestId, string actorId);
[LoggerMessage(
Level = LogLevel.Warning,
Message = "Unknown message type received: MessageType={MessageType}, ActorId={ActorId}")]
public static partial void UnknownMessageType(ILogger logger, string messageType, string actorId);
[LoggerMessage(
Level = LogLevel.Warning,
Message = "Error processing messages: ActorId={ActorId}")]
public static partial void ErrorProcessingMessages(ILogger logger, Exception exception, string actorId);
[LoggerMessage(
Level = LogLevel.Error,
Message = "Write operation failed: ActorId={ActorId}, RequestId={RequestId}")]
public static partial void WriteOperationFailed(ILogger logger, string actorId, string requestId);
}
@@ -0,0 +1,44 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>$(ProjectsTargetFrameworks)</TargetFrameworks>
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsDebugTargetFrameworks)</TargetFrameworks>
<VersionSuffix>alpha</VersionSuffix>
</PropertyGroup>
<PropertyGroup>
<InjectSharedThrow>true</InjectSharedThrow>
<InjectDiagnosticClassesOnLegacy>true</InjectDiagnosticClassesOnLegacy>
<InjectTrimAttributesOnLegacy>true</InjectTrimAttributesOnLegacy>
</PropertyGroup>
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
<ItemGroup>
<ProjectReference Include="..\Microsoft.Extensions.AI.Agents.Abstractions\Microsoft.Extensions.AI.Agents.Abstractions.csproj" />
<ProjectReference Include="..\Microsoft.Extensions.AI.Agents.Runtime.Abstractions\Microsoft.Extensions.AI.Agents.Runtime.Abstractions.csproj" />
<ProjectReference Include="..\Microsoft.Extensions.AI.Agents.Runtime\Microsoft.Extensions.AI.Agents.Runtime.csproj" />
<ProjectReference Include="..\Microsoft.Extensions.AI.Agents\Microsoft.Extensions.AI.Agents.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
<PackageReference Include="System.Text.Json" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFrameworkIdentifier)' != '.NETCoreApp'">
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" />
<PackageReference Include="System.Text.Json" />
<PackageReference Include="Microsoft.Bcl.HashCode" />
<PackageReference Include="System.Threading.Channels" />
<PackageReference Include="System.Threading.Tasks.Extensions" />
<PackageReference Include="System.Diagnostics.DiagnosticSource" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Microsoft.Extensions.AI.Agents.Hosting.UnitTests" />
</ItemGroup>
</Project>
@@ -1,41 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Source-generated JSON type information for use by all agent runtime abstractions.
/// </summary>
[JsonSourceGenerationOptions(
JsonSerializerDefaults.Web,
UseStringEnumConverter = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false)]
[JsonSerializable(typeof(ActorId))]
[JsonSerializable(typeof(ActorMessage))]
[JsonSerializable(typeof(ActorReadOperation))]
[JsonSerializable(typeof(ActorReadOperationBatch))]
[JsonSerializable(typeof(ActorReadResult))]
[JsonSerializable(typeof(ActorRequest))]
[JsonSerializable(typeof(ActorRequestMessage))]
[JsonSerializable(typeof(ActorRequestUpdate))]
[JsonSerializable(typeof(ActorResponse))]
[JsonSerializable(typeof(ActorResponseMessage))]
[JsonSerializable(typeof(ActorType))]
[JsonSerializable(typeof(ActorWriteOperation))]
[JsonSerializable(typeof(ActorWriteOperationBatch))]
[JsonSerializable(typeof(GetValueOperation))]
[JsonSerializable(typeof(GetValueResult))]
[JsonSerializable(typeof(JsonElement))]
[JsonSerializable(typeof(ListKeysOperation))]
[JsonSerializable(typeof(ListKeysResult))]
[JsonSerializable(typeof(ReadResponse))]
[JsonSerializable(typeof(RemoveKeyOperation))]
[JsonSerializable(typeof(RequestStatus))]
[JsonSerializable(typeof(SendRequestOperation))]
[JsonSerializable(typeof(SetValueOperation))]
[JsonSerializable(typeof(UpdateRequestOperation))]
[JsonSerializable(typeof(WriteResponse))]
internal sealed partial class ActorJsonContext : JsonSerializerContext;
@@ -33,4 +33,25 @@ public sealed class ActorResponse
/// </summary>
[JsonPropertyName("status")]
public RequestStatus Status { get; init; }
/// <inheritdoc />
public override string ToString()
{
string dataString;
if (this.Data.ValueKind == JsonValueKind.Undefined)
{
dataString = "undefined";
}
else
{
var rawText = this.Data.GetRawText();
dataString = rawText.Length switch
{
> 250 => $"{rawText.Substring(0, 250)}...",
_ => rawText,
};
}
return $"ActorResponse(ActorId: {this.ActorId}, Status: {this.Status}, MessageId: {this.MessageId ?? "null"}, Data: {dataString})";
}
}
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
@@ -10,7 +11,7 @@ namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Represents a handle to an actor response, allowing retrieval of the response data and status updates.
/// </summary>
public abstract class ActorResponseHandle
public abstract class ActorResponseHandle : IDisposable
{
/// <summary>
/// Attempts to get the response from the request if it is immediately available.
@@ -43,4 +44,17 @@ public abstract class ActorResponseHandle
/// <param name="cancellationToken">A token to cancel the watch operation.</param>
/// <returns>An asynchronous enumerable of request updates.</returns>
public abstract IAsyncEnumerable<ActorRequestUpdate> WatchUpdatesAsync(CancellationToken cancellationToken);
/// <inheritdoc/>
public void Dispose()
{
this.Dispose(true);
GC.SuppressFinalize(this);
}
/// <summary>
/// Disposes of the resources used by the <see cref="ActorResponseHandle"/> class.
/// </summary>
/// <param name="disposing">A boolean indicating whether the method is being called from the <see cref="Dispose()"/> method.</param>
protected virtual void Dispose(bool disposing) { }
}
@@ -0,0 +1,81 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>Provides a collection of utility methods for working with JSON data in the context of actor runtime abstractions.</summary>
public static partial class AgentRuntimeAbstractionsJsonUtilities
{
/// <summary>
/// Gets the <see cref="JsonSerializerOptions"/> singleton used as the default in JSON serialization operations.
/// </summary>
/// <remarks>
/// <para>
/// For Native AOT or applications disabling <see cref="JsonSerializer.IsReflectionEnabledByDefault"/>, this instance
/// includes source generated contracts for all common exchange types contained in this library.
/// </para>
/// <para>
/// It additionally turns on the following settings:
/// <list type="number">
/// <item>Enables <see cref="JsonSerializerDefaults.Web"/> defaults.</item>
/// <item>Enables <see cref="JsonIgnoreCondition.WhenWritingNull"/> as the default ignore condition for properties.</item>
/// <item>Enables <see cref="JsonNumberHandling.AllowReadingFromString"/> as the default number handling for number types.</item>
/// <item>Enables <see cref="JsonStringEnumConverter"/> for enum serialization.</item>
/// </list>
/// </para>
/// </remarks>
public static JsonSerializerOptions DefaultOptions { get; } = CreateDefaultOptions();
/// <summary>
/// Creates default options to use for actor runtime-related serialization.
/// </summary>
/// <returns>The configured options.</returns>
[UnconditionalSuppressMessage("ReflectionAnalysis", "IL3050:RequiresDynamicCode", Justification = "Converter is guarded by IsReflectionEnabledByDefault check.")]
[UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access", Justification = "Converter is guarded by IsReflectionEnabledByDefault check.")]
private static JsonSerializerOptions CreateDefaultOptions()
{
// Copy the configuration from the source generated context.
JsonSerializerOptions options = new(JsonContext.Default.Options);
options.MakeReadOnly();
return options;
}
/// <summary>
/// Source-generated JSON type information for use by all agent runtime abstractions.
/// </summary>
[JsonSourceGenerationOptions(
JsonSerializerDefaults.Web,
UseStringEnumConverter = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false)]
[JsonSerializable(typeof(ActorId))]
[JsonSerializable(typeof(ActorMessage))]
[JsonSerializable(typeof(ActorReadOperation))]
[JsonSerializable(typeof(ActorReadOperationBatch))]
[JsonSerializable(typeof(ActorReadResult))]
[JsonSerializable(typeof(ActorRequest))]
[JsonSerializable(typeof(ActorRequestMessage))]
[JsonSerializable(typeof(ActorRequestUpdate))]
[JsonSerializable(typeof(ActorResponse))]
[JsonSerializable(typeof(ActorResponseMessage))]
[JsonSerializable(typeof(ActorType))]
[JsonSerializable(typeof(ActorWriteOperation))]
[JsonSerializable(typeof(ActorWriteOperationBatch))]
[JsonSerializable(typeof(GetValueOperation))]
[JsonSerializable(typeof(GetValueResult))]
[JsonSerializable(typeof(JsonElement))]
[JsonSerializable(typeof(ListKeysOperation))]
[JsonSerializable(typeof(ListKeysResult))]
[JsonSerializable(typeof(ReadResponse))]
[JsonSerializable(typeof(RemoveKeyOperation))]
[JsonSerializable(typeof(RequestStatus))]
[JsonSerializable(typeof(SendRequestOperation))]
[JsonSerializable(typeof(SetValueOperation))]
[JsonSerializable(typeof(UpdateRequestOperation))]
[JsonSerializable(typeof(WriteResponse))]
internal sealed partial class JsonContext : JsonSerializerContext;
}
@@ -4,6 +4,7 @@ using System;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Extensions.AI.Agents.Runtime;
@@ -13,15 +14,21 @@ namespace Microsoft.Extensions.AI.Agents.Runtime;
[JsonConverter(typeof(Converter))]
public readonly partial struct ActorType : IEquatable<ActorType>
{
#if NET7_0_OR_GREATER
[System.Diagnostics.CodeAnalysis.StringSyntax("Regex")]
#endif
private const string AgentTypeValidationRegex = "^[a-zA-Z_][a-zA-Z._:\\-0-9]*$";
/// <summary>
/// Initializes a new instance of the <see cref="ActorId"/> struct.
/// </summary>
/// <param name="type">The actor type.</param>
public ActorType(string type)
{
if (!IsValid(type))
Throw.IfNullOrEmpty(type);
if (!IsValidType(type))
{
throw new ArgumentException($"Invalid type: '{type}'. Must be alphanumeric (a-z, 0-9, _) and cannot start with a number or contain spaces.");
throw new ArgumentException($"Invalid type: '{type}'. Must start with a letter or underscore, and can only contain letters, dots, underscores, colons, hyphens, and numbers.", nameof(type));
}
this.Name = type;
@@ -59,16 +66,23 @@ public readonly partial struct ActorType : IEquatable<ActorType>
public static bool operator !=(ActorType left, ActorType right) =>
!(left == right);
internal static bool IsValid(string type) =>
type is not null && TypeRegex().IsMatch(type);
#if NET
[GeneratedRegex("^[a-zA-Z_][a-zA-Z_:0-9]*$")]
#if NET7_0_OR_GREATER
[GeneratedRegex(AgentTypeValidationRegex)]
private static partial Regex TypeRegex();
#else
private static Regex TypeRegex() => new("^[a-zA-Z_][a-zA-Z_:0-9:]*$", RegexOptions.Compiled);
private static readonly Regex s_typeRegex = new(AgentTypeValidationRegex, RegexOptions.Compiled);
private static Regex TypeRegex() => s_typeRegex;
#endif
/// <summary>
/// Validates whether the provided type string is a valid actor type.
/// </summary>
public static bool IsValidType(string type)
{
Throw.IfNullOrEmpty(type);
return TypeRegex().IsMatch(type);
}
/// <summary>
/// JSON converter for <see cref="ActorType"/>.
/// </summary>
@@ -0,0 +1,16 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Provides extension methods for the <see cref="RequestStatus"/> enumeration.
/// </summary>
public static class RequestStatusExtensions
{
/// <summary>
/// Determines if the request status indicates that the request has terminated.
/// </summary>
/// <param name="status">The request status to check.</param>
/// <returns><see langword="true"/> if the request has terminated; otherwise, <see langword="false"/>.</returns>
public static bool IsTerminated(this RequestStatus status) => status != RequestStatus.Pending;
}
@@ -16,4 +16,6 @@ namespace Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB;
[JsonSerializable(typeof(ActorStateDocument))]
[JsonSerializable(typeof(ActorRootDocument))]
[JsonSerializable(typeof(KeyProjection))]
internal sealed partial class CosmosActorStateJsonContext : JsonSerializerContext;
[JsonSerializable(typeof(KeyProjection[]))]
[JsonSerializable(typeof(JsonElement))]
public sealed partial class CosmosActorStateJsonContext : JsonSerializerContext;
@@ -1,14 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Microsoft.Extensions.AI.Agents.Runtime;
[JsonSourceGenerationOptions(
JsonSerializerDefaults.Web,
UseStringEnumConverter = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false)]
[JsonSerializable(typeof(string))]
internal sealed partial class ActorRuntimeJsonContext : JsonSerializerContext;
@@ -0,0 +1,57 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>Provides a collection of utility methods for working with JSON data in the context of agent runtime.</summary>
public static partial class AgentRuntimeJsonUtilities
{
/// <summary>
/// Gets the <see cref="JsonSerializerOptions"/> singleton used as the default in JSON serialization operations.
/// </summary>
/// <remarks>
/// <para>
/// For Native AOT or applications disabling <see cref="JsonSerializer.IsReflectionEnabledByDefault"/>, this instance
/// includes source generated contracts for all common exchange types contained in this library.
/// </para>
/// <para>
/// It additionally turns on the following settings:
/// <list type="number">
/// <item>Enables <see cref="JsonSerializerDefaults.Web"/> defaults.</item>
/// <item>Enables <see cref="JsonIgnoreCondition.WhenWritingNull"/> as the default ignore condition for properties.</item>
/// <item>Enables <see cref="JsonNumberHandling.AllowReadingFromString"/> as the default number handling for number types.</item>
/// <item>Enables <see cref="JsonStringEnumConverter"/> for enum serialization.</item>
/// </list>
/// </para>
/// </remarks>
public static JsonSerializerOptions DefaultOptions { get; } = CreateDefaultOptions();
/// <summary>
/// Creates default options to use for agent runtime-related serialization.
/// </summary>
/// <returns>The configured options.</returns>
[UnconditionalSuppressMessage("ReflectionAnalysis", "IL3050:RequiresDynamicCode", Justification = "Converter is guarded by IsReflectionEnabledByDefault check.")]
[UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access", Justification = "Converter is guarded by IsReflectionEnabledByDefault check.")]
private static JsonSerializerOptions CreateDefaultOptions()
{
// Copy the configuration from the source generated context.
JsonSerializerOptions options = new(JsonContext.Default.Options);
// Chain with all supported types from Microsoft.Extensions.AI.Agents.Runtime.Abstractions.
options.TypeInfoResolverChain.Add(AgentRuntimeAbstractionsJsonUtilities.DefaultOptions.TypeInfoResolver!);
options.MakeReadOnly();
return options;
}
[JsonSourceGenerationOptions(
JsonSerializerDefaults.Web,
UseStringEnumConverter = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false)]
[JsonSerializable(typeof(string))]
internal sealed partial class JsonContext : JsonSerializerContext;
}
@@ -100,6 +100,21 @@ internal sealed class InProcessActorContext : IActorRuntimeContext, IAsyncDispos
}
}
public bool TryGetResponseHandle(string messageId, [NotNullWhen(true)] out ActorResponseHandle? handle)
{
lock (this._lock)
{
if (!this._inbox.TryGetValue(messageId, out var entry))
{
handle = null;
return false;
}
handle = new InProcessActorResponseHandle(this, entry);
return true;
}
}
public ActorResponseHandle SendRequest(ActorRequest request)
{
using var activity = ActivitySource.StartActivity(
@@ -133,7 +148,9 @@ internal sealed class InProcessActorContext : IActorRuntimeContext, IAsyncDispos
requestStatus = "found";
}
#pragma warning disable CA2000 // Dispose objects before losing scope
var handle = new InProcessActorResponseHandle(this, entry);
#pragma warning restore CA2000 // Dispose objects before losing scope
Log.ResponseHandleCreated(this._logger, this.ActorId.ToString(), request.MessageId);
activity.Complete(RequestCompleted, this.ActorId, [(Tel.Request.Status, requestStatus), (Tel.Response.Status, HandleCreated)],
@@ -243,19 +260,11 @@ internal sealed class InProcessActorContext : IActorRuntimeContext, IAsyncDispos
[.. operations.Operations.OfType<ActorStateWriteOperation>()];
WriteResponse result;
if (writeOps.Count == 0)
{
// Nothing to write
result = new WriteResponse(operations.ETag, success: true);
}
else
{
result = await this.Storage.WriteStateAsync(
this.ActorId,
writeOps,
operations.ETag,
cancellationToken).ConfigureAwait(false);
}
result = await this.Storage.WriteStateAsync(
this.ActorId,
writeOps,
operations.ETag,
cancellationToken).ConfigureAwait(false);
Log.WriteOperationCompleted(this._logger, this.ActorId.ToString(), result.Success);
@@ -396,7 +405,7 @@ internal sealed class InProcessActorContext : IActorRuntimeContext, IAsyncDispos
{
ActorId = context.ActorId,
MessageId = entry.Request.MessageId,
Data = JsonSerializer.SerializeToElement($"Error: {exception.Message}", ActorRuntimeJsonContext.Default.String),
Data = JsonSerializer.SerializeToElement($"Error: {exception.Message}", AgentRuntimeJsonUtilities.JsonContext.Default.String),
Status = RequestStatus.Failed,
};
}
@@ -433,6 +442,13 @@ internal sealed class InProcessActorContext : IActorRuntimeContext, IAsyncDispos
{
yield return new ActorRequestUpdate(update.Status, update.Data);
}
var response = await entry.Response
#if NET8_0_OR_GREATER
.WaitAsync(cancellationToken)
#endif
.ConfigureAwait(false);
yield return new ActorRequestUpdate(response.Status, response.Data);
}
}
}
@@ -162,7 +162,17 @@ internal sealed class InProcessActorClient(InProcessActorRuntime runtime) : IAct
activity.SetupRequestOperation(actorId, messageId, service: "ActorClient", rpcMethod: "GetResponse");
throw new NotImplementedException("GetResponseAsync is not yet implemented");
var actorContext = this._runtime.GetOrCreateActor(actorId);
#pragma warning disable CA2000 // Dispose objects before losing scope
if (actorContext.TryGetResponseHandle(messageId, out var handle))
{
return new(handle);
}
#pragma warning restore CA2000 // Dispose objects before losing scope
#pragma warning disable CA2000 // Dispose objects before losing scope
return new(new NotFoundActorResponseHandle(actorId, messageId));
#pragma warning restore CA2000 // Dispose objects before losing scope
}
public ValueTask<ActorResponseHandle> SendRequestAsync(ActorRequest request, CancellationToken cancellationToken)
@@ -180,7 +190,9 @@ internal sealed class InProcessActorClient(InProcessActorRuntime runtime) : IAct
// Ensure the message is enqueued on the actor's inbox, getting a response handle for it.
var actorId = request.ActorId;
var actorContext = this._runtime.GetOrCreateActor(actorId);
#pragma warning disable CA2000 // Dispose objects before losing scope
var response = actorContext.SendRequest(request);
#pragma warning restore CA2000 // Dispose objects before losing scope
activity.Complete(MessageSent, actorId, Sent, (Tel.Message.Id, request.MessageId));
@@ -0,0 +1,47 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Extensions.AI.Agents.Runtime;
internal sealed class NotFoundActorResponseHandle : ActorResponseHandle
{
private readonly ActorResponse _response;
public NotFoundActorResponseHandle(ActorId actorId, string messageId)
{
this._response = new ActorResponse()
{
Status = RequestStatus.NotFound,
ActorId = actorId,
MessageId = messageId,
};
}
public override ValueTask CancelAsync(CancellationToken cancellationToken)
{
throw new InvalidOperationException(
$"Failed to cancel request for actor '{this._response.ActorId}' with message ID '{this._response.MessageId}'. The request was not found.");
}
public override ValueTask<ActorResponse> GetResponseAsync(CancellationToken cancellationToken)
{
return new ValueTask<ActorResponse>(this._response);
}
public override bool TryGetResponse([NotNullWhen(true)] out ActorResponse? response)
{
response = this._response;
return true;
}
public override async IAsyncEnumerable<ActorRequestUpdate> WatchUpdatesAsync([EnumeratorCancellation] CancellationToken cancellationToken)
{
yield break;
}
}