mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.NET: Add agent hosting package and update sample (#296)
* Add agent hosting package and update sample * Review feedback and cleanup * Include the narrator * wip * wip * Remove workaround for empty state writes. * Handle changes to AgentThread. * One more. * Fix. --------- Co-authored-by: Aditya Mandaleeka <adityam@microsoft.com>
This commit is contained in:
co-authored by
Aditya Mandaleeka
parent
8dcc8533a6
commit
e7441ee29e
@@ -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; }
|
||||
}
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.AI.Agents.Runtime;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Extensions.AI.Agents.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods for configuring AI agents in a host application builder.
|
||||
/// </summary>
|
||||
public static class HostApplicationBuilderAgentExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds an AI agent to the host application builder with the specified name and instructions.
|
||||
/// </summary>
|
||||
/// <param name="builder">The host application builder to configure.</param>
|
||||
/// <param name="name">The name of the agent.</param>
|
||||
/// <param name="instructions">The instructions for the agent.</param>
|
||||
/// <returns>The configured host application builder.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/>, <paramref name="name"/>, or <paramref name="instructions"/> is null.</exception>
|
||||
public static IHostApplicationBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string instructions)
|
||||
{
|
||||
Throw.IfNull(builder);
|
||||
Throw.IfNull(name);
|
||||
return builder.AddAIAgent(name, instructions, chatClientServiceKey: null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an AI agent to the host application builder with the specified name, instructions, and chat client key.
|
||||
/// </summary>
|
||||
/// <param name="builder">The host application builder to configure.</param>
|
||||
/// <param name="name">The name of the agent.</param>
|
||||
/// <param name="instructions">The instructions for the agent.</param>
|
||||
/// <param name="chatClient">The chat client which the agent will use for inference.</param>
|
||||
/// <returns>The configured host application builder.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/>, <paramref name="name"/>, or <paramref name="instructions"/> is null.</exception>
|
||||
public static IHostApplicationBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string instructions, IChatClient chatClient)
|
||||
{
|
||||
Throw.IfNull(builder);
|
||||
Throw.IfNull(name);
|
||||
return builder.AddAIAgent(name, (sp, key) => new ChatClientAgent(chatClient, instructions, key));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an AI agent to the host application builder with the specified name, instructions, and chat client key.
|
||||
/// </summary>
|
||||
/// <param name="builder">The host application builder to configure.</param>
|
||||
/// <param name="name">The name of the agent.</param>
|
||||
/// <param name="instructions">The instructions for the agent.</param>
|
||||
/// <param name="description">A description of the agent.</param>
|
||||
/// <param name="chatClientServiceKey">The key to use when resolving the chat client from the service provider. If null, a non-keyed service will be resolved.</param>
|
||||
/// <returns>The configured host application builder.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/>, <paramref name="name"/>, or <paramref name="instructions"/> is null.</exception>
|
||||
public static IHostApplicationBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string instructions, string description, object? chatClientServiceKey)
|
||||
{
|
||||
Throw.IfNull(builder);
|
||||
Throw.IfNull(name);
|
||||
return builder.AddAIAgent(name, (sp, key) =>
|
||||
{
|
||||
var chatClient = chatClientServiceKey is null ? sp.GetRequiredService<IChatClient>() : sp.GetRequiredKeyedService<IChatClient>(chatClientServiceKey);
|
||||
return new ChatClientAgent(chatClient, instructions: instructions, name: key, description: description);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an AI agent to the host application builder with the specified name, instructions, and chat client key.
|
||||
/// </summary>
|
||||
/// <param name="builder">The host application builder to configure.</param>
|
||||
/// <param name="name">The name of the agent.</param>
|
||||
/// <param name="instructions">The instructions for the agent.</param>
|
||||
/// <param name="chatClientServiceKey">The key to use when resolving the chat client from the service provider. If null, a non-keyed service will be resolved.</param>
|
||||
/// <returns>The configured host application builder.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/>, <paramref name="name"/>, or <paramref name="instructions"/> is null.</exception>
|
||||
public static IHostApplicationBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string instructions, object? chatClientServiceKey)
|
||||
{
|
||||
Throw.IfNull(builder);
|
||||
Throw.IfNull(name);
|
||||
return builder.AddAIAgent(name, (sp, key) =>
|
||||
{
|
||||
var chatClient = chatClientServiceKey is null ? sp.GetRequiredService<IChatClient>() : sp.GetRequiredKeyedService<IChatClient>(chatClientServiceKey);
|
||||
return new ChatClientAgent(chatClient, instructions, key);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an AI agent to the host application builder using a custom factory delegate.
|
||||
/// </summary>
|
||||
/// <param name="builder">The host application builder to configure.</param>
|
||||
/// <param name="name">The name of the agent.</param>
|
||||
/// <param name="createAgentDelegate">A factory delegate that creates the AI agent instance. The delegate receives the service provider and agent key as parameters.</param>
|
||||
/// <returns>The configured host application builder.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/>, <paramref name="name"/>, or <paramref name="createAgentDelegate"/> is null.</exception>
|
||||
/// <exception cref="InvalidOperationException">Thrown when the agent factory delegate returns null or an invalid AI agent instance.</exception>
|
||||
public static IHostApplicationBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, Func<IServiceProvider, string, AIAgent> createAgentDelegate)
|
||||
{
|
||||
Throw.IfNull(builder);
|
||||
Throw.IfNull(name);
|
||||
Throw.IfNull(createAgentDelegate);
|
||||
builder.Services.AddKeyedSingleton<AIAgent>(name, (sp, key) =>
|
||||
{
|
||||
Throw.IfNull(key);
|
||||
var keyString = key as string;
|
||||
Throw.IfNullOrEmpty(keyString);
|
||||
var agent = createAgentDelegate(sp, keyString) ?? throw new InvalidOperationException($"The agent factory did not return a valid {nameof(AIAgent)} instance for key '{keyString}'.");
|
||||
if (agent.Name != keyString)
|
||||
{
|
||||
throw new InvalidOperationException($"The agent factory returned an agent with name '{agent.Name}', but the expected name is '{keyString}'.");
|
||||
}
|
||||
|
||||
return agent;
|
||||
});
|
||||
|
||||
return builder.AddAgentActor(name);
|
||||
}
|
||||
|
||||
private static IHostApplicationBuilder AddAgentActor(this IHostApplicationBuilder builder, string name)
|
||||
{
|
||||
Throw.IfNull(builder);
|
||||
|
||||
// Register the agent by name for discovery.
|
||||
var agentHostBuilder = GetAgentRegistry(builder);
|
||||
agentHostBuilder.AgentNames.Add(name);
|
||||
|
||||
// Add the actor runtime and register the agent actor type.
|
||||
var actorBuilder = builder.AddActorRuntime();
|
||||
actorBuilder.AddActorType(
|
||||
new ActorType(name),
|
||||
(sp, ctx) => new AgentActor(
|
||||
sp.GetRequiredKeyedService<AIAgent>(name),
|
||||
ctx,
|
||||
sp.GetRequiredService<ILogger<AgentActor>>()));
|
||||
return builder;
|
||||
}
|
||||
|
||||
private static LocalAgentRegistry GetAgentRegistry(IHostApplicationBuilder builder)
|
||||
{
|
||||
var descriptor = builder.Services.FirstOrDefault(s => !s.IsKeyedService && s.ServiceType.Equals(typeof(LocalAgentRegistry)));
|
||||
if (descriptor?.ImplementationInstance is not LocalAgentRegistry instance)
|
||||
{
|
||||
instance = new LocalAgentRegistry();
|
||||
ConfigureHostBuilder(builder, instance);
|
||||
}
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
private static void ConfigureHostBuilder(IHostApplicationBuilder builder, LocalAgentRegistry agentHostBuilderContext)
|
||||
{
|
||||
builder.Services.Add(ServiceDescriptor.Singleton(agentHostBuilderContext));
|
||||
builder.Services.AddSingleton<AgentCatalog, LocalAgentCatalog>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Microsoft.Extensions.AI.Agents.Hosting;
|
||||
|
||||
// Implementation of an AgentCatalog which enumerates agents registered in the local service provider.
|
||||
internal sealed class LocalAgentCatalog : AgentCatalog
|
||||
{
|
||||
public readonly HashSet<string> _registeredAgents;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
|
||||
public LocalAgentCatalog(LocalAgentRegistry agentHostBuilder, IServiceProvider serviceProvider)
|
||||
{
|
||||
this._registeredAgents = [.. agentHostBuilder.AgentNames];
|
||||
this._serviceProvider = serviceProvider;
|
||||
}
|
||||
|
||||
public override async IAsyncEnumerable<AIAgent> GetAgentsAsync([EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
await Task.CompletedTask.ConfigureAwait(false);
|
||||
|
||||
foreach (var name in this._registeredAgents)
|
||||
{
|
||||
var agent = this._serviceProvider.GetKeyedService<AIAgent>(name);
|
||||
if (agent is not null)
|
||||
{
|
||||
yield return agent;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Microsoft.Extensions.AI.Agents.Hosting;
|
||||
|
||||
internal sealed class LocalAgentRegistry
|
||||
{
|
||||
public HashSet<string> AgentNames { get; } = [];
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Microsoft.Extensions.AI.Agents.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// High-performance logging messages using LoggerMessage source generator.
|
||||
/// </summary>
|
||||
internal static partial class Log
|
||||
{
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Information,
|
||||
Message = "Actor started: ActorId={ActorId}, AgentName={AgentName}")]
|
||||
public static partial void ActorStarted(ILogger logger, string actorId, string agentName);
|
||||
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Debug,
|
||||
Message = "Thread state restored: ActorId={ActorId}, HasExistingThread={HasExistingThread}")]
|
||||
public static partial void ThreadStateRestored(ILogger logger, string actorId, bool hasExistingThread);
|
||||
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Information,
|
||||
Message = "Processing agent request: RequestId={RequestId}, ActorId={ActorId}, MessageCount={MessageCount}")]
|
||||
public static partial void ProcessingAgentRequest(ILogger logger, string requestId, string actorId, int messageCount);
|
||||
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Debug,
|
||||
Message = "Agent streaming update: RequestId={RequestId}, UpdateNumber={UpdateNumber}")]
|
||||
public static partial void AgentStreamingUpdate(ILogger logger, string requestId, int updateNumber);
|
||||
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Information,
|
||||
Message = "Agent request completed: RequestId={RequestId}, TotalUpdates={TotalUpdates}")]
|
||||
public static partial void AgentRequestCompleted(ILogger logger, string requestId, int totalUpdates);
|
||||
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Error,
|
||||
Message = "Agent request failed: RequestId={RequestId}, ActorId={ActorId}")]
|
||||
public static partial void AgentRequestFailed(ILogger logger, Exception exception, string requestId, string actorId);
|
||||
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Warning,
|
||||
Message = "Unknown message type received: MessageType={MessageType}, ActorId={ActorId}")]
|
||||
public static partial void UnknownMessageType(ILogger logger, string messageType, string actorId);
|
||||
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Warning,
|
||||
Message = "Error processing messages: ActorId={ActorId}")]
|
||||
public static partial void ErrorProcessingMessages(ILogger logger, Exception exception, string actorId);
|
||||
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Error,
|
||||
Message = "Write operation failed: ActorId={ActorId}, RequestId={RequestId}")]
|
||||
public static partial void WriteOperationFailed(ILogger logger, string actorId, string requestId);
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>$(ProjectsTargetFrameworks)</TargetFrameworks>
|
||||
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsDebugTargetFrameworks)</TargetFrameworks>
|
||||
<VersionSuffix>alpha</VersionSuffix>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<InjectSharedThrow>true</InjectSharedThrow>
|
||||
<InjectDiagnosticClassesOnLegacy>true</InjectDiagnosticClassesOnLegacy>
|
||||
<InjectTrimAttributesOnLegacy>true</InjectTrimAttributesOnLegacy>
|
||||
</PropertyGroup>
|
||||
|
||||
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Microsoft.Extensions.AI.Agents.Abstractions\Microsoft.Extensions.AI.Agents.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\Microsoft.Extensions.AI.Agents.Runtime.Abstractions\Microsoft.Extensions.AI.Agents.Runtime.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\Microsoft.Extensions.AI.Agents.Runtime\Microsoft.Extensions.AI.Agents.Runtime.csproj" />
|
||||
<ProjectReference Include="..\Microsoft.Extensions.AI.Agents\Microsoft.Extensions.AI.Agents.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
|
||||
<PackageReference Include="System.Text.Json" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TargetFrameworkIdentifier)' != '.NETCoreApp'">
|
||||
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" />
|
||||
<PackageReference Include="System.Text.Json" />
|
||||
<PackageReference Include="Microsoft.Bcl.HashCode" />
|
||||
<PackageReference Include="System.Threading.Channels" />
|
||||
<PackageReference Include="System.Threading.Tasks.Extensions" />
|
||||
<PackageReference Include="System.Diagnostics.DiagnosticSource" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Microsoft.Extensions.AI.Agents.Hosting.UnitTests" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,41 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Microsoft.Extensions.AI.Agents.Runtime;
|
||||
|
||||
/// <summary>
|
||||
/// Source-generated JSON type information for use by all agent runtime abstractions.
|
||||
/// </summary>
|
||||
[JsonSourceGenerationOptions(
|
||||
JsonSerializerDefaults.Web,
|
||||
UseStringEnumConverter = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false)]
|
||||
[JsonSerializable(typeof(ActorId))]
|
||||
[JsonSerializable(typeof(ActorMessage))]
|
||||
[JsonSerializable(typeof(ActorReadOperation))]
|
||||
[JsonSerializable(typeof(ActorReadOperationBatch))]
|
||||
[JsonSerializable(typeof(ActorReadResult))]
|
||||
[JsonSerializable(typeof(ActorRequest))]
|
||||
[JsonSerializable(typeof(ActorRequestMessage))]
|
||||
[JsonSerializable(typeof(ActorRequestUpdate))]
|
||||
[JsonSerializable(typeof(ActorResponse))]
|
||||
[JsonSerializable(typeof(ActorResponseMessage))]
|
||||
[JsonSerializable(typeof(ActorType))]
|
||||
[JsonSerializable(typeof(ActorWriteOperation))]
|
||||
[JsonSerializable(typeof(ActorWriteOperationBatch))]
|
||||
[JsonSerializable(typeof(GetValueOperation))]
|
||||
[JsonSerializable(typeof(GetValueResult))]
|
||||
[JsonSerializable(typeof(JsonElement))]
|
||||
[JsonSerializable(typeof(ListKeysOperation))]
|
||||
[JsonSerializable(typeof(ListKeysResult))]
|
||||
[JsonSerializable(typeof(ReadResponse))]
|
||||
[JsonSerializable(typeof(RemoveKeyOperation))]
|
||||
[JsonSerializable(typeof(RequestStatus))]
|
||||
[JsonSerializable(typeof(SendRequestOperation))]
|
||||
[JsonSerializable(typeof(SetValueOperation))]
|
||||
[JsonSerializable(typeof(UpdateRequestOperation))]
|
||||
[JsonSerializable(typeof(WriteResponse))]
|
||||
internal sealed partial class ActorJsonContext : JsonSerializerContext;
|
||||
@@ -33,4 +33,25 @@ public sealed class ActorResponse
|
||||
/// </summary>
|
||||
[JsonPropertyName("status")]
|
||||
public RequestStatus Status { get; init; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string ToString()
|
||||
{
|
||||
string dataString;
|
||||
if (this.Data.ValueKind == JsonValueKind.Undefined)
|
||||
{
|
||||
dataString = "undefined";
|
||||
}
|
||||
else
|
||||
{
|
||||
var rawText = this.Data.GetRawText();
|
||||
dataString = rawText.Length switch
|
||||
{
|
||||
> 250 => $"{rawText.Substring(0, 250)}...",
|
||||
_ => rawText,
|
||||
};
|
||||
}
|
||||
|
||||
return $"ActorResponse(ActorId: {this.ActorId}, Status: {this.Status}, MessageId: {this.MessageId ?? "null"}, Data: {dataString})";
|
||||
}
|
||||
}
|
||||
|
||||
+15
-1
@@ -1,5 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading;
|
||||
@@ -10,7 +11,7 @@ namespace Microsoft.Extensions.AI.Agents.Runtime;
|
||||
/// <summary>
|
||||
/// Represents a handle to an actor response, allowing retrieval of the response data and status updates.
|
||||
/// </summary>
|
||||
public abstract class ActorResponseHandle
|
||||
public abstract class ActorResponseHandle : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Attempts to get the response from the request if it is immediately available.
|
||||
@@ -43,4 +44,17 @@ public abstract class ActorResponseHandle
|
||||
/// <param name="cancellationToken">A token to cancel the watch operation.</param>
|
||||
/// <returns>An asynchronous enumerable of request updates.</returns>
|
||||
public abstract IAsyncEnumerable<ActorRequestUpdate> WatchUpdatesAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose()
|
||||
{
|
||||
this.Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes of the resources used by the <see cref="ActorResponseHandle"/> class.
|
||||
/// </summary>
|
||||
/// <param name="disposing">A boolean indicating whether the method is being called from the <see cref="Dispose()"/> method.</param>
|
||||
protected virtual void Dispose(bool disposing) { }
|
||||
}
|
||||
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Microsoft.Extensions.AI.Agents.Runtime;
|
||||
|
||||
/// <summary>Provides a collection of utility methods for working with JSON data in the context of actor runtime abstractions.</summary>
|
||||
public static partial class AgentRuntimeAbstractionsJsonUtilities
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the <see cref="JsonSerializerOptions"/> singleton used as the default in JSON serialization operations.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// For Native AOT or applications disabling <see cref="JsonSerializer.IsReflectionEnabledByDefault"/>, this instance
|
||||
/// includes source generated contracts for all common exchange types contained in this library.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It additionally turns on the following settings:
|
||||
/// <list type="number">
|
||||
/// <item>Enables <see cref="JsonSerializerDefaults.Web"/> defaults.</item>
|
||||
/// <item>Enables <see cref="JsonIgnoreCondition.WhenWritingNull"/> as the default ignore condition for properties.</item>
|
||||
/// <item>Enables <see cref="JsonNumberHandling.AllowReadingFromString"/> as the default number handling for number types.</item>
|
||||
/// <item>Enables <see cref="JsonStringEnumConverter"/> for enum serialization.</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static JsonSerializerOptions DefaultOptions { get; } = CreateDefaultOptions();
|
||||
|
||||
/// <summary>
|
||||
/// Creates default options to use for actor runtime-related serialization.
|
||||
/// </summary>
|
||||
/// <returns>The configured options.</returns>
|
||||
[UnconditionalSuppressMessage("ReflectionAnalysis", "IL3050:RequiresDynamicCode", Justification = "Converter is guarded by IsReflectionEnabledByDefault check.")]
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access", Justification = "Converter is guarded by IsReflectionEnabledByDefault check.")]
|
||||
private static JsonSerializerOptions CreateDefaultOptions()
|
||||
{
|
||||
// Copy the configuration from the source generated context.
|
||||
JsonSerializerOptions options = new(JsonContext.Default.Options);
|
||||
|
||||
options.MakeReadOnly();
|
||||
return options;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Source-generated JSON type information for use by all agent runtime abstractions.
|
||||
/// </summary>
|
||||
[JsonSourceGenerationOptions(
|
||||
JsonSerializerDefaults.Web,
|
||||
UseStringEnumConverter = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false)]
|
||||
[JsonSerializable(typeof(ActorId))]
|
||||
[JsonSerializable(typeof(ActorMessage))]
|
||||
[JsonSerializable(typeof(ActorReadOperation))]
|
||||
[JsonSerializable(typeof(ActorReadOperationBatch))]
|
||||
[JsonSerializable(typeof(ActorReadResult))]
|
||||
[JsonSerializable(typeof(ActorRequest))]
|
||||
[JsonSerializable(typeof(ActorRequestMessage))]
|
||||
[JsonSerializable(typeof(ActorRequestUpdate))]
|
||||
[JsonSerializable(typeof(ActorResponse))]
|
||||
[JsonSerializable(typeof(ActorResponseMessage))]
|
||||
[JsonSerializable(typeof(ActorType))]
|
||||
[JsonSerializable(typeof(ActorWriteOperation))]
|
||||
[JsonSerializable(typeof(ActorWriteOperationBatch))]
|
||||
[JsonSerializable(typeof(GetValueOperation))]
|
||||
[JsonSerializable(typeof(GetValueResult))]
|
||||
[JsonSerializable(typeof(JsonElement))]
|
||||
[JsonSerializable(typeof(ListKeysOperation))]
|
||||
[JsonSerializable(typeof(ListKeysResult))]
|
||||
[JsonSerializable(typeof(ReadResponse))]
|
||||
[JsonSerializable(typeof(RemoveKeyOperation))]
|
||||
[JsonSerializable(typeof(RequestStatus))]
|
||||
[JsonSerializable(typeof(SendRequestOperation))]
|
||||
[JsonSerializable(typeof(SetValueOperation))]
|
||||
[JsonSerializable(typeof(UpdateRequestOperation))]
|
||||
[JsonSerializable(typeof(WriteResponse))]
|
||||
internal sealed partial class JsonContext : JsonSerializerContext;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using System;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Extensions.AI.Agents.Runtime;
|
||||
|
||||
@@ -13,15 +14,21 @@ namespace Microsoft.Extensions.AI.Agents.Runtime;
|
||||
[JsonConverter(typeof(Converter))]
|
||||
public readonly partial struct ActorType : IEquatable<ActorType>
|
||||
{
|
||||
#if NET7_0_OR_GREATER
|
||||
[System.Diagnostics.CodeAnalysis.StringSyntax("Regex")]
|
||||
#endif
|
||||
private const string AgentTypeValidationRegex = "^[a-zA-Z_][a-zA-Z._:\\-0-9]*$";
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ActorId"/> struct.
|
||||
/// </summary>
|
||||
/// <param name="type">The actor type.</param>
|
||||
public ActorType(string type)
|
||||
{
|
||||
if (!IsValid(type))
|
||||
Throw.IfNullOrEmpty(type);
|
||||
if (!IsValidType(type))
|
||||
{
|
||||
throw new ArgumentException($"Invalid type: '{type}'. Must be alphanumeric (a-z, 0-9, _) and cannot start with a number or contain spaces.");
|
||||
throw new ArgumentException($"Invalid type: '{type}'. Must start with a letter or underscore, and can only contain letters, dots, underscores, colons, hyphens, and numbers.", nameof(type));
|
||||
}
|
||||
|
||||
this.Name = type;
|
||||
@@ -59,16 +66,23 @@ public readonly partial struct ActorType : IEquatable<ActorType>
|
||||
public static bool operator !=(ActorType left, ActorType right) =>
|
||||
!(left == right);
|
||||
|
||||
internal static bool IsValid(string type) =>
|
||||
type is not null && TypeRegex().IsMatch(type);
|
||||
|
||||
#if NET
|
||||
[GeneratedRegex("^[a-zA-Z_][a-zA-Z_:0-9]*$")]
|
||||
#if NET7_0_OR_GREATER
|
||||
[GeneratedRegex(AgentTypeValidationRegex)]
|
||||
private static partial Regex TypeRegex();
|
||||
#else
|
||||
private static Regex TypeRegex() => new("^[a-zA-Z_][a-zA-Z_:0-9:]*$", RegexOptions.Compiled);
|
||||
private static readonly Regex s_typeRegex = new(AgentTypeValidationRegex, RegexOptions.Compiled);
|
||||
private static Regex TypeRegex() => s_typeRegex;
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
/// Validates whether the provided type string is a valid actor type.
|
||||
/// </summary>
|
||||
public static bool IsValidType(string type)
|
||||
{
|
||||
Throw.IfNullOrEmpty(type);
|
||||
return TypeRegex().IsMatch(type);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// JSON converter for <see cref="ActorType"/>.
|
||||
/// </summary>
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Extensions.AI.Agents.Runtime;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods for the <see cref="RequestStatus"/> enumeration.
|
||||
/// </summary>
|
||||
public static class RequestStatusExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Determines if the request status indicates that the request has terminated.
|
||||
/// </summary>
|
||||
/// <param name="status">The request status to check.</param>
|
||||
/// <returns><see langword="true"/> if the request has terminated; otherwise, <see langword="false"/>.</returns>
|
||||
public static bool IsTerminated(this RequestStatus status) => status != RequestStatus.Pending;
|
||||
}
|
||||
+3
-1
@@ -16,4 +16,6 @@ namespace Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB;
|
||||
[JsonSerializable(typeof(ActorStateDocument))]
|
||||
[JsonSerializable(typeof(ActorRootDocument))]
|
||||
[JsonSerializable(typeof(KeyProjection))]
|
||||
internal sealed partial class CosmosActorStateJsonContext : JsonSerializerContext;
|
||||
[JsonSerializable(typeof(KeyProjection[]))]
|
||||
[JsonSerializable(typeof(JsonElement))]
|
||||
public sealed partial class CosmosActorStateJsonContext : JsonSerializerContext;
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Microsoft.Extensions.AI.Agents.Runtime;
|
||||
|
||||
[JsonSourceGenerationOptions(
|
||||
JsonSerializerDefaults.Web,
|
||||
UseStringEnumConverter = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false)]
|
||||
[JsonSerializable(typeof(string))]
|
||||
internal sealed partial class ActorRuntimeJsonContext : JsonSerializerContext;
|
||||
@@ -0,0 +1,57 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Microsoft.Extensions.AI.Agents.Runtime;
|
||||
|
||||
/// <summary>Provides a collection of utility methods for working with JSON data in the context of agent runtime.</summary>
|
||||
public static partial class AgentRuntimeJsonUtilities
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the <see cref="JsonSerializerOptions"/> singleton used as the default in JSON serialization operations.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// For Native AOT or applications disabling <see cref="JsonSerializer.IsReflectionEnabledByDefault"/>, this instance
|
||||
/// includes source generated contracts for all common exchange types contained in this library.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It additionally turns on the following settings:
|
||||
/// <list type="number">
|
||||
/// <item>Enables <see cref="JsonSerializerDefaults.Web"/> defaults.</item>
|
||||
/// <item>Enables <see cref="JsonIgnoreCondition.WhenWritingNull"/> as the default ignore condition for properties.</item>
|
||||
/// <item>Enables <see cref="JsonNumberHandling.AllowReadingFromString"/> as the default number handling for number types.</item>
|
||||
/// <item>Enables <see cref="JsonStringEnumConverter"/> for enum serialization.</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static JsonSerializerOptions DefaultOptions { get; } = CreateDefaultOptions();
|
||||
|
||||
/// <summary>
|
||||
/// Creates default options to use for agent runtime-related serialization.
|
||||
/// </summary>
|
||||
/// <returns>The configured options.</returns>
|
||||
[UnconditionalSuppressMessage("ReflectionAnalysis", "IL3050:RequiresDynamicCode", Justification = "Converter is guarded by IsReflectionEnabledByDefault check.")]
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access", Justification = "Converter is guarded by IsReflectionEnabledByDefault check.")]
|
||||
private static JsonSerializerOptions CreateDefaultOptions()
|
||||
{
|
||||
// Copy the configuration from the source generated context.
|
||||
JsonSerializerOptions options = new(JsonContext.Default.Options);
|
||||
|
||||
// Chain with all supported types from Microsoft.Extensions.AI.Agents.Runtime.Abstractions.
|
||||
options.TypeInfoResolverChain.Add(AgentRuntimeAbstractionsJsonUtilities.DefaultOptions.TypeInfoResolver!);
|
||||
|
||||
options.MakeReadOnly();
|
||||
return options;
|
||||
}
|
||||
|
||||
[JsonSourceGenerationOptions(
|
||||
JsonSerializerDefaults.Web,
|
||||
UseStringEnumConverter = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false)]
|
||||
[JsonSerializable(typeof(string))]
|
||||
internal sealed partial class JsonContext : JsonSerializerContext;
|
||||
}
|
||||
@@ -100,6 +100,21 @@ internal sealed class InProcessActorContext : IActorRuntimeContext, IAsyncDispos
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryGetResponseHandle(string messageId, [NotNullWhen(true)] out ActorResponseHandle? handle)
|
||||
{
|
||||
lock (this._lock)
|
||||
{
|
||||
if (!this._inbox.TryGetValue(messageId, out var entry))
|
||||
{
|
||||
handle = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
handle = new InProcessActorResponseHandle(this, entry);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public ActorResponseHandle SendRequest(ActorRequest request)
|
||||
{
|
||||
using var activity = ActivitySource.StartActivity(
|
||||
@@ -133,7 +148,9 @@ internal sealed class InProcessActorContext : IActorRuntimeContext, IAsyncDispos
|
||||
requestStatus = "found";
|
||||
}
|
||||
|
||||
#pragma warning disable CA2000 // Dispose objects before losing scope
|
||||
var handle = new InProcessActorResponseHandle(this, entry);
|
||||
#pragma warning restore CA2000 // Dispose objects before losing scope
|
||||
Log.ResponseHandleCreated(this._logger, this.ActorId.ToString(), request.MessageId);
|
||||
|
||||
activity.Complete(RequestCompleted, this.ActorId, [(Tel.Request.Status, requestStatus), (Tel.Response.Status, HandleCreated)],
|
||||
@@ -243,19 +260,11 @@ internal sealed class InProcessActorContext : IActorRuntimeContext, IAsyncDispos
|
||||
[.. operations.Operations.OfType<ActorStateWriteOperation>()];
|
||||
|
||||
WriteResponse result;
|
||||
if (writeOps.Count == 0)
|
||||
{
|
||||
// Nothing to write
|
||||
result = new WriteResponse(operations.ETag, success: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
result = await this.Storage.WriteStateAsync(
|
||||
this.ActorId,
|
||||
writeOps,
|
||||
operations.ETag,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
result = await this.Storage.WriteStateAsync(
|
||||
this.ActorId,
|
||||
writeOps,
|
||||
operations.ETag,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
Log.WriteOperationCompleted(this._logger, this.ActorId.ToString(), result.Success);
|
||||
|
||||
@@ -396,7 +405,7 @@ internal sealed class InProcessActorContext : IActorRuntimeContext, IAsyncDispos
|
||||
{
|
||||
ActorId = context.ActorId,
|
||||
MessageId = entry.Request.MessageId,
|
||||
Data = JsonSerializer.SerializeToElement($"Error: {exception.Message}", ActorRuntimeJsonContext.Default.String),
|
||||
Data = JsonSerializer.SerializeToElement($"Error: {exception.Message}", AgentRuntimeJsonUtilities.JsonContext.Default.String),
|
||||
Status = RequestStatus.Failed,
|
||||
};
|
||||
}
|
||||
@@ -433,6 +442,13 @@ internal sealed class InProcessActorContext : IActorRuntimeContext, IAsyncDispos
|
||||
{
|
||||
yield return new ActorRequestUpdate(update.Status, update.Data);
|
||||
}
|
||||
|
||||
var response = await entry.Response
|
||||
#if NET8_0_OR_GREATER
|
||||
.WaitAsync(cancellationToken)
|
||||
#endif
|
||||
.ConfigureAwait(false);
|
||||
yield return new ActorRequestUpdate(response.Status, response.Data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,7 +162,17 @@ internal sealed class InProcessActorClient(InProcessActorRuntime runtime) : IAct
|
||||
|
||||
activity.SetupRequestOperation(actorId, messageId, service: "ActorClient", rpcMethod: "GetResponse");
|
||||
|
||||
throw new NotImplementedException("GetResponseAsync is not yet implemented");
|
||||
var actorContext = this._runtime.GetOrCreateActor(actorId);
|
||||
#pragma warning disable CA2000 // Dispose objects before losing scope
|
||||
if (actorContext.TryGetResponseHandle(messageId, out var handle))
|
||||
{
|
||||
return new(handle);
|
||||
}
|
||||
#pragma warning restore CA2000 // Dispose objects before losing scope
|
||||
|
||||
#pragma warning disable CA2000 // Dispose objects before losing scope
|
||||
return new(new NotFoundActorResponseHandle(actorId, messageId));
|
||||
#pragma warning restore CA2000 // Dispose objects before losing scope
|
||||
}
|
||||
|
||||
public ValueTask<ActorResponseHandle> SendRequestAsync(ActorRequest request, CancellationToken cancellationToken)
|
||||
@@ -180,7 +190,9 @@ internal sealed class InProcessActorClient(InProcessActorRuntime runtime) : IAct
|
||||
// Ensure the message is enqueued on the actor's inbox, getting a response handle for it.
|
||||
var actorId = request.ActorId;
|
||||
var actorContext = this._runtime.GetOrCreateActor(actorId);
|
||||
#pragma warning disable CA2000 // Dispose objects before losing scope
|
||||
var response = actorContext.SendRequest(request);
|
||||
#pragma warning restore CA2000 // Dispose objects before losing scope
|
||||
|
||||
activity.Complete(MessageSent, actorId, Sent, (Tel.Message.Id, request.MessageId));
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Extensions.AI.Agents.Runtime;
|
||||
|
||||
internal sealed class NotFoundActorResponseHandle : ActorResponseHandle
|
||||
{
|
||||
private readonly ActorResponse _response;
|
||||
|
||||
public NotFoundActorResponseHandle(ActorId actorId, string messageId)
|
||||
{
|
||||
this._response = new ActorResponse()
|
||||
{
|
||||
Status = RequestStatus.NotFound,
|
||||
ActorId = actorId,
|
||||
MessageId = messageId,
|
||||
};
|
||||
}
|
||||
|
||||
public override ValueTask CancelAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Failed to cancel request for actor '{this._response.ActorId}' with message ID '{this._response.MessageId}'. The request was not found.");
|
||||
}
|
||||
|
||||
public override ValueTask<ActorResponse> GetResponseAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return new ValueTask<ActorResponse>(this._response);
|
||||
}
|
||||
|
||||
public override bool TryGetResponse([NotNullWhen(true)] out ActorResponse? response)
|
||||
{
|
||||
response = this._response;
|
||||
return true;
|
||||
}
|
||||
|
||||
public override async IAsyncEnumerable<ActorRequestUpdate> WatchUpdatesAsync([EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user