Overhaul orchestration library with new approach (#199)

This commit is contained in:
Stephen Toub
2025-07-23 16:29:37 +00:00
committed by GitHub
parent 27f7af2160
commit 5472d6e996
104 changed files with 1212 additions and 5084 deletions
@@ -0,0 +1,93 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.AI.Agents;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.Orchestration;
/// <summary>Provides extensions for orchestrating <see cref="AIAgent"/>s.</summary>
public static class AIAgentExtensions
{
private const string DefaultInstructions = "Respond with JSON that is populated by using the information in this conversation.";
/// <summary>
/// Runs the agent with the messages, then uses the chat client to process the agent's output and return a structured response.
/// </summary>
/// <typeparam name="T">The type of the result expected from the chat client response.</typeparam>
/// <param name="agent">The AI agent to be run.</param>
/// <param name="chatClient">The chat client used to process the messages.</param>
/// <param name="message">The message to be processed.</param>
/// <param name="thread">An optional thread context for the agent execution.</param>
/// <param name="runOptions">Optional settings that influence the agent's execution.</param>
/// <param name="serializerOptions">Optional serializer options to control how <typeparamref name="T"/> is deserialized.</param>
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
/// <returns>A task representing the asynchronous operation, with a result of type <typeparamref name="T"/> containing the
/// structured response.</returns>
public static ValueTask<T> RunAsync<T>(
this AIAgent agent,
IChatClient chatClient,
string message,
AgentThread? thread = null,
AgentRunOptions? runOptions = null,
JsonSerializerOptions? serializerOptions = null,
CancellationToken cancellationToken = default)
{
Throw.IfNull(agent);
Throw.IfNull(chatClient);
Throw.IfNull(message);
return RunAsync<T>(
agent,
chatClient,
[new ChatMessage(ChatRole.User, message)],
thread,
runOptions,
serializerOptions,
cancellationToken);
}
/// <summary>
/// Runs the agent with the messages, then uses the chat client to process the agent's output and return a structured response.
/// </summary>
/// <typeparam name="T">The type of the result expected from the chat client response.</typeparam>
/// <param name="agent">The AI agent to be run.</param>
/// <param name="chatClient">The chat client used to process the messages.</param>
/// <param name="messages">A collection of chat messages to be processed.</param>
/// <param name="thread">An optional thread context for the agent execution.</param>
/// <param name="runOptions">Optional settings that influence the agent's execution.</param>
/// <param name="serializerOptions">Optional serializer options to control how <typeparamref name="T"/> is deserialized.</param>
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
/// <returns>A task representing the asynchronous operation, with a result of type <typeparamref name="T"/> containing the
/// structured response.</returns>
public static async ValueTask<T> RunAsync<T>(
this AIAgent agent,
IChatClient chatClient,
IReadOnlyCollection<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? runOptions = null,
JsonSerializerOptions? serializerOptions = null,
CancellationToken cancellationToken = default)
{
Throw.IfNull(agent);
Throw.IfNull(chatClient);
Throw.IfNull(messages);
// Invoke the agent.
var response = await agent.RunAsync(messages, thread, runOptions, cancellationToken).ConfigureAwait(false);
// Pass the output messages to the chat client to get a structured response.
var result = await chatClient.GetResponseAsync<T>(
response.Messages,
serializerOptions: serializerOptions ?? AIJsonUtilities.DefaultOptions,
new ChatOptions() { Instructions = DefaultInstructions },
cancellationToken: cancellationToken).ConfigureAwait(false);
// Parse and return the results.
return result.Result;
}
}
@@ -1,124 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.AI.Agents;
using Microsoft.Extensions.AI.Agents.Runtime;
using Microsoft.Extensions.Logging;
namespace Microsoft.Agents.Orchestration;
/// <summary>
/// An actor that represents an <see cref="Agent"/>.
/// </summary>
public abstract class AgentActor : OrchestrationActor
{
/// <summary>
/// Initializes a new instance of the <see cref="AgentActor"/> class.
/// </summary>
/// <param name="id">The unique identifier of the agent.</param>
/// <param name="runtime">The runtime associated with the agent.</param>
/// <param name="context">The orchestration context.</param>
/// <param name="agent">An <see cref="Agent"/>.</param>
/// <param name="logger">The logger to use for the actor</param>
protected AgentActor(ActorId id, IAgentRuntime runtime, OrchestrationContext context, AIAgent agent, ILogger? logger = null)
: base(id, runtime, context, agent.Description, logger)
{
this.Agent = agent;
this.Thread = this.Agent.GetNewThread();
}
/// <summary>
/// Gets the associated agent.
/// </summary>
protected AIAgent Agent { get; }
/// <summary>
/// Gets the current conversation thread used during agent communication.
/// </summary>
protected AgentThread Thread { get; private set; }
/// <summary>
/// Reset the conversation thread.
/// </summary>
protected void ResetThread()
{
this.Thread = this.Agent.GetNewThread();
}
/// <summary>
/// Invokes the agent for a non-streamed responses.
/// </summary>
/// <param name="messages">The messages to send.</param>
/// <param name="options">The options for running the agent.</param>
/// <param name="cancellationToken">A cancellation token for the operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
/// <remarks>
/// This method is not intended to be called directly; instead, use <see cref="RunAsync"/>.
/// This method exists to be overridden in derived classes in order to customize the invocation of the agent by <see cref="RunAsync"/>.
/// </remarks>
protected virtual Task<AgentRunResponse> InvokeCoreAsync(
IReadOnlyCollection<ChatMessage> messages, AgentRunOptions? options, CancellationToken cancellationToken) =>
this.Agent.RunAsync([.. messages], this.Thread, options, cancellationToken);
/// <summary>
/// Invokes the agent for a streamed responses.
/// </summary>
/// <param name="messages">The messages to send.</param>
/// <param name="options">The options for running the agent.</param>
/// <param name="cancellationToken">A cancellation token for the operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
/// <remarks>
/// This method is not intended to be called directly; instead, use <see cref="RunAsync"/>.
/// This method exists to be overridden in derived classes in order to customize the invocation of the agent by <see cref="RunAsync"/>.
/// </remarks>
protected virtual IAsyncEnumerable<AgentRunResponseUpdate> InvokeStreamingCoreAsync(
IReadOnlyCollection<ChatMessage> messages, AgentRunOptions? options, CancellationToken cancellationToken) =>
this.Agent.RunStreamingAsync(messages, this.Thread, options, cancellationToken);
/// <summary>
/// Runs the agent with input messages and respond with both streamed and regular messages.
/// </summary>
/// <param name="input">The list of chat messages to send.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>A task that returns the response <see cref="ChatMessage"/>.</returns>
protected async ValueTask<ChatMessage> RunAsync(IEnumerable<ChatMessage> input, CancellationToken cancellationToken)
{
using CancellationTokenSource combined = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, this.Context.CancellationToken);
cancellationToken = combined.Token;
// Utilize streaming iff a streaming callback is provided; otherwise, use the non-streaming API.
AgentRunResponse response;
if (this.Context.StreamingResponseCallback is { } streamingCallback)
{
// For streaming, enumerate all the updates, invoking the callback for each, and storing them all.
// Then convert them all into a single response instance.
List<AgentRunResponseUpdate> updates = [];
await foreach (AgentRunResponseUpdate update in this.InvokeStreamingCoreAsync([.. input], options: null, cancellationToken).WithCancellation(this.Context.CancellationToken).ConfigureAwait(false))
{
updates.Add(update);
await streamingCallback(update).ConfigureAwait(false);
}
response = updates.ToAgentRunResponse();
}
else
{
// For non-streaming, just invoke the non-streaming method and get back the response.
response = await this.InvokeCoreAsync([.. input], options: null, cancellationToken).ConfigureAwait(false);
}
// Regardless of whether we invoked streaming callbacks for individual updates, invoke the non-streaming callback with the final response instance.
// This can be used as an indication of completeness if someone otherwise only cares about the streaming updates.
if (this.Context.ResponseCallback is { } responseCallback)
{
await responseCallback.Invoke(response.Messages).ConfigureAwait(false);
}
return response.Messages.LastOrDefault() ?? new();
}
}
@@ -1,78 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.AI.Agents.Runtime;
using Microsoft.Extensions.Logging;
namespace Microsoft.Agents.Orchestration;
public abstract partial class AgentOrchestration<TInput, TOutput>
{
/// <summary>
/// Actor responsible for receiving final message and transforming it into the output type.
/// </summary>
private sealed class RequestActor : OrchestrationActor
{
private readonly Func<TInput, JsonSerializerOptions?, CancellationToken, ValueTask<IEnumerable<ChatMessage>>> _transform;
private readonly Func<IEnumerable<ChatMessage>, ValueTask> _action;
private readonly TaskCompletionSource<TOutput> _completionSource;
/// <summary>
/// Initializes a new instance of the <see cref="AgentOrchestration{TInput, TOutput}"/> class.
/// </summary>
/// <param name="id">The unique identifier of the agent.</param>
/// <param name="runtime">The runtime associated with the agent.</param>
/// <param name="context">The orchestration context.</param>
/// <param name="transform">A function that transforms an input of type TInput into a source type TSource.</param>
/// <param name="completionSource">Optional TaskCompletionSource to signal orchestration completion.</param>
/// <param name="action">An asynchronous function that processes the resulting source.</param>
/// <param name="logger">The logger to use for the actor</param>
public RequestActor(
ActorId id,
IAgentRuntime runtime,
OrchestrationContext context,
Func<TInput, JsonSerializerOptions?, CancellationToken, ValueTask<IEnumerable<ChatMessage>>> transform,
TaskCompletionSource<TOutput> completionSource,
Func<IEnumerable<ChatMessage>, ValueTask> action,
ILogger<RequestActor>? logger = null)
: base(id, runtime, context, $"{id.Type}_Actor", logger)
{
this._transform = transform;
this._action = action;
this._completionSource = completionSource;
this.RegisterMessageHandler<TInput>(this.HandleAsync);
}
/// <summary>
/// Handles the incoming message by transforming the input and executing the corresponding action asynchronously.
/// </summary>
/// <param name="item">The input message of type TInput.</param>
/// <param name="messageContext">The context of the message, providing additional details.</param>
/// <param name="cancellationToken">A token to cancel the operation if needed.</param>
/// <returns>A ValueTask representing the asynchronous operation.</returns>
private async ValueTask HandleAsync(TInput item, MessageContext messageContext, CancellationToken cancellationToken)
{
this.Logger.LogOrchestrationRequestInvoke(this.Context.Orchestration, this.Id);
try
{
IEnumerable<ChatMessage> input = await this._transform.Invoke(item, messageContext.SerializerOptions, cancellationToken).ConfigureAwait(false);
var task = this._action.Invoke(input);
this.Logger.LogOrchestrationStart(this.Context.Orchestration, this.Id);
await task.ConfigureAwait(false);
}
catch (Exception exception)
{
// Log exception details and allow orchestration to fail
this.Logger.LogOrchestrationRequestFailure(this.Context.Orchestration, this.Id, exception);
this._completionSource.SetException(exception);
throw;
}
}
}
}
@@ -1,83 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.AI.Agents.Runtime;
using Microsoft.Extensions.Logging;
namespace Microsoft.Agents.Orchestration;
public abstract partial class AgentOrchestration<TInput, TOutput>
{
/// <summary>
/// Actor responsible for receiving the resultant message, transforming it, and handling further orchestration.
/// </summary>
private sealed class ResultActor<TResult> : OrchestrationActor
{
private readonly TaskCompletionSource<TOutput> _completionSource;
private readonly Func<TResult, IList<ChatMessage>> _transformResult;
private readonly Func<IList<ChatMessage>, JsonSerializerOptions?, CancellationToken, ValueTask<TOutput>> _transform;
/// <summary>
/// Initializes a new instance of the <see cref="AgentOrchestration{TInput, TOutput}.ResultActor{TResult}"/> class.
/// </summary>
/// <param name="id">The unique identifier of the agent.</param>
/// <param name="runtime">The runtime associated with the agent.</param>
/// <param name="context">The orchestration context.</param>
/// <param name="transformResult">A delegate that transforms a TResult instance into a ChatMessage.</param>
/// <param name="transformOutput">A delegate that transforms a ChatMessage into a TOutput instance.</param>
/// <param name="completionSource">Optional TaskCompletionSource to signal orchestration completion.</param>
/// <param name="logger">The logger to use for the actor</param>
public ResultActor(
ActorId id,
IAgentRuntime runtime,
OrchestrationContext context,
Func<TResult, IList<ChatMessage>> transformResult,
Func<IList<ChatMessage>, JsonSerializerOptions?, CancellationToken, ValueTask<TOutput>> transformOutput,
TaskCompletionSource<TOutput> completionSource,
ILogger<ResultActor<TResult>>? logger = null)
: base(id, runtime, context, $"{id.Type}_Actor", logger)
{
this._completionSource = completionSource;
this._transformResult = transformResult;
this._transform = transformOutput;
this.RegisterMessageHandler<TResult>(this.HandleAsync);
}
/// <summary>
/// Processes the received TResult message by transforming it into a TOutput message.
/// If a CompletionTarget is defined, it sends the transformed message to the corresponding agent.
/// Additionally, it signals completion via the provided TaskCompletionSource if available.
/// </summary>
/// <param name="item">The result item to process.</param>
/// <param name="messageContext">The context associated with the message.</param>
/// <param name="cancellationToken">A token to cancel the operation if needed.</param>
/// <returns>A ValueTask representing asynchronous operation.</returns>
private async ValueTask HandleAsync(TResult item, MessageContext messageContext, CancellationToken cancellationToken)
{
this.Logger.LogOrchestrationResultInvoke(this.Context.Orchestration, this.Id);
try
{
if (!this._completionSource.Task.IsCompleted)
{
IList<ChatMessage> result = this._transformResult.Invoke(item);
TOutput output = await this._transform.Invoke(result, messageContext.SerializerOptions, cancellationToken).ConfigureAwait(false);
this._completionSource.TrySetResult(output);
}
}
catch (Exception exception)
{
// Log exception details and fail orchestration as per design.
this.Logger.LogOrchestrationResultFailure(this.Context.Orchestration, this.Id, exception);
this._completionSource.SetException(exception);
throw;
}
}
}
}
@@ -1,242 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.AI.Agents;
using Microsoft.Extensions.AI.Agents.Runtime;
using Microsoft.Extensions.AI.Agents.Runtime.InProcess;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Shared.Diagnostics;
#pragma warning disable CA2000 // Dispose objects before losing scope
namespace Microsoft.Agents.Orchestration;
/// <summary>
/// Base class for multi-agent agent orchestration patterns.
/// </summary>
/// <typeparam name="TInput">The type of the input to the orchestration.</typeparam>
/// <typeparam name="TOutput">The type of the result output by the orchestration.</typeparam>
public abstract partial class AgentOrchestration<TInput, TOutput>
{
/// <summary>
/// Initializes a new instance of the <see cref="AgentOrchestration{TInput, TOutput}"/> class.
/// </summary>
/// <param name="members">Specifies the member agents or orchestrations participating in this orchestration.</param>
protected AgentOrchestration(params AIAgent[] members)
{
_ = Throw.IfNull(members);
// Capture orchestration root name without generic parameters for use in
// agent type and topic formatting as well as logging.
string name = this.GetType().Name;
int pos = name.IndexOf('`');
if (pos > 0)
{
name = name.Substring(0, pos);
}
this.OrchestrationLabel = name;
this.Members = members;
}
/// <summary>
/// Gets the description of the orchestration.
/// </summary>
public string Description { get; init; } = string.Empty;
/// <summary>
/// Gets the name of the orchestration.
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// Gets the associated logger.
/// </summary>
public ILoggerFactory LoggerFactory { get; init; } = NullLoggerFactory.Instance;
/// <summary>
/// Transforms the orchestration input into a source input suitable for processing.
/// </summary>
public Func<TInput, JsonSerializerOptions?, CancellationToken, ValueTask<IEnumerable<ChatMessage>>>? InputTransform { get; set; }
/// <summary>
/// Transforms the processed result into the final output form.
/// </summary>
public Func<IList<ChatMessage>, JsonSerializerOptions?, CancellationToken, ValueTask<TOutput>>? ResultTransform { get; set; }
/// <summary>
/// Optional callback that is invoked for every agent response.
/// </summary>
public Func<IEnumerable<ChatMessage>, ValueTask>? ResponseCallback { get; set; }
/// <summary>
/// Optional callback that is invoked for every agent update.
/// </summary>
public Func<AgentRunResponseUpdate, ValueTask>? StreamingResponseCallback { get; set; }
/// <summary>
/// Gets the list of member targets involved in the orchestration.
/// </summary>
protected IReadOnlyList<AIAgent> Members { get; }
/// <summary>
/// Orchestration identifier without generic parameters for use in
/// agent type and topic formatting as well as logging.
/// </summary>
protected string OrchestrationLabel { get; }
/// <summary>
/// Initiates processing of the orchestration.
/// </summary>
/// <param name="input">The input message.</param>
/// <param name="runtime">The runtime associated with the orchestration.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
public async ValueTask<OrchestrationResult<TOutput>> InvokeAsync(
TInput input,
IAgentRuntime? runtime = null,
CancellationToken cancellationToken = default)
{
Throw.IfNull(input, nameof(input));
cancellationToken.ThrowIfCancellationRequested();
TopicId topic = new($"{this.OrchestrationLabel}_{Guid.NewGuid():N}");
CancellationTokenSource orchestrationCancelSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cancellationToken = orchestrationCancelSource.Token;
OrchestrationContext context =
new(this.OrchestrationLabel,
topic,
this.ResponseCallback,
this.StreamingResponseCallback,
this.LoggerFactory,
cancellationToken);
ILogger logger = this.LoggerFactory.CreateLogger(this.GetType());
TaskCompletionSource<TOutput> completion = new();
InProcessRuntime? temporaryRuntime = null;
runtime ??= temporaryRuntime = InProcessRuntime.StartNew();
ActorType orchestrationType = await this.RegisterAsync(runtime, context, completion, handoff: null).ConfigureAwait(false);
logger.LogOrchestrationInvoke(this.OrchestrationLabel, topic);
Task task = runtime.PublishMessageAsync(input, orchestrationType, cancellationToken).AsTask();
logger.LogOrchestrationYield(this.OrchestrationLabel, topic);
return new OrchestrationResult<TOutput>(context, completion, orchestrationCancelSource, logger, temporaryRuntime);
}
/// <summary>
/// Initiates processing according to the orchestration pattern.
/// </summary>
/// <param name="runtime">The runtime associated with the orchestration.</param>
/// <param name="topic">The unique identifier for the orchestration session.</param>
/// <param name="input">The input to be transformed and processed.</param>
/// <param name="entryAgent">The initial agent type used for starting the orchestration.</param>
protected abstract ValueTask StartAsync(IAgentRuntime runtime, TopicId topic, IEnumerable<ChatMessage> input, ActorType? entryAgent);
/// <summary>
/// Orchestration specific registration, including members and returns an optional entry agent.
/// </summary>
/// <param name="runtime">The runtime targeted for registration.</param>
/// <param name="context">The orchestration context.</param>
/// <param name="registrar">A registration context.</param>
/// <param name="logger">The logger to use during registration</param>
/// <returns>The entry AgentType for the orchestration, if any.</returns>
protected abstract ValueTask<ActorType?> RegisterOrchestrationAsync(IAgentRuntime runtime, OrchestrationContext context, RegistrationContext registrar, ILogger logger);
/// <summary>
/// Formats and returns a unique AgentType based on the provided topic and suffix.
/// </summary>
/// <param name="topic">The topic identifier used in formatting the agent type.</param>
/// <param name="suffix">A suffix to differentiate the agent type.</param>
/// <returns>A formatted AgentType object.</returns>
protected ActorType FormatAgentType(TopicId topic, string suffix) => new($"{topic.Type}_{suffix}");
/// <summary>
/// Registers the orchestration's root and boot agents, setting up completion and target routing.
/// </summary>
/// <param name="runtime">The runtime targeted for registration.</param>
/// <param name="context">The orchestration context.</param>
/// <param name="completion">A TaskCompletionSource for the orchestration.</param>
/// <param name="handoff">The actor type used for handoff. Only defined for nested orchestrations.</param>
/// <returns>The AgentType representing the orchestration entry point.</returns>
private async ValueTask<ActorType> RegisterAsync(IAgentRuntime runtime, OrchestrationContext context, TaskCompletionSource<TOutput> completion, ActorType? handoff)
{
// Create a logger for the orchestration registration.
ILogger logger = context.LoggerFactory.CreateLogger(this.GetType());
logger.LogOrchestrationRegistrationStart(context.Orchestration, context.Topic);
// Register orchestration
RegistrationContext registrar = new(this.FormatAgentType(context.Topic, "Root"), runtime, context, completion, this.ResultTransform ?? DefaultTransforms.ToOutput<TOutput>);
ActorType? entryAgent = await this.RegisterOrchestrationAsync(runtime, context, registrar, logger).ConfigureAwait(false);
// Register actor for orchestration entry-point
ActorType orchestrationEntry =
await runtime.RegisterOrchestrationAgentAsync(
this.FormatAgentType(context.Topic, "Boot"),
(agentId, runtime) =>
{
RequestActor actor =
new(agentId,
runtime,
context,
this.InputTransform ?? DefaultTransforms.FromInput<TInput>,
completion,
input => this.StartAsync(runtime, context.Topic, input, entryAgent),
context.LoggerFactory.CreateLogger<RequestActor>());
return new ValueTask<IRuntimeActor>(actor);
}).ConfigureAwait(false);
logger.LogOrchestrationRegistrationDone(context.Orchestration, context.Topic);
return orchestrationEntry;
}
/// <summary>
/// A context used during registration (<see cref="RegisterAsync"/>).
/// </summary>
public sealed class RegistrationContext(
ActorType agentType,
IAgentRuntime runtime,
OrchestrationContext context,
TaskCompletionSource<TOutput> completion,
Func<IList<ChatMessage>, JsonSerializerOptions?, CancellationToken, ValueTask<TOutput>> outputTransform)
{
/// <summary>
/// Register the final result type.
/// </summary>
public async ValueTask<ActorType> RegisterResultTypeAsync<TResult>(Func<TResult, IList<ChatMessage>> resultTransform)
{
// Register actor for final result
ActorType registeredType =
await runtime.RegisterOrchestrationAgentAsync(
agentType,
(agentId, runtime) =>
{
ResultActor<TResult> actor =
new(agentId,
runtime,
context,
resultTransform,
outputTransform,
completion,
context.LoggerFactory.CreateLogger<ResultActor<TResult>>());
return new ValueTask<IRuntimeActor>(actor);
}).ConfigureAwait(false);
return registeredType;
}
}
}
@@ -1,146 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.AI.Agents.Runtime;
using Microsoft.Extensions.Logging;
namespace Microsoft.Agents.Orchestration;
/// <summary>
/// Extensions for logging <see cref="AgentOrchestration{TInput, TOutput}"/>.
/// </summary>
/// <remarks>
/// This extension uses the <see cref="LoggerMessageAttribute"/> to
/// generate logging code at compile time to achieve optimized code.
/// </remarks>
[ExcludeFromCodeCoverage]
internal static partial class AgentOrchestrationLogMessages
{
/// <summary>
/// Logs the start of the registration phase for an orchestration.
/// </summary>
[LoggerMessage(
Level = LogLevel.Trace,
Message = "REGISTER {Orchestration} Start: {Topic}")]
public static partial void LogOrchestrationRegistrationStart(
this ILogger logger,
string orchestration,
TopicId topic);
/// <summary>
/// Logs pattern actor registration.
/// </summary>
[LoggerMessage(
Level = LogLevel.Information,
Message = "REGISTER ACTOR {Orchestration} {label}: {AgentType}")]
public static partial void LogRegisterActor(
this ILogger logger,
string orchestration,
ActorType agentType,
string label);
/// <summary>
/// Logs agent actor registration.
/// </summary>
[LoggerMessage(
Level = LogLevel.Information,
Message = "REGISTER ACTOR {Orchestration} {label} #{Count}: {AgentType}")]
public static partial void LogRegisterActor(
this ILogger logger,
string orchestration,
ActorType agentType,
string label,
int count);
/// <summary>
/// Logs the end of the registration phase for an orchestration.
/// </summary>
[LoggerMessage(
Level = LogLevel.Trace,
Message = "REGISTER {Orchestration} Complete: {Topic}")]
public static partial void LogOrchestrationRegistrationDone(
this ILogger logger,
string orchestration,
TopicId topic);
/// <summary>
/// Logs an orchestration invocation
/// </summary>
[LoggerMessage(
Level = LogLevel.Information,
Message = "INVOKE {Orchestration}: {Topic}")]
public static partial void LogOrchestrationInvoke(
this ILogger logger,
string orchestration,
TopicId topic);
/// <summary>
/// Logs that the orchestration has started successfully and
/// yielded control back to the caller.
/// </summary>
[LoggerMessage(
Level = LogLevel.Trace,
Message = "YIELD {Orchestration}: {Topic}")]
public static partial void LogOrchestrationYield(
this ILogger logger,
string orchestration,
TopicId topic);
/// <summary>
/// Logs the start an orchestration (top/outer).
/// </summary>
[LoggerMessage(
Level = LogLevel.Information,
Message = "START {Orchestration}: {AgentId}")]
public static partial void LogOrchestrationStart(
this ILogger logger,
string orchestration,
ActorId agentId);
/// <summary>
/// Logs that orchestration request actor is active
/// </summary>
[LoggerMessage(
Level = LogLevel.Information,
Message = "INIT {Orchestration}: {AgentId}")]
public static partial void LogOrchestrationRequestInvoke(
this ILogger logger,
string orchestration,
ActorId agentId);
/// <summary>
/// Logs that orchestration request actor experienced an unexpected failure.
/// </summary>
[LoggerMessage(
Level = LogLevel.Error,
Message = "FAILURE {Orchestration}: {AgentId}")]
public static partial void LogOrchestrationRequestFailure(
this ILogger logger,
string orchestration,
ActorId agentId,
Exception exception);
/// <summary>
/// Logs that orchestration result actor is active
/// </summary>
[LoggerMessage(
Level = LogLevel.Information,
Message = "EXIT {Orchestration}: {AgentId}")]
public static partial void LogOrchestrationResultInvoke(
this ILogger logger,
string orchestration,
ActorId agentId);
/// <summary>
/// Logs that orchestration result actor experienced an unexpected failure.
/// </summary>
[LoggerMessage(
Level = LogLevel.Error,
Message = "FAILURE {Orchestration}: {AgentId}")]
public static partial void LogOrchestrationResultFailure(
this ILogger logger,
string orchestration,
ActorId agentId,
Exception exception);
}
@@ -1,58 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Extension methods for <see cref="IAgentRuntime"/>.
/// </summary>
internal static class AgentRuntimeExtensions
{
/// <summary>
/// Sends a message to the specified agent.
/// </summary>
public static ValueTask PublishMessageAsync(this IAgentRuntime runtime, object message, ActorType agentType, CancellationToken cancellationToken = default) =>
runtime.PublishMessageAsync(message, new TopicId(agentType.Name), sender: null, messageId: null, cancellationToken);
/// <summary>
/// Registers an agent factory for the specified agent type and associates it with the runtime.
/// </summary>
/// <param name="runtime">The runtime targeted for registration.</param>
/// <param name="agentType">The type of agent to register.</param>
/// <param name="factoryFunc">The factory function for creating the agent.</param>
/// <returns>The registered agent type.</returns>
public static async ValueTask<ActorType> RegisterOrchestrationAgentAsync(this IAgentRuntime runtime, ActorType agentType, Func<ActorId, IAgentRuntime, ValueTask<IRuntimeActor>> factoryFunc)
{
ActorType registeredType = await runtime.RegisterActorFactoryAsync(agentType, factoryFunc).ConfigureAwait(false);
// Subscribe agent to its own unique topic
await runtime.SubscribeAsync(new(registeredType.Name)).ConfigureAwait(false);
return registeredType;
}
/// <summary>
/// Subscribes the specified agent type to its own dedicated topic.
/// </summary>
/// <param name="runtime">The runtime for managing the subscription.</param>
/// <param name="agentType">The agent type to subscribe.</param>
public static Task SubscribeAsync(this IAgentRuntime runtime, ActorType agentType) =>
runtime.AddSubscriptionAsync(new TypeSubscription(agentType.Name, agentType)).AsTask();
/// <summary>
/// Subscribes the specified agent type to the provided topics.
/// </summary>
/// <param name="runtime">The runtime for managing the subscription.</param>
/// <param name="agentType">The agent type to subscribe.</param>
/// <param name="topics">A variable list of topics for subscription.</param>
public static async Task SubscribeAsync(this IAgentRuntime runtime, ActorType agentType, params TopicId[] topics)
{
for (int index = 0; index < topics.Length; ++index)
{
await runtime.AddSubscriptionAsync(new TypeSubscription(topics[index].Type, agentType)).ConfigureAwait(false);
}
}
}
@@ -1,46 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.AI.Agents;
using Microsoft.Extensions.AI.Agents.Runtime;
using Microsoft.Extensions.Logging;
namespace Microsoft.Agents.Orchestration;
/// <summary>
/// An <see cref="AgentActor"/> used with the <see cref="ConcurrentOrchestration{TInput, TOutput}"/>.
/// </summary>
internal sealed class ConcurrentActor : AgentActor
{
private readonly ActorType _handoffActor;
/// <summary>
/// Initializes a new instance of the <see cref="ConcurrentActor"/> class.
/// </summary>
/// <param name="id">The unique identifier of the agent.</param>
/// <param name="runtime">The runtime associated with the agent.</param>
/// <param name="context">The orchestration context.</param>
/// <param name="agent">An <see cref="AIAgent"/>.</param>
/// <param name="resultActor">Identifies the actor collecting results.</param>
/// <param name="logger">The logger to use for the actor</param>
public ConcurrentActor(ActorId id, IAgentRuntime runtime, OrchestrationContext context, AIAgent agent, ActorType resultActor, ILogger<ConcurrentActor>? logger = null)
: base(id, runtime, context, agent, logger)
{
this._handoffActor = resultActor;
this.RegisterMessageHandler<ConcurrentMessages.Request>(this.HandleAsync);
}
private async ValueTask HandleAsync(ConcurrentMessages.Request item, MessageContext messageContext, CancellationToken cancellationToken)
{
this.Logger.LogConcurrentAgentInvoke(this.Id);
ChatMessage response = await this.RunAsync(item.Messages, cancellationToken).ConfigureAwait(false);
this.Logger.LogConcurrentAgentResult(this.Id, response.Text);
await this.PublishMessageAsync(new ConcurrentMessages.Result(response), this._handoffActor, cancellationToken).ConfigureAwait(false);
}
}
@@ -1,22 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.Orchestration;
/// <summary>
/// Common messages used by the <see cref="ConcurrentOrchestration{TInput, TOutput}"/>.
/// </summary>
internal static class ConcurrentMessages
{
/// <summary>
/// The input task for a <see cref="ConcurrentOrchestration{TInput, TOutput}"/>.
/// </summary>
public sealed record Request(IList<ChatMessage> Messages);
/// <summary>
/// A result from a <see cref="ConcurrentOrchestration{TInput, TOutput}"/>.
/// </summary>
public sealed record Result(ChatMessage Message);
}
@@ -1,29 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.AI.Agents;
namespace Microsoft.Agents.Orchestration;
/// <summary>
/// An orchestration that broadcasts the input message to each agent.
/// </summary>
public sealed class ConcurrentOrchestration : ConcurrentOrchestration<string, string[]>
{
/// <summary>
/// Initializes a new instance of the <see cref="ConcurrentOrchestration"/> class.
/// </summary>
/// <param name="members">The agents to be orchestrated.</param>
public ConcurrentOrchestration(params AIAgent[] members)
: base(members)
{
this.ResultTransform =
(response, _, cancellationToken) =>
{
string[] result = [.. response.Select(r => r.Text)];
return new ValueTask<string[]>(result);
};
}
}
@@ -1,75 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.AI.Agents;
using Microsoft.Extensions.AI.Agents.Runtime;
using Microsoft.Extensions.Logging;
namespace Microsoft.Agents.Orchestration;
/// <summary>
/// An orchestration that broadcasts the input message to each agent.
/// </summary>
/// <remarks>
/// <c>TOutput</c> must be an array type for <see cref="ConcurrentOrchestration"/>.
/// </remarks>
public class ConcurrentOrchestration<TInput, TOutput>
: AgentOrchestration<TInput, TOutput>
{
/// <summary>
/// Initializes a new instance of the <see cref="ConcurrentOrchestration{TInput, TOutput}"/> class.
/// </summary>
/// <param name="agents">The agents participating in the orchestration.</param>
public ConcurrentOrchestration(params AIAgent[] agents)
: base(agents)
{
}
/// <inheritdoc />
protected override ValueTask StartAsync(IAgentRuntime runtime, TopicId topic, IEnumerable<ChatMessage> input, ActorType? entryAgent)
{
return runtime.PublishMessageAsync(new ConcurrentMessages.Request([.. input]), topic);
}
/// <inheritdoc />
protected override async ValueTask<ActorType?> RegisterOrchestrationAsync(IAgentRuntime runtime, OrchestrationContext context, RegistrationContext registrar, ILogger logger)
{
ActorType outputType = await registrar.RegisterResultTypeAsync<ConcurrentMessages.Result[]>(response => [.. response.Select(r => r.Message)]).ConfigureAwait(false);
// Register result actor
ActorType resultType = this.FormatAgentType(context.Topic, "Results");
await runtime.RegisterOrchestrationAgentAsync(
resultType,
async (agentId, runtime) =>
{
ConcurrentResultActor actor = new(agentId, runtime, context, outputType, this.Members.Count, context.LoggerFactory.CreateLogger<ConcurrentResultActor>());
return actor;
}).ConfigureAwait(false);
logger.LogRegisterActor(this.OrchestrationLabel, resultType, "RESULTS");
// Register member actors - All agents respond to the same message.
int agentCount = 0;
foreach (AIAgent agent in this.Members)
{
++agentCount;
ActorType agentType =
await runtime.RegisterActorFactoryAsync(
this.FormatAgentType(context.Topic, $"Agent_{agentCount}"),
(agentId, runtime) =>
{
ConcurrentActor actor = new(agentId, runtime, context, agent, resultType, context.LoggerFactory.CreateLogger<ConcurrentActor>());
return new ValueTask<IRuntimeActor>(actor);
}).ConfigureAwait(false);
logger.LogRegisterActor(this.OrchestrationLabel, agentType, "MEMBER", agentCount);
await runtime.SubscribeAsync(agentType, context.Topic).ConfigureAwait(false);
}
return null;
}
}
@@ -1,45 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.AI.Agents.Runtime;
using Microsoft.Extensions.Logging;
namespace Microsoft.Agents.Orchestration;
/// <summary>
/// Extensions for logging <see cref="ConcurrentOrchestration{TInput, TOutput}"/>.
/// </summary>
/// <remarks>
/// This extension uses the <see cref="LoggerMessageAttribute"/> to
/// generate logging code at compile time to achieve optimized code.
/// </remarks>
[ExcludeFromCodeCoverage]
internal static partial class ConcurrentOrchestrationLogMessages
{
[LoggerMessage(
Level = LogLevel.Trace,
Message = "REQUEST Concurrent agent [{AgentId}]")]
public static partial void LogConcurrentAgentInvoke(
this ILogger logger,
ActorId agentId);
[LoggerMessage(
Level = LogLevel.Trace,
Message = "RESULT Concurrent agent [{AgentId}]: {Message}")]
public static partial void LogConcurrentAgentResult(
this ILogger logger,
ActorId agentId,
string? message);
/// <summary>
/// Logs result capture.
/// </summary>
[LoggerMessage(
Level = LogLevel.Information,
Message = "COLLECT Concurrent result [{AgentId}]: #{ResultCount} / {ExpectedCount}")]
public static partial void LogConcurrentResultCapture(
this ILogger logger,
ActorId agentId,
int resultCount,
int expectedCount);
}
@@ -1,57 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI.Agents.Runtime;
using Microsoft.Extensions.Logging;
namespace Microsoft.Agents.Orchestration;
/// <summary>
/// Actor for capturing each <see cref="ConcurrentMessages.Result"/> message.
/// </summary>
internal sealed class ConcurrentResultActor : OrchestrationActor
{
private readonly ConcurrentQueue<ConcurrentMessages.Result> _results;
private readonly ActorType _orchestrationType;
private readonly int _expectedCount;
private int _resultCount;
/// <summary>
/// Initializes a new instance of the <see cref="ConcurrentResultActor"/> class.
/// </summary>
/// <param name="id">The unique identifier of the agent.</param>
/// <param name="runtime">The runtime associated with the agent.</param>
/// <param name="context">The orchestration context.</param>
/// <param name="orchestrationType">Identifies the orchestration agent.</param>
/// <param name="expectedCount">The expected number of messages to be received.</param>
/// <param name="logger">The logger to use for the actor</param>
public ConcurrentResultActor(
ActorId id,
IAgentRuntime runtime,
OrchestrationContext context,
ActorType orchestrationType,
int expectedCount,
ILogger logger)
: base(id, runtime, context, "Captures the results of the ConcurrentOrchestration", logger)
{
this._orchestrationType = orchestrationType;
this._expectedCount = expectedCount;
this._results = [];
this.RegisterMessageHandler<ConcurrentMessages.Result>(this.HandleAsync);
}
private async ValueTask HandleAsync(ConcurrentMessages.Result item, MessageContext messageContext, CancellationToken cancellationToken)
{
this.Logger.LogConcurrentResultCapture(this.Id, this._resultCount + 1, this._expectedCount);
this._results.Enqueue(item);
if (Interlocked.Increment(ref this._resultCount) == this._expectedCount)
{
await this.PublishMessageAsync(this._results.ToArray(), this._orchestrationType, cancellationToken).ConfigureAwait(false);
}
}
}
@@ -0,0 +1,96 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.AI.Agents;
namespace Microsoft.Agents.Orchestration;
/// <summary>Provides an orchestrating agent that broadcasts the input message to each agent and then aggregates the result into a single response.</summary>
public partial class ConcurrentOrchestration : OrchestratingAgent
{
private Func<AgentRunResponse[], CancellationToken, Task<AgentRunResponse>>? _aggregationFunc;
/// <summary>Initializes a new instance of the <see cref="ConcurrentOrchestration"/> class.</summary>
/// <param name="subagents">The agents participating in the orchestration.</param>
public ConcurrentOrchestration(params AIAgent[] subagents) : base(subagents)
{
}
/// <summary>Gets or sets the function to use to aggregate an <see cref="AgentRunResponse"/> from each participating agent into a single <see cref="AgentRunResponse"/>.</summary>
/// <remarks>The default function takes the last message from each response and puts those messages into a new response instance.</remarks>
public Func<AgentRunResponse[], CancellationToken, Task<AgentRunResponse>> AggregationFunc
{
get
{
if (this._aggregationFunc is { } f)
{
return f;
}
return static async (responses, cancellationToken) =>
new AgentRunResponse([.. responses.Where(r => r.Messages.Count > 0).Select(r =>
{
var messages = r.Messages;
return messages.Count > 0 ? messages[messages.Count - 1] : new();
})]);
}
set => this._aggregationFunc = value;
}
/// <inheritdoc />
protected override Task<AgentRunResponse> RunCoreAsync(IReadOnlyCollection<ChatMessage> messages, OrchestratingAgentContext context, CancellationToken cancellationToken) =>
this.ResumeAsync(messages, new AgentRunResponse?[this.Agents.Count], context, cancellationToken);
/// <inheritdoc />
protected override Task<AgentRunResponse> ResumeCoreAsync(JsonElement checkpointState, OrchestratingAgentContext context, CancellationToken cancellationToken)
{
var state = checkpointState.Deserialize(OrchestrationJsonContext.Default.ConcurrentState) ?? throw new InvalidOperationException("The checkpoint state is invalid.");
return this.ResumeAsync(state.Messages, state.Completed, context, cancellationToken);
}
/// <inheritdoc />
private async Task<AgentRunResponse> ResumeAsync(
IReadOnlyCollection<ChatMessage> input, AgentRunResponse?[] completed, OrchestratingAgentContext context, CancellationToken cancellationToken)
{
List<Task> tasks = new(this.Agents.Count);
for (int i = 0; i < this.Agents.Count; i++)
{
if (completed[i] is null)
{
int localI = i;
tasks.Add(Task.Run(async () =>
{
AIAgent agent = this.Agents[localI];
this.LogOrchestrationSubagentRunning(context, agent);
completed[localI] = await RunAsync(agent, context, input, options: null, cancellationToken).ConfigureAwait(false);
this.LogOrchestrationSubagentCompleted(context, agent);
await this.CheckpointAsync(input, completed, context, cancellationToken).ConfigureAwait(false);
}, cancellationToken));
}
}
// TODO: What do we want to do if one of the agents fails? As written, this waits for all to complete,
// and then throws. And when resumption happens, it'll end up retrying failed agents. If we don't want that,
// which we probably don't, we should checkpoint that failures happened, too.
await Task.WhenAll(tasks).ConfigureAwait(false);
Debug.Assert(Array.TrueForAll(completed, r => r is not null), "Expected all agents to have produced a result");
return await this.AggregationFunc(completed!, cancellationToken).ConfigureAwait(false);
}
private Task CheckpointAsync(IReadOnlyCollection<ChatMessage> messages, AgentRunResponse?[] completed, OrchestratingAgentContext context, CancellationToken cancellationToken) =>
context.Runtime is not null ? base.WriteCheckpointAsync(JsonSerializer.SerializeToElement(new(messages, completed), OrchestrationJsonContext.Default.ConcurrentState), context, cancellationToken) :
Task.CompletedTask;
internal sealed record ConcurrentState(IReadOnlyCollection<ChatMessage> Messages, AgentRunResponse?[] Completed);
}
@@ -1,63 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.AI.Agents;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.Orchestration;
internal static class DefaultTransforms
{
public static ValueTask<IEnumerable<ChatMessage>> FromInput<TInput>(TInput input, JsonSerializerOptions? serializerOptions = null, CancellationToken cancellationToken = default)
{
serializerOptions ??= AgentAbstractionsJsonUtilities.DefaultOptions;
return new(input switch
{
IEnumerable<ChatMessage> messages => messages,
ChatMessage message => [message],
string text => [new ChatMessage(ChatRole.User, text)],
_ => [new ChatMessage(ChatRole.User, JsonSerializer.Serialize(input, serializerOptions.GetTypeInfo(typeof(TInput))))]
});
}
public static ValueTask<TOutput> ToOutput<TOutput>(IList<ChatMessage> result, JsonSerializerOptions? serializerOptions = null, CancellationToken cancellationToken = default)
{
Throw.IfNull(result);
serializerOptions ??= AgentAbstractionsJsonUtilities.DefaultOptions;
bool isSingleResult = result.Count == 1;
if (result is TOutput)
{
return new((TOutput)(object)result);
}
if (isSingleResult)
{
if (typeof(ChatMessage).IsAssignableFrom(typeof(TOutput)))
{
return new((TOutput)(object)result[0]);
}
if (typeof(string) == typeof(TOutput))
{
return new((TOutput)(object)(result[0].Text ?? string.Empty));
}
try
{
return new((TOutput)JsonSerializer.Deserialize(result[0].Text, serializerOptions.GetTypeInfo(typeof(TOutput)))!);
}
catch (JsonException)
{
}
}
throw new InvalidOperationException($"Unable to transform output to {typeof(TOutput)}.");
}
}
@@ -1,49 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.AI.Agents;
using Microsoft.Extensions.AI.Agents.Runtime;
using Microsoft.Extensions.Logging;
namespace Microsoft.Agents.Orchestration;
/// <summary>
/// An <see cref="AgentActor"/> used with the <see cref="GroupChatOrchestration{TInput, TOutput}"/>.
/// </summary>
internal sealed class GroupChatAgentActor : AgentActor
{
private readonly List<ChatMessage> _cache;
/// <summary>
/// Initializes a new instance of the <see cref="GroupChatAgentActor"/> class.
/// </summary>
/// <param name="id">The unique identifier of the agent.</param>
/// <param name="runtime">The runtime associated with the agent.</param>
/// <param name="context">The orchestration context.</param>
/// <param name="agent">An <see cref="AIAgent"/>.</param>
/// <param name="logger">The logger to use for the actor</param>
public GroupChatAgentActor(ActorId id, IAgentRuntime runtime, OrchestrationContext context, AIAgent agent, ILogger<GroupChatAgentActor>? logger = null)
: base(id, runtime, context, agent, logger)
{
this._cache = [];
this.RegisterMessageHandler<GroupChatMessages.Group>((item, ctx) => this._cache.AddRange(item.Messages));
this.RegisterMessageHandler<GroupChatMessages.Reset>((item, ctx) => this.ResetThread());
this.RegisterMessageHandler<GroupChatMessages.Speak>(this.HandleAsync);
}
private async ValueTask HandleAsync(GroupChatMessages.Speak item, MessageContext messageContext, CancellationToken cancellationToken)
{
this.Logger.LogChatAgentInvoke(this.Id);
ChatMessage response = await this.RunAsync(this._cache, cancellationToken).ConfigureAwait(false);
this.Logger.LogChatAgentResult(this.Id, response.Text);
this._cache.Clear();
await this.PublishMessageAsync(new GroupChatMessages.Group([response]), this.Context.Topic, cancellationToken: cancellationToken).ConfigureAwait(false);
}
}
@@ -1,100 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.AI.Agents.Runtime;
using Microsoft.Extensions.Logging;
namespace Microsoft.Agents.Orchestration;
/// <summary>
/// An <see cref="OrchestrationActor"/> used to manage a <see cref="GroupChatOrchestration{TInput, TOutput}"/>.
/// </summary>
internal sealed class GroupChatManagerActor : OrchestrationActor
{
/// <summary>
/// A common description for the manager.
/// </summary>
public const string DefaultDescription = "Orchestrates a team of agents to accomplish a defined task.";
private readonly ActorType _orchestrationType;
private readonly GroupChatManager _manager;
private readonly List<ChatMessage> _chat;
private readonly GroupChatTeam _team;
/// <summary>
/// Initializes a new instance of the <see cref="GroupChatManagerActor"/> class.
/// </summary>
/// <param name="id">The unique identifier of the agent.</param>
/// <param name="runtime">The runtime associated with the agent.</param>
/// <param name="context">The orchestration context.</param>
/// <param name="manager">The manages the flow of the group-chat.</param>
/// <param name="team">The team of agents being orchestrated</param>
/// <param name="orchestrationType">Identifies the orchestration agent.</param>
/// <param name="logger">The logger to use for the actor</param>
public GroupChatManagerActor(ActorId id, IAgentRuntime runtime, OrchestrationContext context, GroupChatManager manager, GroupChatTeam team, ActorType orchestrationType, ILogger? logger = null)
: base(id, runtime, context, DefaultDescription, logger)
{
this._chat = [];
this._manager = manager;
this._orchestrationType = orchestrationType;
this._team = team;
this.RegisterMessageHandler<GroupChatMessages.InputTask>(this.HandleAsync);
this.RegisterMessageHandler<GroupChatMessages.Group>(this.HandleAsync);
}
private async ValueTask HandleAsync(GroupChatMessages.InputTask item, MessageContext messageContext, CancellationToken cancellationToken)
{
this.Logger.LogChatManagerInit(this.Id);
this._chat.AddRange(item.Messages);
await this.PublishMessageAsync(new GroupChatMessages.Group(item.Messages), this.Context.Topic, cancellationToken: cancellationToken).ConfigureAwait(false);
await this.ManageAsync(messageContext, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc/>
private async ValueTask HandleAsync(GroupChatMessages.Group item, MessageContext messageContext, CancellationToken cancellationToken)
{
this.Logger.LogChatManagerInvoke(this.Id);
this._chat.AddRange(item.Messages);
await this.ManageAsync(messageContext, cancellationToken).ConfigureAwait(false);
}
private async ValueTask ManageAsync(MessageContext messageContext, CancellationToken cancellationToken)
{
if (this._manager.InteractiveCallback != null)
{
GroupChatManagerResult<bool> inputResult = await this._manager.ShouldRequestUserInput(this._chat, cancellationToken).ConfigureAwait(false);
this.Logger.LogChatManagerInput(this.Id, inputResult.Value, inputResult.Reason);
if (inputResult.Value)
{
ChatMessage input = await this._manager.InteractiveCallback.Invoke().ConfigureAwait(false);
this.Logger.LogChatManagerUserInput(this.Id, input.Text);
this._chat.Add(input);
await this.PublishMessageAsync(new GroupChatMessages.Group([input]), this.Context.Topic, cancellationToken: cancellationToken).ConfigureAwait(false);
}
}
GroupChatManagerResult<bool> terminateResult = await this._manager.ShouldTerminate(this._chat, cancellationToken).ConfigureAwait(false);
this.Logger.LogChatManagerTerminate(this.Id, terminateResult.Value, terminateResult.Reason);
if (terminateResult.Value)
{
GroupChatManagerResult<string> filterResult = await this._manager.FilterResults(this._chat, cancellationToken).ConfigureAwait(false);
this.Logger.LogChatManagerResult(this.Id, filterResult.Value, filterResult.Reason);
await this.PublishMessageAsync(new GroupChatMessages.Result(new(ChatRole.Assistant, filterResult.Value)), this._orchestrationType, cancellationToken).ConfigureAwait(false);
return;
}
GroupChatManagerResult<string> selectionResult = await this._manager.SelectNextAgent(this._chat, this._team, cancellationToken).ConfigureAwait(false);
ActorType selectionType = new(this._team[selectionResult.Value].Type);
this.Logger.LogChatManagerSelect(this.Id, selectionType);
await this.PublishMessageAsync(new GroupChatMessages.Speak(), selectionType, cancellationToken: cancellationToken).ConfigureAwait(false);
}
}
@@ -1,43 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.Orchestration;
/// <summary>
/// Common messages used for agent chat patterns.
/// </summary>
internal static class GroupChatMessages
{
/// <summary>
/// Broadcast a message to all <see cref="GroupChatAgentActor"/>.
/// </summary>
public sealed record Group(IEnumerable<ChatMessage> Messages);
/// <summary>
/// Reset/clear the conversation history for all <see cref="GroupChatAgentActor"/>.
/// </summary>
public sealed class Reset;
/// <summary>
/// The final result.
/// </summary>
public sealed record Result(ChatMessage Message);
/// <summary>
/// Signal a <see cref="GroupChatAgentActor"/> to respond.
/// </summary>
public sealed class Speak;
/// <summary>
/// The input task.
/// </summary>
public sealed record InputTask(IEnumerable<ChatMessage> Messages)
{
/// <summary>
/// Gets an input task that does not require any action.
/// </summary>
public static InputTask None { get; } = new([]);
}
}
@@ -1,21 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.AI.Agents;
namespace Microsoft.Agents.Orchestration;
/// <summary>
/// An orchestration that broadcasts the input message to each agent.
/// </summary>
public sealed class GroupChatOrchestration : GroupChatOrchestration<string, string>
{
/// <summary>
/// Initializes a new instance of the <see cref="GroupChatOrchestration"/> class.
/// </summary>
/// <param name="manager">The manages the flow of the group-chat.</param>
/// <param name="members">The agents to be orchestrated.</param>
public GroupChatOrchestration(GroupChatManager manager, params AIAgent[] members)
: base(manager, members)
{
}
}
@@ -1,91 +1,116 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.AI.Agents;
using Microsoft.Extensions.AI.Agents.Runtime;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.Orchestration;
/// <summary>
/// An orchestration that coordinates a group-chat.
/// An orchestration that coordinates a group-chat using a manager to control conversation flow.
/// </summary>
public class GroupChatOrchestration<TInput, TOutput> :
AgentOrchestration<TInput, TOutput>
public sealed partial class GroupChatOrchestration : OrchestratingAgent
{
internal const string DefaultAgentDescription = "A helpful agent.";
private readonly GroupChatManager _manager;
/// <summary>
/// Initializes a new instance of the <see cref="GroupChatOrchestration{TInput, TOutput}"/> class.
/// Initializes a new instance of the <see cref="GroupChatOrchestration"/> class.
/// </summary>
/// <param name="manager">The manages the flow of the group-chat.</param>
/// <param name="manager">The manager that controls the flow of the group-chat.</param>
/// <param name="agents">The agents participating in the orchestration.</param>
public GroupChatOrchestration(GroupChatManager manager, params AIAgent[] agents)
: base(agents)
public GroupChatOrchestration(GroupChatManager manager, params AIAgent[] agents) : base(agents)
{
Throw.IfNull(manager, nameof(manager));
this._manager = Throw.IfNull(manager);
}
this._manager = manager;
/// <summary>Gets or sets a callback invoked when user input is requested.</summary>
public Func<ValueTask<ChatMessage>>? InteractiveCallback { get; set; }
/// <inheritdoc />
protected override Task<AgentRunResponse> RunCoreAsync(IReadOnlyCollection<ChatMessage> messages, OrchestratingAgentContext context, CancellationToken cancellationToken)
{
List<ChatMessage> allMessages = [.. messages];
int originalMessageCount = allMessages.Count;
return this.ResumeAsync(allMessages, originalMessageCount, context, cancellationToken);
}
/// <inheritdoc />
protected override ValueTask StartAsync(IAgentRuntime runtime, TopicId topic, IEnumerable<ChatMessage> input, ActorType? entryAgent)
protected override Task<AgentRunResponse> ResumeCoreAsync(JsonElement checkpointState, OrchestratingAgentContext context, CancellationToken cancellationToken)
{
if (!entryAgent.HasValue)
{
Throw.ArgumentException(nameof(entryAgent), "Entry agent is not defined.");
}
return runtime.PublishMessageAsync(new GroupChatMessages.InputTask(input), entryAgent.Value);
var state = checkpointState.Deserialize(OrchestrationJsonContext.Default.GroupChatState) ?? throw new InvalidOperationException("The checkpoint state is invalid.");
return this.ResumeAsync(state.AllMessages, state.OriginalMessageCount, context, cancellationToken);
}
/// <inheritdoc />
protected override async ValueTask<ActorType?> RegisterOrchestrationAsync(IAgentRuntime runtime, OrchestrationContext context, RegistrationContext registrar, ILogger logger)
private async Task<AgentRunResponse> ResumeAsync(
List<ChatMessage> allMessages, int originalMessageCount, OrchestratingAgentContext context, CancellationToken cancellationToken)
{
ActorType outputType = await registrar.RegisterResultTypeAsync<GroupChatMessages.Result>(response => [response.Message]).ConfigureAwait(false);
int agentCount = 0;
GroupChatTeam team = [];
foreach (AIAgent agent in this.Members)
foreach (AIAgent agent in this.Agents)
{
++agentCount;
ActorType agentType = await RegisterAgentAsync(agent, agentCount).ConfigureAwait(false);
string name = agent.Name ?? agent.Id ?? agentType.Name;
string? description = agent.Description;
team[name] = (agentType.Name, description ?? DefaultAgentDescription);
logger.LogRegisterActor(this.OrchestrationLabel, agentType, "MEMBER", agentCount);
await runtime.SubscribeAsync(agentType, context.Topic).ConfigureAwait(false);
team[agent.DisplayName] = (agent.GetType().Name, agent.Description ?? agent.Name ?? "A helpful agent.");
}
ActorType managerType =
await runtime.RegisterOrchestrationAgentAsync(
this.FormatAgentType(context.Topic, "Manager"),
(agentId, runtime) =>
var interactiveCallback = this.InteractiveCallback ?? this._manager.InteractiveCallback;
while (true)
{
// First, check if we should request user input.
if (interactiveCallback is not null)
{
var userInputResult = await this._manager.ShouldRequestUserInput(allMessages, cancellationToken).ConfigureAwait(false);
if (userInputResult.Value)
{
GroupChatManagerActor actor = new(agentId, runtime, context, this._manager, team, outputType, context.LoggerFactory.CreateLogger<GroupChatManagerActor>());
return new ValueTask<IRuntimeActor>(actor);
}).ConfigureAwait(false);
logger.LogRegisterActor(this.OrchestrationLabel, managerType, "MANAGER");
if (interactiveCallback is not null)
{
ChatMessage userMessage = await interactiveCallback().ConfigureAwait(false);
allMessages.Add(userMessage);
await runtime.SubscribeAsync(managerType, context.Topic).ConfigureAwait(false);
// Broadcast the user input
if (this.ResponseCallback is not null)
{
await this.ResponseCallback([userMessage]).ConfigureAwait(false);
}
return managerType;
await this.CheckpointAsync(allMessages, originalMessageCount, context, cancellationToken).ConfigureAwait(false);
continue;
}
}
}
ValueTask<ActorType> RegisterAgentAsync(AIAgent agent, int agentCount) =>
runtime.RegisterOrchestrationAgentAsync(
this.FormatAgentType(context.Topic, $"Agent_{agentCount}"),
(agentId, runtime) =>
{
GroupChatAgentActor actor = new(agentId, runtime, context, agent, context.LoggerFactory.CreateLogger<GroupChatAgentActor>());
return new ValueTask<IRuntimeActor>(actor);
});
// Check if we should terminate the conversation
var terminateResult = await this._manager.ShouldTerminate(allMessages, cancellationToken).ConfigureAwait(false);
if (terminateResult.Value)
{
// Filter and return final results
var filterResult = await this._manager.FilterResults(allMessages, cancellationToken).ConfigureAwait(false);
return new AgentRunResponse([new ChatMessage(ChatRole.Assistant, filterResult.Value) { AuthorName = this.DisplayName }]);
}
// Select the next agent to speak
var nextAgentResult = await this._manager.SelectNextAgent(allMessages, team, cancellationToken).ConfigureAwait(false);
AIAgent nextAgent = this.FindAgentByName(nextAgentResult.Value) ??
throw new InvalidOperationException($"AIAgent '{nextAgentResult.Value}' not found in the orchestration.");
// Run the selected agent with all messages.
this.LogOrchestrationSubagentRunning(context, nextAgent);
AgentRunResponse response = await RunAsync(nextAgent, context, allMessages, options: null, cancellationToken).ConfigureAwait(false);
allMessages.AddRange(response.Messages); // Add the agent's response to the conversation.
this.LogOrchestrationSubagentCompleted(context, nextAgent);
await this.CheckpointAsync(allMessages, originalMessageCount, context, cancellationToken).ConfigureAwait(false);
}
}
private AIAgent? FindAgentByName(string name) => this.Agents.FirstOrDefault(a => a.DisplayName == name);
private Task CheckpointAsync(List<ChatMessage> allMessages, int originalMessageCount, OrchestratingAgentContext context, CancellationToken cancellationToken) =>
context.Runtime is not null ? base.WriteCheckpointAsync(JsonSerializer.SerializeToElement(new(allMessages, originalMessageCount), OrchestrationJsonContext.Default.GroupChatState), context, cancellationToken) :
Task.CompletedTask;
internal sealed record GroupChatState(List<ChatMessage> AllMessages, int OriginalMessageCount);
}
@@ -1,90 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.AI.Agents.Runtime;
using Microsoft.Extensions.Logging;
namespace Microsoft.Agents.Orchestration;
/// <summary>
/// Extensions for logging <see cref="GroupChatOrchestration{TInput, TOutput}"/>.
/// </summary>
/// <remarks>
/// This extension uses the <see cref="LoggerMessageAttribute"/> to
/// generate logging code at compile time to achieve optimized code.
/// </remarks>
[ExcludeFromCodeCoverage]
internal static partial class GroupChatOrchestrationLogMessages
{
[LoggerMessage(
Level = LogLevel.Trace,
Message = "CHAT AGENT invoked [{AgentId}]")]
public static partial void LogChatAgentInvoke(
this ILogger logger,
ActorId agentId);
[LoggerMessage(
Level = LogLevel.Trace,
Message = "CHAT AGENT result [{AgentId}]: {Message}")]
public static partial void LogChatAgentResult(
this ILogger logger,
ActorId agentId,
string? message);
[LoggerMessage(
Level = LogLevel.Debug,
Message = "CHAT MANAGER initialized [{AgentId}]")]
public static partial void LogChatManagerInit(
this ILogger logger,
ActorId agentId);
[LoggerMessage(
Level = LogLevel.Debug,
Message = "CHAT MANAGER invoked [{AgentId}]")]
public static partial void LogChatManagerInvoke(
this ILogger logger,
ActorId agentId);
[LoggerMessage(
Level = LogLevel.Debug,
Message = "CHAT MANAGER terminate? [{AgentId}]: {Result} ({Reason})")]
public static partial void LogChatManagerTerminate(
this ILogger logger,
ActorId agentId,
bool result,
string reason);
[LoggerMessage(
Level = LogLevel.Debug,
Message = "CHAT MANAGER select: {NextAgent} [{AgentId}]")]
public static partial void LogChatManagerSelect(
this ILogger logger,
ActorId agentId,
ActorType nextAgent);
[LoggerMessage(
Level = LogLevel.Debug,
Message = "CHAT MANAGER result [{AgentId}]: '{Result}' ({Reason})")]
public static partial void LogChatManagerResult(
this ILogger logger,
ActorId agentId,
string result,
string reason);
[LoggerMessage(
Level = LogLevel.Debug,
Message = "CHAT MANAGER user-input? [{AgentId}]: {Result} ({Reason})")]
public static partial void LogChatManagerInput(
this ILogger logger,
ActorId agentId,
bool result,
string reason);
[LoggerMessage(
Level = LogLevel.Trace,
Message = "CHAT AGENT user-input [{AgentId}]: {Message}")]
public static partial void LogChatManagerUserInput(
this ILogger logger,
ActorId agentId,
string? message);
}
@@ -1,194 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.AI.Agents;
using Microsoft.Extensions.AI.Agents.Runtime;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.Orchestration;
/// <summary>
/// An actor used with the <see cref="HandoffOrchestration{TInput,TOutput}"/>.
/// </summary>
internal sealed partial class HandoffActor : AgentActor
{
private readonly ChatClientAgent _chatAgent;
private readonly HandoffLookup _handoffs;
private readonly ActorType _resultHandoff;
private readonly List<ChatMessage> _cache;
private readonly ChatOptions _options;
private string? _handoffAgent;
private string? _taskSummary;
/// <summary>
/// Initializes a new instance of the <see cref="HandoffActor"/> class.
/// </summary>
/// <param name="id">The unique identifier of the agent.</param>
/// <param name="runtime">The runtime associated with the agent.</param>
/// <param name="context">The orchestration context.</param>
/// <param name="agent">An <see cref="AIAgent"/>.</param>>
/// <param name="handoffs">The handoffs available to this agent</param>
/// <param name="resultHandoff">The handoff agent for capturing the result.</param>
/// <param name="logger">The logger to use for the actor</param>
public HandoffActor(ActorId id, IAgentRuntime runtime, OrchestrationContext context, ChatClientAgent agent, HandoffLookup handoffs, ActorType resultHandoff, ILogger<HandoffActor>? logger = null)
: base(id, runtime, context, agent, logger)
{
Throw.IfNull(handoffs);
Throw.IfNull(resultHandoff);
if (handoffs.ContainsKey(agent.Name ?? agent.Id))
{
Throw.ArgumentException(nameof(handoffs), $"The agent {agent.Name ?? agent.Id} cannot have a handoff to itself.");
}
this._cache = [];
this._chatAgent = agent;
this._handoffs = handoffs;
this._resultHandoff = resultHandoff;
this._options = new() { Tools = this.CreateHandoffFunctions() };
this.RegisterMessageHandler<HandoffMessages.InputTask>(this.Handle);
this.RegisterMessageHandler<HandoffMessages.Request>(this.HandleAsync);
this.RegisterMessageHandler<HandoffMessages.Response>(this.Handle);
}
/// <inheritdoc/>
protected override Task<AgentRunResponse> InvokeCoreAsync(
IReadOnlyCollection<ChatMessage> messages, AgentRunOptions? options, CancellationToken cancellationToken) =>
this._chatAgent.RunAsync([.. messages], this.Thread, options, this._options, cancellationToken);
/// <inheritdoc/>
protected override IAsyncEnumerable<AgentRunResponseUpdate> InvokeStreamingCoreAsync(
IReadOnlyCollection<ChatMessage> messages, AgentRunOptions? options, CancellationToken cancellationToken) =>
this._chatAgent.RunStreamingAsync(messages, this.Thread, options, this._options, cancellationToken);
/// <summary>
/// Gets or sets the callback to be invoked for interactive input.
/// </summary>
public Func<ValueTask<ChatMessage>>? InteractiveCallback { get; init; }
private void Handle(HandoffMessages.InputTask item, MessageContext messageContext)
{
this._taskSummary = null;
this._cache.AddRange(item.Messages);
}
private void Handle(HandoffMessages.Response item, MessageContext messageContext)
{
this._cache.Add(item.Message);
}
private async ValueTask HandleAsync(HandoffMessages.Request item, MessageContext messageContext, CancellationToken cancellationToken)
{
try
{
this.Logger.LogHandoffAgentInvoke(this.Id);
while (this._taskSummary == null)
{
ChatMessage response;
try
{
response = await this.RunAsync(this._cache, cancellationToken).ConfigureAwait(false);
}
catch (Exception exception)
{
this.Logger.LogError(exception, "Failure");
throw;
}
this._cache.Clear();
this.Logger.LogHandoffAgentResult(this.Id, response.Text);
// The response can potentially be a TOOL message from the Handoff plugin due to the filter
// which will terminate the conversation when a function from the handoff plugin is called.
// Since we don't want to publish that message, so we only publish if the response is an ASSISTANT message.
if (response.Role == ChatRole.Assistant)
{
await this.PublishMessageAsync(new HandoffMessages.Response(response), this.Context.Topic, messageId: null, cancellationToken).ConfigureAwait(false);
}
if (this._handoffAgent != null)
{
ActorType handoffType = this._handoffs[this._handoffAgent].AgentType;
await this.PublishMessageAsync(new HandoffMessages.Request(), handoffType, cancellationToken).ConfigureAwait(false);
this._handoffAgent = null;
break;
}
if (this.InteractiveCallback != null && this._taskSummary == null)
{
ChatMessage input = await this.InteractiveCallback().ConfigureAwait(false);
await this.PublishMessageAsync(new HandoffMessages.Response(input), this.Context.Topic, messageId: null, cancellationToken).ConfigureAwait(false);
this._cache.Add(input);
continue;
}
await this.EndAsync(response.Text ?? "No handoff or human response function requested. Ending task.", cancellationToken).ConfigureAwait(false);
}
}
catch (Exception exception)
{
this.Logger.LogError(exception, "Failure");
throw;
}
}
private List<AITool> CreateHandoffFunctions()
{
List<AITool> functions = [];
functions.Add(AIFunctionFactory.Create(
this.EndAsync,
name: "end_task",
description: "Complete the task with a summary when no further requests are given."));
foreach (KeyValuePair<string, (ActorType AgentType, string Description)> handoff in this._handoffs)
{
functions.Add(AIFunctionFactory.Create(
() => this.Handoff(handoff.Key),
name: $"transfer_to_{InvalidNameCharsRegex().Replace(handoff.Key, "_")}",
description: handoff.Value.Description));
}
return functions;
}
private void Handoff(string agentName)
{
this.Logger.LogHandoffFunctionCall(this.Id, agentName);
this._handoffAgent = agentName;
FunctionInvokingChatClient.CurrentContext!.Terminate = true;
}
private async ValueTask EndAsync(string summary, CancellationToken cancellationToken)
{
this.Logger.LogHandoffSummary(this.Id, summary);
this._taskSummary = summary;
await this.PublishMessageAsync(new HandoffMessages.Result(new(ChatRole.Assistant, summary)), this._resultHandoff, cancellationToken).ConfigureAwait(false);
if (FunctionInvokingChatClient.CurrentContext is not null)
{
FunctionInvokingChatClient.CurrentContext.Terminate = true;
}
}
/// <summary>Regex that flags any character other than ASCII digits or letters or the underscore.</summary>
#if NET
[GeneratedRegex("[^0-9A-Za-z_]+")]
private static partial Regex InvalidNameCharsRegex();
#else
private static Regex InvalidNameCharsRegex() => s_invalidNameCharsRegex;
private static readonly Regex s_invalidNameCharsRegex = new("[^0-9A-Za-z_]+", RegexOptions.Compiled);
#endif
}
@@ -1,32 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.Orchestration;
/// <summary>
/// A message that describes the input task and captures results for a <see cref="HandoffOrchestration{TInput,TOutput}"/>.
/// </summary>
internal static class HandoffMessages
{
/// <summary>
/// The input message.
/// </summary>
public sealed record InputTask(IList<ChatMessage> Messages);
/// <summary>
/// The final result.
/// </summary>
public sealed record Result(ChatMessage Message);
/// <summary>
/// Signals the handoff to another agent.
/// </summary>
public sealed class Request;
/// <summary>
/// Broadcast an agent response to all actors in the orchestration.
/// </summary>
public sealed record Response(ChatMessage Message);
}
@@ -1,22 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.AI.Agents;
namespace Microsoft.Agents.Orchestration;
/// <summary>
/// An orchestration that passes the input message to the first agent, and
/// then the subsequent result to the next agent, etc...
/// </summary>
public sealed class HandoffOrchestration : HandoffOrchestration<string, string>
{
/// <summary>
/// Initializes a new instance of the <see cref="HandoffOrchestration"/> class.
/// </summary>
/// <param name="handoffs">Defines the handoff connections for each agent.</param>
/// <param name="members">The agents to be orchestrated.</param>
public HandoffOrchestration(OrchestrationHandoffs handoffs, params AIAgent[] members)
: base(handoffs, members)
{
}
}
@@ -1,114 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.AI.Agents;
using Microsoft.Extensions.AI.Agents.Runtime;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.Orchestration;
/// <summary>
/// An orchestration that provides the input message to the first agent
/// and sequentially passes each agent result to the next agent.
/// </summary>
public class HandoffOrchestration<TInput, TOutput> : AgentOrchestration<TInput, TOutput>
{
private readonly OrchestrationHandoffs _handoffs;
/// <summary>
/// Initializes a new instance of the <see cref="HandoffOrchestration{TInput, TOutput}"/> class.
/// </summary>
/// <param name="handoffs">Defines the handoff connections for each agent.</param>
/// <param name="agents">Additional agents participating in the orchestration that weren't passed to <paramref name="handoffs"/>.</param>
public HandoffOrchestration(OrchestrationHandoffs handoffs, params AIAgent[] agents) : base(
agents is { Length: 0 } ? handoffs.Agents.ToArray() :
handoffs.Agents is { Count: 0 } ? agents :
handoffs.Agents.Concat(agents).Distinct().ToArray())
{
// Create list of distinct agent names
HashSet<string> agentNames = new(base.Members.Select(a => a.Name ?? a.Id), StringComparer.Ordinal)
{
handoffs.FirstAgentName
};
// Extract names from handoffs that don't align with a member agent.
string[] badNames = [.. handoffs.Keys.Concat(handoffs.Values.SelectMany(h => h.Keys)).Where(name => !agentNames.Contains(name))];
// Fail fast if invalid names are present.
if (badNames.Length > 0)
{
Throw.ArgumentException(nameof(handoffs), $"The following agents are not defined in the orchestration: {string.Join(", ", badNames)}");
}
this._handoffs = handoffs;
}
/// <summary>
/// Gets or sets the callback to be invoked for interactive input.
/// </summary>
public Func<ValueTask<ChatMessage>>? InteractiveCallback { get; init; }
/// <inheritdoc />
protected override async ValueTask StartAsync(IAgentRuntime runtime, TopicId topic, IEnumerable<ChatMessage> input, ActorType? entryAgent)
{
Throw.IfNull(entryAgent);
await runtime.PublishMessageAsync(new HandoffMessages.InputTask([.. input]), topic).ConfigureAwait(false);
await runtime.PublishMessageAsync(new HandoffMessages.Request(), entryAgent.Value).ConfigureAwait(false);
}
/// <inheritdoc />
protected override async ValueTask<ActorType?> RegisterOrchestrationAsync(IAgentRuntime runtime, OrchestrationContext context, RegistrationContext registrar, ILogger logger)
{
ActorType outputType = await registrar.RegisterResultTypeAsync<HandoffMessages.Result>(response => [response.Message]).ConfigureAwait(false);
// Each agent handsoff its result to the next agent.
Dictionary<string, ActorType> agentMap = [];
Dictionary<string, HandoffLookup> handoffMap = [];
ActorType agentType = outputType;
for (int index = this.Members.Count - 1; index >= 0; --index)
{
AIAgent agent = this.Members[index];
HandoffLookup map = [];
handoffMap[agent.Name ?? agent.Id] = map;
agentType =
await runtime.RegisterOrchestrationAgentAsync(
this.GetAgentType(context.Topic, index),
(agentId, runtime) =>
{
HandoffActor actor =
new(agentId, runtime, context, (ChatClientAgent)agent, map, outputType, context.LoggerFactory.CreateLogger<HandoffActor>())
{
InteractiveCallback = this.InteractiveCallback
};
return new ValueTask<IRuntimeActor>(actor);
}).ConfigureAwait(false);
agentMap[agent.Name ?? agent.Id] = agentType;
await runtime.SubscribeAsync(agentType, context.Topic).ConfigureAwait(false);
logger.LogRegisterActor(this.OrchestrationLabel, agentType, "MEMBER", index + 1);
}
// Complete the handoff model
foreach (KeyValuePair<string, AgentHandoffs> handoffs in this._handoffs)
{
// Retrieve the map for the agent (every agent had an empty map created)
HandoffLookup agentHandoffs = handoffMap[handoffs.Key];
foreach (KeyValuePair<string, string> handoff in handoffs.Value)
{
// name = (type,description)
agentHandoffs[handoff.Key] = (agentMap[handoff.Key], handoff.Value);
}
}
return agentMap[this._handoffs.FirstAgentName];
}
private ActorType GetAgentType(TopicId topic, int index) => this.FormatAgentType(topic, $"Agent_{index + 1}");
}
@@ -1,49 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.AI.Agents.Runtime;
using Microsoft.Extensions.Logging;
namespace Microsoft.Agents.Orchestration;
/// <summary>
/// Extensions for logging <see cref="HandoffOrchestration{TInput, TOutput}"/>.
/// </summary>
/// <remarks>
/// This extension uses the <see cref="LoggerMessageAttribute"/> to
/// generate logging code at compile time to achieve optimized code.
/// </remarks>
[ExcludeFromCodeCoverage]
internal static partial class HandoffOrchestrationLogMessages
{
[LoggerMessage(
Level = LogLevel.Trace,
Message = "REQUEST Handoff agent [{AgentId}]")]
public static partial void LogHandoffAgentInvoke(
this ILogger logger,
ActorId agentId);
[LoggerMessage(
Level = LogLevel.Trace,
Message = "RESULT Handoff agent [{AgentId}]: {Message}")]
public static partial void LogHandoffAgentResult(
this ILogger logger,
ActorId agentId,
string? message);
[LoggerMessage(
Level = LogLevel.Trace,
Message = "TOOL Handoff [{AgentId}]: {Name}")]
public static partial void LogHandoffFunctionCall(
this ILogger logger,
ActorId agentId,
string name);
[LoggerMessage(
Level = LogLevel.Trace,
Message = "RESULT Handoff summary [{AgentId}]: {Summary}")]
public static partial void LogHandoffSummary(
this ILogger logger,
ActorId agentId,
string? summary);
}
@@ -0,0 +1,233 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.AI.Agents;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.Orchestration;
/// <summary>
/// An orchestration that provides the input message to the first agent
/// and sequentially passes each agent result to the next agent.
/// </summary>
public sealed partial class HandoffOrchestration : OrchestratingAgent
{
private readonly OrchestrationHandoffs _handoffs;
/// <summary>
/// Initializes a new instance of the <see cref="HandoffOrchestration"/> class.
/// </summary>
/// <param name="handoffs">Defines the handoff connections for each agent.</param>
/// <param name="agents">Additional agents participating in the orchestration that weren't passed to <paramref name="handoffs"/>.</param>
public HandoffOrchestration(OrchestrationHandoffs handoffs, params AIAgent[] agents) : base(
agents is { Length: 0 } ? [.. handoffs.Agents] :
handoffs.Agents is { Count: 0 } ? agents :
[.. handoffs.Agents.Concat(agents).Distinct()])
{
// Create list of distinct agent names
HashSet<string> agentNames = [.. base.Agents.Select(a => a.DisplayName), handoffs.FirstAgentName];
// Extract names from handoffs that don't align with a member agent.
// Fail fast if invalid names are present.
string[] badNames = [.. handoffs.Keys.Concat(handoffs.Values.SelectMany(h => h.Keys)).Where(name => !agentNames.Contains(name))];
if (badNames.Length > 0)
{
Throw.ArgumentException(nameof(handoffs), $"The following agents are not defined in the orchestration: {string.Join(", ", badNames)}");
}
this._handoffs = handoffs;
}
/// <summary>Gets or sets a callback invoked when no next handoff is selected in order to supply </summary>
public Func<ValueTask<ChatMessage>>? InteractiveCallback { get; set; }
/// <inheritdoc />
protected override Task<AgentRunResponse> RunCoreAsync(IReadOnlyCollection<ChatMessage> messages, OrchestratingAgentContext context, CancellationToken cancellationToken)
{
List<ChatMessage> allMessages = [.. messages];
int originalMessageCount = allMessages.Count;
return this.ResumeAsync(this._handoffs.FirstAgentName, allMessages, originalMessageCount, context, cancellationToken);
}
/// <inheritdoc />
protected override Task<AgentRunResponse> ResumeCoreAsync(JsonElement checkpointState, OrchestratingAgentContext context, CancellationToken cancellationToken)
{
var state = checkpointState.Deserialize(OrchestrationJsonContext.Default.HandoffState) ?? throw new InvalidOperationException("The checkpoint state is invalid.");
return this.ResumeAsync(state.NextAgent, state.AllMessages, state.OriginalMessageCount, context, cancellationToken);
}
/// <inheritdoc />
private async Task<AgentRunResponse> ResumeAsync(
string? nextAgent, List<ChatMessage> allMessages, int originalMessageCount, OrchestratingAgentContext context, CancellationToken cancellationToken)
{
Debug.Assert(nextAgent is not null);
AgentRunResponse? response = null;
while (nextAgent is not null)
{
AIAgent? agent =
this.Agents.FirstOrDefault(a => a.Name == nextAgent || a.Id == nextAgent) ??
throw new InvalidOperationException($"The agent '{nextAgent}' is not defined in the orchestration.");
this.LogOrchestrationSubagentRunning(context, agent);
if (!this._handoffs.TryGetValue(agent.DisplayName, out AgentHandoffs? handoffs) || handoffs.Count == 0)
{
// If no handoff is available, we can run the agent directly and return its response.
response = await RunAsync(agent, context, allMessages, context.Options, cancellationToken).ConfigureAwait(false);
allMessages.AddRange(response.Messages);
nextAgent = null;
await CheckpointAsync().ConfigureAwait(false);
this.LogOrchestrationSubagentCompleted(context, agent);
break;
}
// Create the options for the next agent request, including handoff functions.
HandoffContext handoffCtx = new(handoffs);
ChatClientAgentRunOptions? options = null;
List<AITool> handoffTools = handoffCtx.CreateHandoffFunctions(this.InteractiveCallback is not null);
if (context.Options is ChatClientAgentRunOptions contextOptions)
{
ChatOptions chatOptions = contextOptions.ChatOptions?.Clone() ?? new();
chatOptions.Tools = chatOptions.Tools is { Count: > 0 } ? [.. chatOptions.Tools, .. handoffTools] : handoffTools;
options = new(chatOptions);
}
else
{
options = new(new() { Tools = handoffTools });
}
// Invoke the next agent with all of the messages collected so far.
response = await RunAsync(agent, context, allMessages, options, cancellationToken).ConfigureAwait(false);
allMessages.AddRange(response.Messages);
nextAgent = handoffCtx.TargetedAgent;
RemoveHandoffFunctionCalls(response, handoffTools);
if (this.InteractiveCallback is not null)
{
if (handoffCtx.EndTaskInvoked)
{
break;
}
nextAgent = agent.DisplayName;
allMessages.Add(await this.InteractiveCallback().ConfigureAwait(false));
}
await CheckpointAsync().ConfigureAwait(false);
this.LogOrchestrationSubagentCompleted(context, agent);
}
allMessages.RemoveRange(0, originalMessageCount);
response ??= new();
response.Messages = allMessages;
return response;
Task CheckpointAsync() => context.Runtime is not null ?
base.WriteCheckpointAsync(JsonSerializer.SerializeToElement(new(nextAgent, allMessages, originalMessageCount), OrchestrationJsonContext.Default.HandoffState), context, cancellationToken) :
Task.CompletedTask;
}
private static void RemoveHandoffFunctionCalls(AgentRunResponse response, List<AITool> handoffTools)
{
HashSet<string>? removeToolNames = null;
HashSet<string>? callIds = null;
foreach (var message in response.Messages)
{
for (int i = message.Contents.Count - 1; i >= 0; i--)
{
if (message.Contents[i] is FunctionCallContent fcc)
{
removeToolNames ??= [.. handoffTools.Select(t => t.Name)];
(callIds ??= new()).Add(fcc.CallId);
if (removeToolNames.Contains(fcc.Name))
{
message.Contents.RemoveAt(i);
}
}
}
}
if (callIds is not null)
{
foreach (var message in response.Messages)
{
for (int i = message.Contents.Count - 1; i >= 0; i--)
{
if (message.Contents[i] is FunctionResultContent frc && callIds.Contains(frc.CallId))
{
message.Contents.RemoveAt(i);
}
}
}
}
}
private sealed class HandoffContext(AgentHandoffs handoffs)
{
public string? TargetedAgent { get; set; }
public bool EndTaskInvoked { get; set; }
public List<AITool> CreateHandoffFunctions(bool needsEndTask)
{
List<AITool> functions = [];
if (needsEndTask)
{
functions.Add(AIFunctionFactory.Create(
() =>
{
this.EndTaskInvoked = true;
Terminate();
},
name: "end_task",
description: "Invoke this function when all work is completed and no further interactions are required."));
}
foreach (KeyValuePair<string, string> handoff in handoffs)
{
functions.Add(AIFunctionFactory.Create(
() =>
{
this.TargetedAgent = handoff.Key;
Terminate();
},
name: $"handoff_to_{InvalidNameCharsRegex().Replace(handoff.Key, "_")}",
description: handoff.Value));
}
return functions;
static void Terminate()
{
if (FunctionInvokingChatClient.CurrentContext is not { } ctx)
{
throw new NotSupportedException($"The agent is not configured with a {nameof(FunctionInvokingChatClient)}. Cease execution.");
}
ctx.Terminate = true;
}
}
}
internal sealed record HandoffState(string? NextAgent, List<ChatMessage> AllMessages, int OriginalMessageCount);
/// <summary>Regex that flags any character other than ASCII digits or letters or the underscore.</summary>
#if NET
[GeneratedRegex("[^0-9A-Za-z_]+")]
private static partial Regex InvalidNameCharsRegex();
#else
private static Regex InvalidNameCharsRegex() => s_invalidNameCharsRegex;
private static readonly Regex s_invalidNameCharsRegex = new("[^0-9A-Za-z_]+", RegexOptions.Compiled);
#endif
}
@@ -36,7 +36,7 @@ public sealed class OrchestrationHandoffs : Dictionary<string, AgentHandoffs>
/// </summary>
/// <param name="firstAgent">The first agent to be invoked (prior to any handoff).</param>
public OrchestrationHandoffs(AIAgent firstAgent)
: this(firstAgent.Name ?? firstAgent.Id)
: this(firstAgent.DisplayName)
{
this.Agents.Add(firstAgent);
}
@@ -73,7 +73,7 @@ public sealed class OrchestrationHandoffs : Dictionary<string, AgentHandoffs>
/// <returns>The updated <see cref="OrchestrationHandoffs"/> instance.</returns>
public OrchestrationHandoffs Add(AIAgent source, params AIAgent[] targets)
{
string key = source.Name ?? source.Id;
string key = source.DisplayName;
AgentHandoffs agentHandoffs = this.GetAgentHandoffs(key);
@@ -85,7 +85,7 @@ public sealed class OrchestrationHandoffs : Dictionary<string, AgentHandoffs>
}
this.Agents.Add(target);
agentHandoffs[target.Name ?? target.Id] = target.Description ?? target.Name!;
agentHandoffs[target.DisplayName] = target.Description ?? target.Name!;
}
this.Agents.Add(source);
@@ -101,7 +101,11 @@ public sealed class OrchestrationHandoffs : Dictionary<string, AgentHandoffs>
/// <param name="description">The handoff description.</param>
/// <returns>The updated <see cref="OrchestrationHandoffs"/> instance.</returns>
public OrchestrationHandoffs Add(AIAgent source, AIAgent target, string description)
=> this.Add(source.Name ?? source.Id, target.Name ?? target.Id, description);
{
this.Agents.Add(source);
this.Agents.Add(target);
return this.Add(source.DisplayName, target.DisplayName, description);
}
/// <summary>
/// Adds a handoff relationship from a source agent to a target agent name/ID with a custom description.
@@ -111,7 +115,10 @@ public sealed class OrchestrationHandoffs : Dictionary<string, AgentHandoffs>
/// <param name="description">The handoff description.</param>
/// <returns>The updated <see cref="OrchestrationHandoffs"/> instance.</returns>
public OrchestrationHandoffs Add(AIAgent source, string targetName, string description)
=> this.Add(source.Name ?? source.Id, targetName, description);
{
this.Agents.Add(source);
return this.Add(source.DisplayName, targetName, description);
}
/// <summary>
/// Adds a handoff relationship from a source agent name/ID to a target agent name/ID with a custom description.
@@ -0,0 +1,306 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.AI.Agents;
using Microsoft.Extensions.AI.Agents.Runtime;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.Orchestration;
/// <summary>
/// Base class for multi-agent agent orchestration patterns.
/// </summary>
public abstract partial class OrchestratingAgent : AIAgent
{
/// <summary>Key used to persist state with the runtime.</summary>
private const string StateKey = "State";
/// <summary>
/// Initializes a new instance of the <see cref="OrchestratingAgent"/> class.
/// </summary>
/// <param name="agents">Specifies the agents participating in this orchestration.</param>
protected OrchestratingAgent(IReadOnlyList<AIAgent> agents)
{
_ = Throw.IfNullOrEmpty(agents);
this.Agents = agents;
}
/// <summary>
/// Gets the list of member targets involved in the orchestration.
/// </summary>
protected IReadOnlyList<AIAgent> Agents { get; }
/// <summary>Gets the serializer options to use by the orchestration.</summary>
public JsonSerializerOptions? SerializerOptions { get; set; }
/// <summary>
/// Gets the associated logger.
/// </summary>
public ILoggerFactory LoggerFactory { get; set; } = NullLoggerFactory.Instance;
/// <summary>
/// Optional callback that is invoked for every agent response.
/// </summary>
public Func<IEnumerable<ChatMessage>, ValueTask>? ResponseCallback { get; set; }
/// <summary>
/// Optional callback that is invoked for every agent update.
/// </summary>
public Func<AgentRunResponseUpdate, ValueTask>? StreamingResponseCallback { get; set; }
/// <inheritdoc />
public sealed override async Task<AgentRunResponse> RunAsync(
IReadOnlyCollection<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
_ = Throw.IfNull(messages);
if (thread is not null)
{
if (thread is not IMessagesRetrievableThread retrievableThread)
{
throw new InvalidOperationException($"The thread type '{thread.GetType().Name}' is not supported by this agent. Use {nameof(GetNewThread)} to create a thread when needed.");
}
List<ChatMessage> messagesList = [];
await foreach (var threadMessage in retrievableThread.GetMessagesAsync(cancellationToken).ConfigureAwait(false))
{
messagesList.Add(threadMessage);
}
messagesList.AddRange(messages);
messages = messagesList;
}
var orchestrationResult = await this.RunAsync(messages, options, runtime: null, cancellationToken).ConfigureAwait(false);
return await orchestrationResult.Task.ConfigureAwait(false);
}
/// <inheritdoc />
public sealed override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
IReadOnlyCollection<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
// TODO: There should be a RunAsync overload that returns an OrchestratingAgentStreamingResponse, which this then delegates to.
var response = await this.RunAsync(messages, thread, options, cancellationToken).ConfigureAwait(false);
foreach (var update in response.ToAgentRunResponseUpdates())
{
yield return update;
}
}
/// <inheritdoc />
public sealed override AgentThread GetNewThread() => new ChatClientAgentThread();
/// <summary>
/// Initiates processing of the orchestration.
/// </summary>
/// <param name="messages">The input message.</param>
/// <param name="options">Optional parameters for agent invocation.</param>
/// <param name="runtime">The runtime associated with the orchestration.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
public async ValueTask<OrchestratingAgentResponse> RunAsync(
IReadOnlyCollection<ChatMessage> messages,
AgentRunOptions? options = null,
IActorRuntimeContext? runtime = null,
CancellationToken cancellationToken = default)
{
Throw.IfNull(messages, nameof(messages));
cancellationToken.ThrowIfCancellationRequested();
ILogger logger = this.LoggerFactory.CreateLogger(this.GetType().Name);
OrchestratingAgentContext context = new()
{
OrchestratingAgent = this,
Runtime = runtime,
Options = options,
Logger = logger,
};
LogOrchestrationInvoked(logger, this.DisplayName, context.Id);
CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cancellationToken = cts.Token;
JsonElement? checkpoint = await this.ReadCheckpointAsync(context, cancellationToken).ConfigureAwait(false);
Task<AgentRunResponse> completion = checkpoint is null ?
this.RunCoreAsync(messages, context, cancellationToken) :
this.ResumeCoreAsync(checkpoint.Value, context, cancellationToken);
if (logger.IsEnabled(LogLevel.Trace))
{
_ = LogCompletionAsync(logger, context, completion);
}
return new OrchestratingAgentResponse(context, completion, cts, logger);
}
/// <summary>
/// Initiates processing of the orchestration.
/// </summary>
/// <param name="messages">The input message.</param>
/// <param name="context">The context for this operation.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
protected abstract Task<AgentRunResponse> RunCoreAsync(IReadOnlyCollection<ChatMessage> messages, OrchestratingAgentContext context, CancellationToken cancellationToken);
/// <summary>
/// Resumes processing of the orchestration.
/// </summary>
/// <param name="checkpointState">The last checkpoint state available from which to resume the operation.</param>
/// <param name="context">The context for this operation.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
protected abstract Task<AgentRunResponse> ResumeCoreAsync(JsonElement checkpointState, OrchestratingAgentContext context, CancellationToken cancellationToken);
/// <summary>
/// Runs the agent with input messages and respond with both streamed and regular messages.
/// </summary>
/// <param name="agent">The agent being run</param>
/// <param name="context">The associated orchestration context for this run.</param>
/// <param name="input">The list of chat messages to send.</param>
/// <param name="options">Options to use when invoking the agent.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>A task that returns the response <see cref="ChatMessage"/>.</returns>
protected static async ValueTask<AgentRunResponse> RunAsync(AIAgent agent, OrchestratingAgentContext context, IReadOnlyCollection<ChatMessage> input, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
// Utilize streaming iff a streaming callback is provided; otherwise, use the non-streaming API.
AgentRunResponse response;
if (context.OrchestratingAgent?.StreamingResponseCallback is { } streamingCallback)
{
// For streaming, enumerate all the updates, invoking the callback for each, and storing them all.
// Then convert them all into a single response instance.
List<AgentRunResponseUpdate> updates = [];
await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync(input, options: options ?? context.Options, cancellationToken: cancellationToken).ConfigureAwait(false))
{
updates.Add(update);
await streamingCallback(update).ConfigureAwait(false);
}
response = updates.ToAgentRunResponse();
}
else
{
// For non-streaming, just invoke the non-streaming method and get back the response.
response = await agent.RunAsync(input, options: options ?? context.Options, cancellationToken: cancellationToken).ConfigureAwait(false);
}
// Regardless of whether we invoked streaming callbacks for individual updates, invoke the non-streaming callback with the final response instance.
// This can be used as an indication of completeness if someone otherwise only cares about the streaming updates.
if (context.OrchestratingAgent?.ResponseCallback is { } responseCallback)
{
await responseCallback.Invoke(response.Messages).ConfigureAwait(false);
}
return response;
}
/// <inheritdoc />
protected sealed override TThreadType ValidateOrCreateThreadType<TThreadType>(AgentThread? thread, Func<TThreadType> constructThread) =>
base.ValidateOrCreateThreadType(thread, constructThread);
/// <summary>Writes the specified checkpoint state to the runtime.</summary>
/// <param name="state">The state to persist.</param>
/// <param name="context">The context for the orchestrating operation.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>A Task that completes when the asynchronous operation quiesces.</returns>
protected async Task WriteCheckpointAsync(JsonElement state, OrchestratingAgentContext context, CancellationToken cancellationToken)
{
_ = Throw.IfNull(context);
if (context.Runtime is not null)
{
while (true)
{
var response = await context.Runtime.WriteAsync(
new ActorWriteOperationBatch(context.ETag ?? "", [new SetValueOperation(StateKey, state)]),
cancellationToken).ConfigureAwait(false);
if (response.Success)
{
break;
}
// If the write failed, there was a concurrency conflict where someone else updated the state.
// But we don't actually care about consistency between the previous checkpoint and the current one,
// so we just retry the write with the new etag.
context.ETag = response.ETag;
}
}
}
/// <summary>Read checkpoint information, if it exists, for the specified context.</summary>
/// <param name="context">The context for the orchestrating operation.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>The loaded state, or null if it doesn't exist.</returns>
protected async ValueTask<JsonElement?> ReadCheckpointAsync(OrchestratingAgentContext context, CancellationToken cancellationToken)
{
_ = Throw.IfNull(context);
if (context.Runtime is not null)
{
ReadResponse response = await context.Runtime.ReadAsync(
new ActorReadOperationBatch([new GetValueOperation(StateKey)]),
cancellationToken).ConfigureAwait(false);
context.ETag = response.ETag;
if (response.Results is { } results &&
results[results.Count - 1] is GetValueResult { Value: not null } getValueResult)
{
return getValueResult.Value.Value;
}
}
return default;
}
[LoggerMessage(Level = LogLevel.Trace, Message = "{Orchestration} started ('{Id}')")]
private static partial void LogOrchestrationInvoked(ILogger logger, string orchestration, string id);
[LoggerMessage(Level = LogLevel.Trace, Message = "{Orchestration} completed ('{Id}'). Result: '{Result}'")]
private static partial void LogOrchestrationResult(ILogger logger, string orchestration, string id, string result);
[LoggerMessage(Level = LogLevel.Trace, Message = "{Orchestration} cancellation requested ('{Id}')")]
internal static partial void LogOrchestrationCancellationRequested(ILogger logger, string orchestration, string id);
[LoggerMessage(Level = LogLevel.Trace, Message = "{Orchestration} failed ('{Id}')")]
private static partial void LogOrchestrationFailure(ILogger logger, string orchestration, string id, Exception error);
[LoggerMessage(Level = LogLevel.Trace, Message = "{Orchestration} invoking agent '{Agent}' ('{Id}')")]
private static partial void LogOrchestrationSubagentRunning(ILogger logger, string orchestration, string id, string agent);
[LoggerMessage(Level = LogLevel.Trace, Message = "{Orchestration} completed agent '{Agent}' ('{Id}')")]
private static partial void LogOrchestrationSubagentCompleted(ILogger logger, string orchestration, string id, string agent);
private protected void LogOrchestrationSubagentRunning(OrchestratingAgentContext context, AIAgent agent) =>
LogOrchestrationSubagentRunning(context.Logger, context.ToString(), context.Id, agent.DisplayName);
private protected void LogOrchestrationSubagentCompleted(OrchestratingAgentContext context, AIAgent agent) =>
LogOrchestrationSubagentCompleted(context.Logger, context.ToString(), context.Id, agent.DisplayName);
private static async Task LogCompletionAsync(ILogger logger, OrchestratingAgentContext context, Task<AgentRunResponse> completion)
{
try
{
AgentRunResponse result = await completion.ConfigureAwait(false);
if (logger.IsEnabled(LogLevel.Trace))
{
JsonSerializerOptions jso = context.OrchestratingAgent?.SerializerOptions ?? AgentAbstractionsJsonUtilities.DefaultOptions;
LogOrchestrationResult(logger, context.ToString(), context.Id, JsonSerializer.Serialize(result, jso.GetTypeInfo(typeof(AgentRunResponse))));
}
}
catch (Exception ex)
{
LogOrchestrationFailure(logger, context.ToString(), context.Id, ex);
}
}
}
@@ -0,0 +1,54 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using Microsoft.Extensions.AI.Agents;
using Microsoft.Extensions.AI.Agents.Runtime;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
namespace Microsoft.Agents.Orchestration;
/// <summary>
/// Provides contextual information for an orchestration operation, including logging, and response callback.
/// </summary>
public sealed class OrchestratingAgentContext
{
private ILogger? _logger;
private string? _id;
/// <summary>Gets the orchestrating agent associated with this operation.</summary>
public OrchestratingAgent? OrchestratingAgent { get; set; }
/// <summary>Gets the associated agent runtime, if one is being used.</summary>
public IActorRuntimeContext? Runtime { get; set; }
/// <summary>Gets the options associated with the orchestration run.</summary>
public AgentRunOptions? Options { get; set; }
/// <summary>Gets or sets the last version number provided by the runtime for checkpoint state.</summary>
public string? ETag { get; set; }
/// <summary>Gets or sets an ID to use for the orchestration operation.</summary>
public string Id
{
get
{
this._id ??= this.Runtime?.ActorId.ToString() ?? Guid.NewGuid().ToString("N");
return this._id;
}
}
/// <summary>
/// Gets the associated logger for this operation.
/// </summary>
public ILogger Logger
{
get => this._logger ?? NullLogger.Instance;
set => this._logger = value;
}
/// <inheritdoc />
public override string ToString() =>
this.OrchestratingAgent?.DisplayName ??
nameof(OrchestratingAgentContext);
}
@@ -0,0 +1,64 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI.Agents;
using Microsoft.Extensions.Logging;
namespace Microsoft.Agents.Orchestration;
/// <summary>
/// Represents the result of an orchestrating agent.
/// This class encapsulates the asynchronous completion of an orchestration process.
/// </summary>
public sealed partial class OrchestratingAgentResponse : IAsyncDisposable
{
private readonly CancellationTokenSource _cancelSource;
private readonly ILogger _logger;
internal OrchestratingAgentResponse(
OrchestratingAgentContext context,
Task<AgentRunResponse> completion,
CancellationTokenSource orchestrationCancelSource,
ILogger logger)
{
this.Context = context;
this._cancelSource = orchestrationCancelSource;
this.Task = completion;
this._logger = logger;
}
/// <summary>Gets the <see cref="OrchestratingAgentContext"/> associated with this response.</summary>
public OrchestratingAgentContext Context { get; }
/// <summary>
/// Releases all resources used by the <see cref="OrchestratingAgentResponse"/> instance.
/// </summary>
public ValueTask DisposeAsync()
{
this._cancelSource.Dispose();
return default;
}
/// <summary>
/// Gets a task that represents the completion of the orchestration result.
/// </summary>
public Task<AgentRunResponse> Task { get; }
/// <summary>
/// Requests cancellation of the orchestration associated with this result.
/// </summary>
/// <exception cref="ObjectDisposedException">Thrown if this instance has been disposed.</exception>
public void Cancel()
{
OrchestratingAgent.LogOrchestrationCancellationRequested(this._logger, this.Context.ToString(), this.Context.Id);
this._cancelSource.Cancel();
}
/// <summary>Enable directly awaiting an <see cref="OrchestratingAgentResponse"/> by using <see cref="Task"/>'s awaiter.</summary>
[EditorBrowsable(EditorBrowsableState.Never)]
public TaskAwaiter<AgentRunResponse> GetAwaiter() => this.Task.GetAwaiter();
}
@@ -1,41 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI.Agents.Runtime;
using Microsoft.Extensions.Logging;
namespace Microsoft.Agents.Orchestration;
/// <summary>
/// Base abstractions for any actor that participates in an orchestration.
/// </summary>
public abstract class OrchestrationActor : RuntimeActor
{
/// <summary>
/// Initializes a new instance of the <see cref="OrchestrationActor"/> class.
/// </summary>
protected OrchestrationActor(ActorId id, IAgentRuntime runtime, OrchestrationContext context, string? description = null, ILogger? logger = null)
: base(id, runtime, description, logger)
{
this.Context = context;
}
/// <summary>
/// The orchestration context.
/// </summary>
protected OrchestrationContext Context { get; }
/// <summary>
/// Sends a message to a specified recipient agent-type through the runtime.
/// </summary>
/// <param name="message">The message object to send.</param>
/// <param name="agentType">The recipient agent's type.</param>
/// <param name="cancellationToken">A token used to cancel the operation if needed.</param>
/// <returns>The agent identifier, if it exists.</returns>
protected ValueTask PublishMessageAsync(
object message,
ActorType agentType,
CancellationToken cancellationToken = default) =>
base.PublishMessageAsync(message, new TopicId(agentType.Name), messageId: null, cancellationToken);
}
@@ -1,67 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.AI.Agents;
using Microsoft.Extensions.AI.Agents.Runtime;
using Microsoft.Extensions.Logging;
namespace Microsoft.Agents.Orchestration;
/// <summary>
/// Provides contextual information for an orchestration operation, including topic, cancellation, logging, and response callback.
/// </summary>
public sealed class OrchestrationContext
{
internal OrchestrationContext(
string orchestration,
TopicId topic,
Func<IEnumerable<ChatMessage>, ValueTask>? responseCallback,
Func<AgentRunResponseUpdate, ValueTask>? streamingCallback,
ILoggerFactory loggerFactory,
CancellationToken cancellationToken)
{
this.Orchestration = orchestration;
this.Topic = topic;
this.ResponseCallback = responseCallback;
this.StreamingResponseCallback = streamingCallback;
this.LoggerFactory = loggerFactory;
this.CancellationToken = cancellationToken;
}
/// <summary>
/// Gets the name or identifier of the orchestration.
/// </summary>
public string Orchestration { get; }
/// <summary>
/// Gets the identifier associated with orchestration topic.
/// </summary>
/// <remarks>
/// All orchestration actors are subscribed to this topic.
/// </remarks>
public TopicId Topic { get; }
/// <summary>
/// Gets the cancellation token that can be used to observe cancellation requests for the orchestration.
/// </summary>
public CancellationToken CancellationToken { get; }
/// <summary>
/// Gets the associated logger factory for creating loggers within the orchestration context.
/// </summary>
public ILoggerFactory LoggerFactory { get; }
/// <summary>
/// Optional callback that is invoked for every agent response.
/// </summary>
public Func<IEnumerable<ChatMessage>, ValueTask>? ResponseCallback { get; }
/// <summary>
/// Optional callback that is invoked for every agent response.
/// </summary>
public Func<AgentRunResponseUpdate, ValueTask>? StreamingResponseCallback { get; }
}
@@ -0,0 +1,11 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Agents.Orchestration;
[JsonSerializable(typeof(SequentialOrchestration.SequentialState))]
[JsonSerializable(typeof(ConcurrentOrchestration.ConcurrentState))]
[JsonSerializable(typeof(GroupChatOrchestration.GroupChatState))]
[JsonSerializable(typeof(HandoffOrchestration.HandoffState))]
internal sealed partial class OrchestrationJsonContext : JsonSerializerContext;
@@ -1,105 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI.Agents.Runtime;
using Microsoft.Extensions.Logging;
namespace Microsoft.Agents.Orchestration;
/// <summary>
/// Represents the result of an orchestration operation that yields a value of type <typeparamref name="TValue"/>.
/// This class encapsulates the asynchronous completion of an orchestration process.
/// </summary>
/// <typeparam name="TValue">The type of the value produced by the orchestration.</typeparam>
public sealed partial class OrchestrationResult<TValue> : IAsyncDisposable
{
private readonly OrchestrationContext _context;
private readonly CancellationTokenSource _cancelSource;
private readonly TaskCompletionSource<TValue> _completion;
private readonly ILogger _logger;
private readonly IAsyncDisposable? _additionalDisposable;
private bool _isDisposed;
internal OrchestrationResult(OrchestrationContext context, TaskCompletionSource<TValue> completion, CancellationTokenSource orchestrationCancelSource, ILogger logger, IAsyncDisposable? additionalDisposable = null)
{
this._cancelSource = orchestrationCancelSource;
this._context = context;
this._completion = completion;
this._logger = logger;
this._additionalDisposable = additionalDisposable;
}
/// <summary>
/// Releases all resources used by the <see cref="OrchestrationResult{TValue}"/> instance.
/// </summary>
public async ValueTask DisposeAsync()
{
if (!this._isDisposed)
{
this._isDisposed = true;
this._cancelSource.Dispose();
if (this._additionalDisposable is { } ad)
{
await ad.DisposeAsync().ConfigureAwait(false);
}
}
}
/// <summary>
/// Gets the orchestration name associated with this orchestration result.
/// </summary>
public string Orchestration => this._context.Orchestration;
/// <summary>
/// Gets the topic identifier associated with this orchestration result.
/// </summary>
public TopicId Topic => this._context.Topic;
/// <summary>
/// Gets a task that represents the completion of the orchestration result.
/// </summary>
public Task<TValue> Task => this._completion.Task;
/// <summary>
/// Cancel the orchestration associated with this result.
/// </summary>
/// <exception cref="ObjectDisposedException">Thrown if this instance has been disposed.</exception>
/// <remarks>
/// Cancellation is not expected to immediately halt the orchestration. Messages that
/// are already in-flight may still be processed.
/// </remarks>
public void Cancel()
{
#if NET
ObjectDisposedException.ThrowIf(this._isDisposed, this);
#else
if (this._isDisposed)
{
throw new ObjectDisposedException(this.GetType().Name);
}
#endif
this.LogOrchestrationResultCanceled(this.Orchestration, this.Topic);
this._cancelSource.Cancel();
}
/// <summary>Enable directly awaiting an <see cref="OrchestrationResult{TValue}"/> by using <see cref="Task"/>'s awaiter.</summary>
[EditorBrowsable(EditorBrowsableState.Never)]
public TaskAwaiter<TValue> GetAwaiter() => this.Task.GetAwaiter();
/// <summary>
/// Logs <see cref="OrchestrationResult{TValue}"/> canceled the orchestration.
/// </summary>
[LoggerMessage(
Level = LogLevel.Error,
Message = "CANCELED {Orchestration}: {Topic}")]
private partial void LogOrchestrationResultCanceled(
string orchestration,
TopicId topic);
}
@@ -1,57 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.AI.Agents;
using Microsoft.Extensions.AI.Agents.Runtime;
using Microsoft.Extensions.Logging;
namespace Microsoft.Agents.Orchestration;
/// <summary>
/// An actor used with the <see cref="SequentialOrchestration{TInput,TOutput}"/>.
/// </summary>
internal sealed class SequentialActor : AgentActor
{
private readonly ActorType _nextAgent;
/// <summary>
/// Initializes a new instance of the <see cref="SequentialActor"/> class.
/// </summary>
/// <param name="id">The unique identifier of the agent.</param>
/// <param name="runtime">The runtime associated with the agent.</param>
/// <param name="context">The orchestration context.</param>
/// <param name="agent">An <see cref="AIAgent"/>.</param>
/// <param name="nextAgent">The identifier of the next agent for which to handoff the result</param>
/// <param name="logger">The logger to use for the actor</param>
public SequentialActor(ActorId id, IAgentRuntime runtime, OrchestrationContext context, AIAgent agent, ActorType nextAgent, ILogger<SequentialActor>? logger = null)
: base(id, runtime, context, agent, logger)
{
logger?.LogInformation("ACTOR {ActorId} {NextAgent}", this.Id, nextAgent);
this._nextAgent = nextAgent;
this.RegisterMessageHandler<SequentialMessages.Request>(this.HandleAsync);
this.RegisterMessageHandler<SequentialMessages.Response>(this.HandleAsync);
}
public ValueTask HandleAsync(SequentialMessages.Request item, MessageContext messageContext, CancellationToken cancellationToken) =>
this.InvokeAgentAsync(item.Messages, messageContext, cancellationToken);
public ValueTask HandleAsync(SequentialMessages.Response item, MessageContext messageContext, CancellationToken cancellationToken) =>
this.InvokeAgentAsync([item.Message], messageContext, cancellationToken);
private async ValueTask InvokeAgentAsync(IList<ChatMessage> input, MessageContext messageContext, CancellationToken cancellationToken)
{
this.Logger.LogInformation("INVOKE {ActorId} {NextAgent}", this.Id, this._nextAgent);
this.Logger.LogSequentialAgentInvoke(this.Id);
ChatMessage response = await this.RunAsync(input, cancellationToken).ConfigureAwait(false);
this.Logger.LogSequentialAgentResult(this.Id, response.Text);
await this.PublishMessageAsync(new SequentialMessages.Response(response), this._nextAgent, cancellationToken: cancellationToken).ConfigureAwait(false);
}
}
@@ -1,22 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.Orchestration;
/// <summary>
/// A message that describes the input task and captures results for a <see cref="SequentialOrchestration{TInput,TOutput}"/>.
/// </summary>
internal static class SequentialMessages
{
/// <summary>
/// Represents a request containing a sequence of chat messages to be processed by the sequential orchestration.
/// </summary>
public sealed record Request(IList<ChatMessage> Messages);
/// <summary>
/// Represents a response containing the result message from the sequential orchestration.
/// </summary>
public sealed record Response(ChatMessage Message);
}
@@ -1,21 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.AI.Agents;
namespace Microsoft.Agents.Orchestration;
/// <summary>
/// An orchestration that passes the input message to the first agent, and
/// then the subsequent result to the next agent, etc...
/// </summary>
public sealed class SequentialOrchestration : SequentialOrchestration<string, string>
{
/// <summary>
/// Initializes a new instance of the <see cref="SequentialOrchestration"/> class.
/// </summary>
/// <param name="members">The agents to be orchestrated.</param>
public SequentialOrchestration(params AIAgent[] members)
: base(members)
{
}
}
@@ -1,66 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.AI.Agents;
using Microsoft.Extensions.AI.Agents.Runtime;
using Microsoft.Extensions.Logging;
namespace Microsoft.Agents.Orchestration;
/// <summary>
/// An orchestration that provides the input message to the first agent
/// and sequentially passes each agent result to the next agent.
/// </summary>
public class SequentialOrchestration<TInput, TOutput> : AgentOrchestration<TInput, TOutput>
{
/// <summary>
/// Initializes a new instance of the <see cref="SequentialOrchestration{TInput, TOutput}"/> class.
/// </summary>
/// <param name="agents">The agents participating in the orchestration.</param>
public SequentialOrchestration(params AIAgent[] agents)
: base(agents)
{
}
/// <inheritdoc />
protected override async ValueTask StartAsync(IAgentRuntime runtime, TopicId topic, IEnumerable<ChatMessage> input, ActorType? entryAgent)
{
if (!entryAgent.HasValue)
{
throw new ArgumentException("Entry agent is not defined.", nameof(entryAgent));
}
await runtime.PublishMessageAsync(new SequentialMessages.Request([.. input]), entryAgent.Value).ConfigureAwait(false);
}
/// <inheritdoc />
protected override async ValueTask<ActorType?> RegisterOrchestrationAsync(IAgentRuntime runtime, OrchestrationContext context, RegistrationContext registrar, ILogger logger)
{
ActorType outputType = await registrar.RegisterResultTypeAsync<SequentialMessages.Response>(response => [response.Message]).ConfigureAwait(false);
// Each agent handsoff its result to the next agent.
ActorType nextAgent = outputType;
for (int index = this.Members.Count - 1; index >= 0; --index)
{
AIAgent agent = this.Members[index];
nextAgent = await RegisterAgentAsync(agent, index, nextAgent).ConfigureAwait(false);
logger.LogRegisterActor(this.OrchestrationLabel, nextAgent, "MEMBER", index + 1);
}
return nextAgent;
ValueTask<ActorType> RegisterAgentAsync(AIAgent agent, int index, ActorType nextAgent) =>
runtime.RegisterOrchestrationAgentAsync(
this.GetAgentType(context.Topic, index),
(agentId, runtime) =>
{
SequentialActor actor = new(agentId, runtime, context, agent, nextAgent, context.LoggerFactory.CreateLogger<SequentialActor>());
return new ValueTask<IRuntimeActor>(actor);
});
}
private ActorType GetAgentType(TopicId topic, int index) => this.FormatAgentType(topic, $"Agent_{index + 1}");
}
@@ -1,33 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.AI.Agents.Runtime;
using Microsoft.Extensions.Logging;
namespace Microsoft.Agents.Orchestration;
/// <summary>
/// Extensions for logging <see cref="SequentialOrchestration{TInput, TOutput}"/>.
/// </summary>
/// <remarks>
/// This extension uses the <see cref="LoggerMessageAttribute"/> to
/// generate logging code at compile time to achieve optimized code.
/// </remarks>
[ExcludeFromCodeCoverage]
internal static partial class SequentialOrchestrationLogMessages
{
[LoggerMessage(
Level = LogLevel.Trace,
Message = "REQUEST Sequential agent [{AgentId}]")]
public static partial void LogSequentialAgentInvoke(
this ILogger logger,
ActorId agentId);
[LoggerMessage(
Level = LogLevel.Trace,
Message = "RESULT Sequential agent [{AgentId}]: {Message}")]
public static partial void LogSequentialAgentResult(
this ILogger logger,
ActorId agentId,
string? message);
}
@@ -0,0 +1,57 @@
// 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;
using Microsoft.Extensions.AI.Agents;
namespace Microsoft.Agents.Orchestration;
/// <summary>Provides an orchestration that passes messages sequentially through a series of agents.</summary>
public sealed partial class SequentialOrchestration : OrchestratingAgent
{
/// <summary>Initializes a new instance of the <see cref="SequentialOrchestration"/> class.</summary>
/// <param name="agents">The agents participating in the orchestration.</param>
public SequentialOrchestration(params AIAgent[] agents) : base(agents)
{
}
/// <inheritdoc />
protected override Task<AgentRunResponse> RunCoreAsync(IReadOnlyCollection<ChatMessage> messages, OrchestratingAgentContext context, CancellationToken cancellationToken) =>
this.ResumeAsync(0, messages, context, cancellationToken);
/// <inheritdoc />
protected override Task<AgentRunResponse> ResumeCoreAsync(JsonElement checkpointState, OrchestratingAgentContext context, CancellationToken cancellationToken)
{
var state = checkpointState.Deserialize(OrchestrationJsonContext.Default.SequentialState) ?? throw new InvalidOperationException("The checkpoint state is invalid.");
return this.ResumeAsync(state.Index, state.Messages, context, cancellationToken);
}
/// <inheritdoc />
private async Task<AgentRunResponse> ResumeAsync(int i, IReadOnlyCollection<ChatMessage> input, OrchestratingAgentContext context, CancellationToken cancellationToken)
{
AgentRunResponse? response = null;
for (; i < this.Agents.Count; i++)
{
this.LogOrchestrationSubagentRunning(context, this.Agents[i]);
response = await RunAsync(this.Agents[i], context, input, options: null, cancellationToken).ConfigureAwait(false);
input = response.Messages as IReadOnlyCollection<ChatMessage> ?? [.. response.Messages];
await this.CheckpointAsync(i + 1, input, context, cancellationToken).ConfigureAwait(false);
}
Debug.Assert(response is not null, "Response should not be null after processing a positive number of agents.");
return response!;
}
private Task CheckpointAsync(int index, IReadOnlyCollection<ChatMessage> messages, OrchestratingAgentContext context, CancellationToken cancellationToken) =>
context.Runtime is not null ? base.WriteCheckpointAsync(JsonSerializer.SerializeToElement(new(index, messages), OrchestrationJsonContext.Default.SequentialState), context, cancellationToken) :
Task.CompletedTask;
internal sealed record SequentialState(int Index, IReadOnlyCollection<ChatMessage> Messages);
}
@@ -1,66 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.AI.Agents;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.Orchestration;
/// <summary>
/// Populates the target result type <see cref="ChatMessage"/> into a structured output.
/// </summary>
/// <typeparam name="TOutput">The .NET type of the structured-output to deserialization target.</typeparam>
public sealed class StructuredOutputTransform<TOutput>
{
internal const string DefaultInstructions = "Respond with JSON that is populated by using the information in this conversation.";
private readonly IChatClient _client;
private readonly ChatOptions? _options;
/// <summary>
/// Initializes a new instance of the <see cref="StructuredOutputTransform{TOutput}"/> class.
/// </summary>
/// <param name="client">The chat completion service to use for generating responses.</param>
/// <param name="chatOptions">The prompt execution settings to use for the chat completion service.</param>
public StructuredOutputTransform(IChatClient client, ChatOptions? chatOptions = null)
{
Throw.IfNull(client, nameof(client));
this._client = client;
this._options = chatOptions;
}
/// <summary>
/// Gets or sets the instructions to be used as the system message for the chat completion.
/// </summary>
public string Instructions { get; init; } = DefaultInstructions;
/// <summary>
/// Transforms the provided <see cref="ChatMessage"/> into a strongly-typed structured output by invoking the chat completion service and deserializing the response.
/// </summary>
/// <param name="messages">The chat messages to process.</param>
/// <param name="serializerOptions">The JSON serializer options to use when performing any JSON serialization.</param>
/// <param name="cancellationToken">A cancellation token to observe while waiting for the task to complete.</param>
/// <returns>The structured output of type <typeparamref name="TOutput"/>.</returns>
/// <exception cref="InvalidOperationException">Thrown if the response cannot be deserialized into <typeparamref name="TOutput"/>.</exception>
public async ValueTask<TOutput> TransformAsync(IList<ChatMessage> messages, JsonSerializerOptions? serializerOptions = null, CancellationToken cancellationToken = default)
{
Throw.IfNull(messages);
ChatResponse<TOutput> response = await this._client.GetResponseAsync<TOutput>(
[
new ChatMessage(ChatRole.System, this.Instructions),
.. messages,
],
serializerOptions ?? AgentAbstractionsJsonUtilities.DefaultOptions,
this._options,
cancellationToken: cancellationToken).ConfigureAwait(false);
return response.Result;
}
}
@@ -30,10 +30,7 @@ public abstract class AIAgent
/// <summary>
/// Gets a display name for the agent, which is either the <see cref="Name"/> or <see cref="Id"/> if the name is not set.
/// </summary>
public virtual string DisplayName
{
get => this.Name ?? this.Id;
}
public virtual string DisplayName => this.Name ?? this.Id;
/// <summary>
/// Gets the description of the agent (optional).
@@ -39,7 +39,7 @@ public static partial class AgentAbstractionsJsonUtilities
// Copy the configuration from the source generated context.
JsonSerializerOptions options = new(JsonContext.Default.Options);
// Chain with all supported types from MEAI.
// Chain with all supported types from Microsoft.Extensions.AI.Abstractions.
options.TypeInfoResolverChain.Add(AIJsonUtilities.DefaultOptions.TypeInfoResolver!);
options.MakeReadOnly();
@@ -54,7 +54,9 @@ public static partial class AgentAbstractionsJsonUtilities
// Agent abstraction types
[JsonSerializable(typeof(AgentRunOptions))]
[JsonSerializable(typeof(AgentRunResponse))]
[JsonSerializable(typeof(AgentRunResponse[]))]
[JsonSerializable(typeof(AgentRunResponseUpdate))]
[JsonSerializable(typeof(AgentRunResponseUpdate[]))]
[JsonSerializable(typeof(AgentThread))]
[ExcludeFromCodeCoverage]
@@ -6,36 +6,36 @@ using System.Text.Json.Serialization;
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Source-generated JSON type information for use by all Actor abstractions.
/// 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(ActorRequestMessage))]
[JsonSerializable(typeof(ActorResponseMessage))]
[JsonSerializable(typeof(ActorWriteOperation))]
[JsonSerializable(typeof(SetValueOperation))]
[JsonSerializable(typeof(RemoveKeyOperation))]
[JsonSerializable(typeof(SendRequestOperation))]
[JsonSerializable(typeof(UpdateRequestOperation))]
[JsonSerializable(typeof(ActorReadOperation))]
[JsonSerializable(typeof(ListKeysOperation))]
[JsonSerializable(typeof(GetValueOperation))]
[JsonSerializable(typeof(ActorReadOperationBatch))]
[JsonSerializable(typeof(ActorReadResult))]
[JsonSerializable(typeof(ListKeysResult))]
[JsonSerializable(typeof(GetValueResult))]
[JsonSerializable(typeof(ActorRequest))]
[JsonSerializable(typeof(ActorRequestMessage))]
[JsonSerializable(typeof(ActorRequestUpdate))]
[JsonSerializable(typeof(ActorResponse))]
[JsonSerializable(typeof(ActorId))]
[JsonSerializable(typeof(RequestStatus))]
[JsonSerializable(typeof(ActorWriteOperationBatch))]
[JsonSerializable(typeof(ActorReadOperationBatch))]
[JsonSerializable(typeof(ReadResponse))]
[JsonSerializable(typeof(WriteResponse))]
[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;
@@ -1,67 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Represents metadata associated with an actor, including its type, unique key, and description.
/// </summary>
public readonly struct ActorMetadata : IEquatable<ActorMetadata>
{
/// <summary>
/// Initializes a new instance of the <see cref="ActorMetadata"/> class with the specified type, key, and description.
/// </summary>
/// <param name="type">The type of the actor.</param>
/// <param name="key">The unique key associated with the actor.</param>
/// <param name="description">A brief description of the actor.</param>
public ActorMetadata(ActorType type, string key, string? description = null)
{
if (!ActorId.IsValidKey(key))
{
throw new ArgumentException("Invalid actor key.", nameof(key));
}
this.Type = type;
this.Key = key;
this.Description = description;
}
/// <summary>
/// Gets an identifier that associates an actor with a specific factory function.
/// </summary>
public ActorType Type { get; }
/// <summary>
/// A unique key identifying the actor instance.
/// Strings may only be composed of alphanumeric letters (a-z, 0-9), or underscores (_).
/// </summary>
public string Key { get; }
/// <summary>
/// A brief description of the actor's purpose or functionality.
/// </summary>
public string? Description { get; }
/// <inheritdoc/>
public override readonly bool Equals(object? obj) =>
obj is ActorMetadata actorMetadata && this.Equals(actorMetadata);
/// <inheritdoc/>
public readonly bool Equals(ActorMetadata other) =>
this.Type == other.Type &&
this.Key == other.Key &&
this.Description == other.Description;
/// <inheritdoc/>
public override readonly int GetHashCode() =>
HashCode.Combine(this.Type, this.Key, this.Description);
/// <inheritdoc/>
public static bool operator ==(ActorMetadata left, ActorMetadata right) =>
left.Equals(right);
/// <inheritdoc/>
public static bool operator !=(ActorMetadata left, ActorMetadata right) =>
!(left == right);
}
@@ -9,7 +9,7 @@ namespace Microsoft.Extensions.AI.Agents.Runtime;
/// Represents a batch of read operations to be performed on an actor.
/// </summary>
/// <param name="operations">The collection of read operations to perform.</param>
public class ActorReadOperationBatch(IReadOnlyList<ActorReadOperation> operations)
public sealed class ActorReadOperationBatch(IReadOnlyList<ActorReadOperation> operations)
{
/// <summary>
/// Gets the collection of read operations to perform.
@@ -8,7 +8,7 @@ namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Represents a request to be sent to an actor.
/// </summary>
public class ActorRequest(ActorId actorId, string messageId, string method, JsonElement @params)
public sealed class ActorRequest(ActorId actorId, string messageId, string method, JsonElement @params)
{
/// <summary>
/// Gets or sets the identifier of the target actor.
@@ -10,7 +10,7 @@ namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Represents an update to an actor request's status and data.
/// </summary>
public class ActorRequestUpdate(RequestStatus status, JsonElement data)
public sealed class ActorRequestUpdate(RequestStatus status, JsonElement data)
{
/// <summary>
/// Gets the updated status of the request.
@@ -8,7 +8,7 @@ namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Represents a response handle for an actor request, providing access to the result and status updates.
/// </summary>
public class ActorResponse
public sealed class ActorResponse
{
/// <summary>
/// Gets the identifier of the actor that is processing the request.
@@ -10,7 +10,7 @@ namespace Microsoft.Extensions.AI.Agents.Runtime;
/// </summary>
/// <param name="eTag">The ETag for optimistic concurrency control.</param>
/// <param name="operations">The collection of write operations to perform.</param>
public class ActorWriteOperationBatch(string eTag, IReadOnlyCollection<ActorWriteOperation> operations)
public sealed class ActorWriteOperationBatch(string eTag, IReadOnlyCollection<ActorWriteOperation> operations)
{
/// <summary>
/// Gets the collection of write operations to perform.
@@ -1,71 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Provides extension methods for the agent runtime.
/// </summary>
public static class AgentRuntimeExtensions
{
/// <summary>
/// Retrieves an actor by its type.
/// </summary>
/// <param name="agentRuntime">The agent runtime.</param>
/// <param name="actorType">The type of the actor.</param>
/// <param name="key">An optional key to specify variations of the actor. Defaults to "default".</param>
/// <param name="lazy">If <c>true</c>, the actor is fetched lazily.</param>
/// <param name="cancellationToken">A token to cancel the operation if needed.</param>
/// <returns>A task representing the asynchronous operation, returning the actor's ID.</returns>
public static ValueTask<ActorId> GetActorAsync(this IAgentRuntime agentRuntime, ActorType actorType, string? key = null, bool lazy = true, CancellationToken cancellationToken = default)
{
Throw.IfNull(agentRuntime);
return agentRuntime.GetActorAsync(actorType.Name, key, lazy, cancellationToken);
}
/// <summary>
/// Retrieves an actor by its string representation.
/// </summary>
/// <param name="agentRuntime">The agent runtime.</param>
/// <param name="actor">The string representation of the actor.</param>
/// <param name="key">An optional key to specify variations of the actor. Defaults to "default".</param>
/// <param name="lazy">If <c>true</c>, the actor is fetched lazily.</param>
/// <param name="cancellationToken">A token to cancel the operation if needed.</param>
/// <returns>A task representing the asynchronous operation, returning the actor's ID.</returns>
public static ValueTask<ActorId> GetActorAsync(this IAgentRuntime agentRuntime, string actor, string? key = null, bool lazy = true, CancellationToken cancellationToken = default)
{
Throw.IfNull(agentRuntime);
return agentRuntime.GetActorAsync(new ActorId(actor, key ?? "default"), lazy, cancellationToken);
}
/// <summary>
/// Registers an actor factory with the runtime, associating it with a specific actor type.
/// </summary>
/// <typeparam name="TActor">The type of actor created by the factory.</typeparam>
/// <param name="agentRuntime">The agent runtime.</param>
/// <param name="type">The actor type to associate with the factory.</param>
/// <param name="factoryFunc">A function that asynchronously creates the actor instance.</param>
/// <param name="cancellationToken">A token to cancel the operation if needed.</param>
/// <returns>A task representing the asynchronous operation, returning the registered actor type.</returns>
public static ValueTask<ActorType> RegisterActorFactoryAsync<TActor>(
this IAgentRuntime agentRuntime,
ActorType type,
Func<ActorId, IAgentRuntime, ValueTask<TActor>> factoryFunc,
CancellationToken cancellationToken = default)
where TActor : IRuntimeActor
{
Throw.IfNull(agentRuntime);
Throw.IfNull(factoryFunc);
return agentRuntime.RegisterActorFactoryAsync(
type,
async ValueTask<IRuntimeActor> (actorId, runtime) => await factoryFunc(actorId, runtime).ConfigureAwait(false),
cancellationToken);
}
}
@@ -8,7 +8,7 @@ namespace Microsoft.Extensions.AI.Agents.Runtime;
/// Represents a request to read a value from the actor's state by its key.
/// </summary>
/// <param name="key">The key corresponding to the value to read from the actor's state.</param>
public class GetValueOperation(string key) : ActorStateReadOperation
public sealed class GetValueOperation(string key) : ActorStateReadOperation
{
/// <summary>
/// Gets the key corresponding to the value to read from the actor's state.
@@ -9,7 +9,7 @@ namespace Microsoft.Extensions.AI.Agents.Runtime;
/// Represents the result of a get value operation containing the retrieved value.
/// </summary>
/// <param name="value">The value retrieved from the actor's state, or null if not found.</param>
public class GetValueResult(JsonElement? value) : ActorReadResult
public sealed class GetValueResult(JsonElement? value) : ActorReadResult
{
/// <summary>
/// Gets the value retrieved from the actor's state.
@@ -1,110 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Defines the runtime environment for actors, managing message sending, subscriptions, actor resolution, and state persistence,
/// all in support of agent-based architectures.
/// </summary>
public interface IAgentRuntime : ISaveState
{
/// <summary>
/// Sends a message to an actor and gets a response.
/// This method should be used to communicate directly with an actor.
/// </summary>
/// <param name="message">The message to send.</param>
/// <param name="recipient">The actor to send the message to.</param>
/// <param name="sender">The actor sending the message. Should be <c>null</c> if sent from an external source.</param>
/// <param name="messageId">A unique identifier for the message. If <c>null</c>, a new ID will be generated.</param>
/// <param name="cancellationToken">A token to cancel the operation if needed.</param>
/// <returns>A task representing the asynchronous operation, returning the response from the actor.</returns>
ValueTask<object?> SendMessageAsync(object message, ActorId recipient, ActorId? sender = null, string? messageId = null, CancellationToken cancellationToken = default);
/// <summary>
/// Publishes a message to all agents subscribed to the given topic.
/// No responses are expected from publishing.
/// </summary>
/// <param name="message">The message to publish.</param>
/// <param name="topic">The topic to publish the message to.</param>
/// <param name="sender">The actor sending the message. Defaults to <c>null</c>.</param>
/// <param name="messageId">A unique message ID. If <c>null</c>, a new one will be generated.</param>
/// <param name="cancellationToken">A token to cancel the operation if needed.</param>
/// <returns>A task representing the asynchronous operation.</returns>
ValueTask PublishMessageAsync(object message, TopicId topic, ActorId? sender = null, string? messageId = null, CancellationToken cancellationToken = default);
/// <summary>
/// Retrieves an actor by its unique identifier.
/// </summary>
/// <param name="actorId">The unique identifier of the actor.</param>
/// <param name="lazy">If <c>true</c>, the actor is fetched lazily.</param>
/// <param name="cancellationToken">A token to cancel the operation if needed.</param>
/// <returns>A task representing the asynchronous operation, returning the actor's ID.</returns>
ValueTask<ActorId> GetActorAsync(ActorId actorId, bool lazy = true, CancellationToken cancellationToken = default);
/// <summary>
/// Saves the state of an actor.
/// The result must be JSON serializable.
/// </summary>
/// <param name="actorId">The ID of the actor whose state is being saved.</param>
/// <param name="cancellationToken">A token to cancel the operation if needed.</param>
/// <returns>A task representing the asynchronous operation, returning a dictionary of the saved state.</returns>
ValueTask<JsonElement> SaveActorStateAsync(ActorId actorId, CancellationToken cancellationToken = default);
/// <summary>
/// Loads the saved state into an actor.
/// </summary>
/// <param name="actorId">The ID of the actor whose state is being restored.</param>
/// <param name="state">The state dictionary to restore.</param>
/// <param name="cancellationToken">A token to cancel the operation if needed.</param>
/// <returns>A task representing the asynchronous operation.</returns>
ValueTask LoadActorStateAsync(ActorId actorId, JsonElement state, CancellationToken cancellationToken = default);
/// <summary>
/// Retrieves metadata for an actor.
/// </summary>
/// <param name="actorId">The ID of the actor.</param>
/// <param name="cancellationToken">A token to cancel the operation if needed.</param>
/// <returns>A task representing the asynchronous operation, returning the actor's metadata.</returns>
ValueTask<ActorMetadata> GetActorMetadataAsync(ActorId actorId, CancellationToken cancellationToken = default);
/// <summary>
/// Adds a new subscription for the runtime to handle when processing published messages.
/// </summary>
/// <param name="subscription">The subscription to add.</param>
/// <param name="cancellationToken">A token to cancel the operation if needed.</param>
/// <returns>A task representing the asynchronous operation.</returns>
ValueTask AddSubscriptionAsync(ISubscriptionDefinition subscription, CancellationToken cancellationToken = default);
/// <summary>
/// Removes a subscription from the runtime.
/// </summary>
/// <param name="subscriptionId">The unique identifier of the subscription to remove.</param>
/// <param name="cancellationToken">A token to cancel the operation if needed.</param>
/// <returns>A task representing the asynchronous operation.</returns>
/// <exception cref="KeyNotFoundException">Thrown if the subscription does not exist.</exception>
ValueTask RemoveSubscriptionAsync(string subscriptionId, CancellationToken cancellationToken = default);
/// <summary>
/// Registers an actor factory with the runtime, associating it with a specific actor type.
/// The type must be unique.
/// </summary>
/// <param name="type">The actor type to associate with the factory.</param>
/// <param name="factoryFunc">A function that asynchronously creates the actor instance.</param>
/// <param name="cancellationToken">A token to cancel the operation if needed.</param>
/// <returns>A task representing the asynchronous operation, returning the registered <see cref="ActorType"/>.</returns>
ValueTask<ActorType> RegisterActorFactoryAsync(ActorType type, Func<ActorId, IAgentRuntime, ValueTask<IRuntimeActor>> factoryFunc, CancellationToken cancellationToken = default);
/// <summary>
/// Attempts to retrieve an <see cref="IdProxyActor"/> for the specified actor.
/// </summary>
/// <param name="actorId">The ID of the actor.</param>
/// <param name="cancellationToken">A token to cancel the operation if needed.</param>
/// <returns>A task representing the asynchronous operation, returning an <see cref="IdProxyActor"/> if successful.</returns>
ValueTask<IdProxyActor?> TryGetActorProxyAsync(ActorId actorId, CancellationToken cancellationToken = default);
}
@@ -1,37 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Represents an actor within the runtime that can process messages, maintain state, and be closed when no longer needed.
/// </summary>
public interface IRuntimeActor : ISaveState
{
/// <summary>
/// Gets the unique identifier of the actor.
/// </summary>
ActorId Id { get; }
/// <summary>
/// Gets metadata associated with the actor.
/// </summary>
ActorMetadata Metadata { get; }
/// <summary>
/// Handles an incoming message for the actor.
/// This should only be called by the runtime, not by other actors.
/// </summary>
/// <param name="message">The received message. The type should match one of the expected subscription types.</param>
/// <param name="messageContext">The context of the message, providing additional metadata.</param>
/// <param name="cancellationToken">A token to cancel the operation if needed.</param>
/// <returns>
/// A task representing the asynchronous operation, returning a response to the message.
/// The response can be <c>null</c> if no reply is necessary.
/// </returns>
/// <exception cref="OperationCanceledException">Thrown if the message was canceled.</exception>
ValueTask<object?> OnMessageAsync(object message, MessageContext messageContext, CancellationToken cancellationToken = default);
}
@@ -1,39 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Extensions.AI.Agents.Runtime;
// TODO: Why is this interface needed? It's inherited by IAgentRuntime and IRuntimeActor.
// Is the former needed (does IAgentRuntime need to not only persist every actor but do so via
// this interface)? If not, these methods could be moved to IRuntimeActor.
/// <summary>
/// Defines a contract for saving and loading the state of an object as JSON.
/// </summary>
public interface ISaveState
{
/// <summary>
/// Saves the current state of the object.
/// </summary>
/// <param name="cancellationToken">A token to cancel the operation if needed.</param>
/// <returns>
/// A task representing the asynchronous operation, returning a dictionary
/// containing the saved state. The structure of the state is implementation-defined
/// but must be JSON serializable.
/// </returns>
ValueTask<JsonElement> SaveStateAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Loads a previously saved state into the object.
/// </summary>
/// <param name="state">
/// A dictionary representing the saved state. The structure of the state
/// is implementation-defined but must be JSON serializable.
/// </param>
/// <param name="cancellationToken">A token to cancel the operation if needed.</param>
/// <returns>A task representing the asynchronous operation.</returns>
ValueTask LoadStateAsync(JsonElement state, CancellationToken cancellationToken = default);
}
@@ -1,51 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Defines a subscription that matches topics and maps them to actors.
/// </summary>
public interface ISubscriptionDefinition
{
/// <summary>
/// Gets the unique identifier of the subscription.
/// </summary>
string Id { get; }
/// <summary>
/// Determines whether the specified object is equal to the current subscription.
/// </summary>
/// <param name="obj">The object to compare with the current instance.</param>
/// <returns><c>true</c> if the specified object is equal to this instance; otherwise, <c>false</c>.</returns>
bool Equals([NotNullWhen(true)] object? obj);
/// <summary>
/// Determines whether the specified subscription is equal to the current subscription.
/// </summary>
/// <param name="other">The subscription to compare.</param>
/// <returns><c>true</c> if the subscriptions are equal; otherwise, <c>false</c>.</returns>
bool Equals(ISubscriptionDefinition? other);
/// <summary>
/// Returns a hash code for this subscription.
/// </summary>
/// <returns>A hash code for the subscription.</returns>
int GetHashCode();
/// <summary>
/// Checks if a given <see cref="TopicId"/> matches the subscription.
/// </summary>
/// <param name="topic">The topic to check.</param>
/// <returns><c>true</c> if the topic matches the subscription; otherwise, <c>false</c>.</returns>
bool Matches(TopicId topic);
/// <summary>
/// Maps a <see cref="TopicId"/> to an <see cref="ActorId"/>.
/// Should only be called if <see cref="Matches"/> returns <c>true</c>.
/// </summary>
/// <param name="topic">The topic to map.</param>
/// <returns>The <see cref="ActorId"/> that should handle the topic.</returns>
ActorId MapToActor(TopicId topic);
}
@@ -1,56 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Provides an actor proxy that allows you to use an <see cref="ActorId"/> in place of its associated <see cref="IRuntimeActor"/>.
/// </summary>
public sealed class IdProxyActor : IRuntimeActor
{
/// <summary>The runtime instance used to interact with actors.</summary>
private readonly IAgentRuntime _runtime;
/// <summary>The metadata for the actor, lazy-loaded.</summary>
private ActorMetadata? _metadata;
/// <summary>
/// Initializes a new instance of the <see cref="IdProxyActor"/> class.
/// </summary>
public IdProxyActor(IAgentRuntime runtime, ActorId actorId)
{
Throw.IfNull(runtime);
this.Id = actorId;
this._runtime = runtime;
}
/// <inheritdoc />
public ActorId Id { get; }
/// <inheritdoc />
public ActorMetadata Metadata =>
this._metadata ??=
#pragma warning disable VSTHRD002 // Avoid problematic synchronous waits
this._runtime.GetActorMetadataAsync(this.Id).AsTask().GetAwaiter().GetResult();
#pragma warning restore VSTHRD002
/// <inheritdoc />
public ValueTask<object?> SendMessageAsync(object message, ActorId sender, string? messageId = null, CancellationToken cancellationToken = default) =>
this._runtime.SendMessageAsync(message, this.Id, sender, messageId, cancellationToken);
/// <inheritdoc />
public ValueTask LoadStateAsync(JsonElement state, CancellationToken cancellationToken = default) =>
this._runtime.LoadActorStateAsync(this.Id, state, cancellationToken);
/// <inheritdoc />
public ValueTask<JsonElement> SaveStateAsync(CancellationToken cancellationToken = default) =>
this._runtime.SaveActorStateAsync(this.Id, cancellationToken);
/// <inheritdoc />
ValueTask<object?> IRuntimeActor.OnMessageAsync(object message, MessageContext messageContext, CancellationToken cancellationToken) =>
new((object?)null);
}
@@ -1,429 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using Microsoft.Shared.Diagnostics;
#pragma warning disable CA1711 // Identifiers should not have incorrect suffix
namespace Microsoft.Extensions.AI.Agents.Runtime.InProcess;
/// <summary>Provides an in-process/in-memory implementation of the agent runtime.</summary>
public sealed partial class InProcessRuntime : IAgentRuntime, IAsyncDisposable
{
private static readonly UnboundedChannelOptions s_singleReaderOptions = new();
private readonly Dictionary<ActorType, Func<ActorId, IAgentRuntime, ValueTask<IRuntimeActor>>> _actorFactories = [];
private readonly Dictionary<string, ISubscriptionDefinition> _subscriptions = [];
private readonly Channel<MessageToProcess> _messages = Channel.CreateUnbounded<MessageToProcess>(s_singleReaderOptions);
private readonly CancellationTokenSource _shutdownTokenSource = new();
private Task? _messageDeliveryTask;
private int _remainingWork = 1; // initial count of 1 represents overall operation, decremented when shutting down.
private int _signaledCompletion = 0;
// Internal for testing purposes.
internal readonly Dictionary<ActorId, IRuntimeActor> _actorInstances = [];
/// <summary>Initializes a new instance of the in-memory runtime.</summary>
public InProcessRuntime() { }
/// <summary>Gets the number of pending work items.</summary>
/// <remarks>Internal for testing purposes.</remarks>
internal int MessageCountForTesting => this._remainingWork - (1 - this._signaledCompletion);
/// <summary>Creates and starts a new <see cref="InProcessRuntime"/> instance.</summary>
/// <returns>The started runtime.</returns>
public static InProcessRuntime StartNew()
{
InProcessRuntime runtime = new();
runtime.Start();
return runtime;
}
/// <summary>Starts the runtime.</summary>
/// <exception cref="InvalidOperationException">Thrown if the runtime is already started.</exception>
public void Start()
{
ThrowIfInvalid(this._signaledCompletion != 0 || this._messageDeliveryTask is not null, "Runtime was already started or shutdown.");
CancellationToken ct = this._shutdownTokenSource.Token;
this._messageDeliveryTask = Task.Run(() => this.RunAsync(ct));
}
/// <inheritdoc/>
public async ValueTask DisposeAsync()
{
if (Interlocked.Exchange(ref this._signaledCompletion, 1) == 0 && this._messageDeliveryTask is not null)
{
this.DecrementRemainingWork();
this._shutdownTokenSource.Cancel();
this._shutdownTokenSource.Dispose();
await this._messageDeliveryTask.ConfigureAwait(false);
}
}
/// <inheritdoc/>
public ValueTask PublishMessageAsync(object message, TopicId topic, ActorId? sender = null, string? messageId = null, CancellationToken cancellationToken = default)
{
Throw.IfNull(message);
MessageToProcess m = new(this, message, messageId, sender, topic, cancellationToken);
this.IncrementRemainingWork();
this._messages.Writer.TryWrite(m);
return new(m.ResultTcs.Task);
}
/// <inheritdoc/>
public ValueTask<object?> SendMessageAsync(object message, ActorId recipient, ActorId? sender = null, string? messageId = null, CancellationToken cancellationToken = default)
{
Throw.IfNull(message);
MessageToProcess m = new(this, message, messageId, sender, recipient, cancellationToken);
this.IncrementRemainingWork();
this._messages.Writer.TryWrite(m);
return new(m.ResultTcs.Task);
}
/// <inheritdoc/>
public async ValueTask<ActorId> GetActorAsync(ActorId actorId, bool lazy = true, CancellationToken cancellationToken = default)
{
if (!lazy)
{
await this.EnsureActorAsync(actorId, cancellationToken).ConfigureAwait(false);
}
return actorId;
}
/// <inheritdoc/>
public async ValueTask<ActorMetadata> GetActorMetadataAsync(ActorId actorId, CancellationToken cancellationToken = default)
{
IRuntimeActor actor = await this.EnsureActorAsync(actorId, cancellationToken).ConfigureAwait(false);
return actor.Metadata;
}
/// <inheritdoc/>
public async ValueTask<TActor> TryGetUnderlyingActorInstanceAsync<TActor>(ActorId actorId, CancellationToken cancellationToken = default) where TActor : IRuntimeActor
{
IRuntimeActor actor = await this.EnsureActorAsync(actorId, cancellationToken).ConfigureAwait(false);
if (actor is not TActor concreteActor)
{
throw new InvalidOperationException($"Actor with name {actorId.Type} is not of type {typeof(TActor).Name}.");
}
return concreteActor;
}
/// <inheritdoc/>
public async ValueTask LoadActorStateAsync(ActorId actorId, JsonElement state, CancellationToken cancellationToken = default)
{
IRuntimeActor actor = await this.EnsureActorAsync(actorId, cancellationToken).ConfigureAwait(false);
await actor.LoadStateAsync(state, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc/>
public async ValueTask<JsonElement> SaveActorStateAsync(ActorId actorId, CancellationToken cancellationToken = default)
{
IRuntimeActor actor = await this.EnsureActorAsync(actorId, cancellationToken).ConfigureAwait(false);
return await actor.SaveStateAsync(cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc/>
public ValueTask AddSubscriptionAsync(ISubscriptionDefinition subscription, CancellationToken cancellationToken = default)
{
Throw.IfNull(subscription);
ThrowIfInvalid(this._subscriptions.ContainsKey(subscription.Id), "Subscription with the specified ID already exists.");
this._subscriptions.Add(subscription.Id, subscription);
return default;
}
/// <inheritdoc/>
public ValueTask RemoveSubscriptionAsync(string subscriptionId, CancellationToken cancellationToken = default)
{
Throw.IfNull(subscriptionId);
ThrowIfInvalid(!this._subscriptions.ContainsKey(subscriptionId), "Subscription with the specified ID does not exist.");
this._subscriptions.Remove(subscriptionId);
return default;
}
/// <inheritdoc/>
public async ValueTask LoadStateAsync(JsonElement state, CancellationToken cancellationToken = default)
{
foreach (JsonProperty actorIdStr in state.EnumerateObject())
{
ActorId actorId = ActorId.Parse(actorIdStr.Name);
if (this._actorFactories.ContainsKey(actorId.Type))
{
IRuntimeActor actor = await this.EnsureActorAsync(actorId, cancellationToken).ConfigureAwait(false);
await actor.LoadStateAsync(actorIdStr.Value, cancellationToken).ConfigureAwait(false);
}
}
}
/// <inheritdoc/>
public async ValueTask<JsonElement> SaveStateAsync(CancellationToken cancellationToken = default)
{
Dictionary<string, JsonElement> state = [];
foreach (KeyValuePair<ActorId, IRuntimeActor> actor in this._actorInstances)
{
state[actor.Key.ToString()] = await actor.Value.SaveStateAsync(cancellationToken).ConfigureAwait(false);
}
return JsonSerializer.SerializeToElement(state, InProcessRuntimeContext.Default.DictionaryStringJsonElement);
}
/// <inheritdoc/>
public async ValueTask<ActorType> RegisterActorFactoryAsync(ActorType type, Func<ActorId, IAgentRuntime, ValueTask<IRuntimeActor>> factoryFunc, CancellationToken cancellationToken = default)
{
Throw.IfNull(factoryFunc);
ThrowIfInvalid(this._actorFactories.ContainsKey(type), "Actor type already registered.");
this._actorFactories.Add(type, factoryFunc);
return type;
}
/// <inheritdoc/>
public async ValueTask<IdProxyActor?> TryGetActorProxyAsync(ActorId actorId, CancellationToken cancellationToken = default) =>
new(this, actorId);
private async Task RunAsync(CancellationToken cancellationToken)
{
try
{
Dictionary<long, Task> pendingTasks = [];
long currentId = 0;
await foreach (MessageToProcess message in this._messages.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
{
this.DecrementRemainingWork();
ValueTask processTask = message.InvokeAsync(cancellationToken);
if (!processTask.IsCompleted)
{
currentId++;
Task t = WaitAndRemoveAsync(currentId, processTask);
lock (pendingTasks)
{
if (!t.IsCompleted)
{
pendingTasks.Add(currentId, t);
}
}
async Task WaitAndRemoveAsync(long taskId, ValueTask processTask)
{
try
{
await processTask.ConfigureAwait(false);
}
finally
{
lock (pendingTasks)
{
pendingTasks.Remove(taskId);
}
}
}
}
}
await Task.WhenAll(pendingTasks.Values).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// Ignore cancellation exceptions, as they are expected when the runtime is shutting down.
}
finally
{
foreach (var actor in this._actorInstances)
{
if (actor.Value is IAsyncDisposable closeableActor)
{
await closeableActor.DisposeAsync().ConfigureAwait(false);
}
}
}
}
private static readonly Func<MessageToProcess, CancellationToken, ValueTask<object?>> s_publishServicer =
async (MessageToProcess message, CancellationToken cancellationToken) =>
{
Debug.Assert(message.Topic.HasValue);
List<Task>? tasks = null;
TopicId topic = message.Topic!.Value;
foreach (KeyValuePair<string, ISubscriptionDefinition> subscription in message.Runtime._subscriptions)
{
if (subscription.Value.Matches(topic))
{
(tasks ??= []).Add(ProcessSubscriptionAsync(message, subscription.Value, topic, cancellationToken));
}
static async Task ProcessSubscriptionAsync(
MessageToProcess message, ISubscriptionDefinition subscription, TopicId topic, CancellationToken cancellationToken)
{
using CancellationTokenSource combinedSource = CancellationTokenSource.CreateLinkedTokenSource(message.Cancellation, cancellationToken);
combinedSource.Token.ThrowIfCancellationRequested();
ActorId actorId = subscription.MapToActor(topic);
ActorId? sender = message.Sender;
if (sender is null || sender != actorId)
{
IRuntimeActor actor = await message.Runtime.EnsureActorAsync(actorId, combinedSource.Token).ConfigureAwait(false);
await actor.OnMessageAsync(message.Message, new()
{
MessageId = message.MessageId,
Sender = sender,
Topic = topic,
}, combinedSource.Token).ConfigureAwait(false);
}
}
}
if (tasks is not null)
{
await Task.WhenAll(tasks).ConfigureAwait(false);
}
// This method is effectively void, with the result never being used. But it's typed the same as SendMessageServicerAsync
// in order to be able to share the same consuming code.
return null;
};
private static readonly Func<MessageToProcess, CancellationToken, ValueTask<object?>> s_sendServicer =
async (MessageToProcess message, CancellationToken cancellationToken) =>
{
Debug.Assert(message.Receiver.HasValue);
using CancellationTokenSource combinedSource = CancellationTokenSource.CreateLinkedTokenSource(message.Cancellation, cancellationToken);
IRuntimeActor actor = await message.Runtime.EnsureActorAsync(message.Receiver!.Value, combinedSource.Token).ConfigureAwait(false);
return await actor.OnMessageAsync(message.Message, new()
{
MessageId = message.MessageId,
Sender = message.Sender,
}, combinedSource.Token).ConfigureAwait(false);
};
private async ValueTask<IRuntimeActor> EnsureActorAsync(ActorId actorId, CancellationToken cancellationToken)
{
if (!this._actorInstances.TryGetValue(actorId, out IRuntimeActor? actor))
{
this._actorFactories.TryGetValue(actorId.Type, out Func<ActorId, IAgentRuntime, ValueTask<IRuntimeActor>>? factoryFunc);
ThrowIfInvalid(factoryFunc is null, "Actor with the specified name not found.");
actor = await factoryFunc(actorId, this).ConfigureAwait(false);
this._actorInstances.Add(actorId, actor);
}
return actor;
}
private void IncrementRemainingWork()
{
int current;
do
{
current = this._remainingWork;
ThrowIfInvalid(current <= 0, "Runtime has already shut down.");
}
while (Interlocked.CompareExchange(ref this._remainingWork, current + 1, current) != current);
}
private void DecrementRemainingWork()
{
int current;
do
{
current = this._remainingWork;
ThrowIfInvalid(current <= 0, "Runtime has already shut down.");
}
while (Interlocked.CompareExchange(ref this._remainingWork, current - 1, current) != current);
if (current == 1)
{
this._messages.Writer.TryComplete();
}
}
private static void ThrowIfInvalid([DoesNotReturnIf(true)] bool isInvalid, string message)
{
if (isInvalid)
{
throw new InvalidOperationException(message);
}
}
[JsonSerializable(typeof(Dictionary<string, JsonElement>))]
private sealed partial class InProcessRuntimeContext : JsonSerializerContext;
private sealed class MessageToProcess
{
public MessageToProcess(InProcessRuntime runtime, object message, string? messageId, ActorId? sender, ActorId receiver, CancellationToken cancellationToken) :
this(runtime, message, messageId, sender, s_sendServicer, cancellationToken)
{
this.Receiver = Throw.IfNull(receiver);
}
public MessageToProcess(InProcessRuntime runtime, object message, string? messageId, ActorId? sender, TopicId topic, CancellationToken cancellationToken) :
this(runtime, message, messageId, sender, s_publishServicer, cancellationToken)
{
this.Topic = Throw.IfNull(topic);
}
private MessageToProcess(InProcessRuntime runtime, object message, string? messageId, ActorId? sender, Func<MessageToProcess, CancellationToken, ValueTask<object?>> servicer, CancellationToken cancellationToken)
{
this.Runtime = runtime;
this.Message = message;
this.MessageId = messageId ?? Guid.NewGuid().ToString();
this.Sender = sender;
this.Servicer = servicer;
this.Cancellation = cancellationToken;
}
public InProcessRuntime Runtime { get; }
public object Message { get; }
public string MessageId { get; }
public ActorId? Sender { get; }
public TopicId? Topic { get; }
public ActorId? Receiver { get; }
public CancellationToken Cancellation { get; }
public TaskCompletionSource<object?> ResultTcs { get; } = new();
private Func<MessageToProcess, CancellationToken, ValueTask<object?>> Servicer { get; }
public async ValueTask InvokeAsync(CancellationToken cancellationToken)
{
try
{
this.ResultTcs.SetResult(await this.Servicer(this, cancellationToken).ConfigureAwait(false));
}
catch (OperationCanceledException exception)
{
this.ResultTcs.TrySetCanceled(exception.CancellationToken);
}
catch (Exception exception)
{
this.ResultTcs.SetException(exception);
}
}
}
}
@@ -9,7 +9,7 @@ namespace Microsoft.Extensions.AI.Agents.Runtime;
/// </summary>
/// <param name="continuationToken">Optional token for pagination to continue listing from a previous operation.</param>
/// <param name="keyPrefix">Optional prefix to filter keys. Only keys starting with this prefix will be returned.</param>
public class ListKeysOperation(string? continuationToken, string? keyPrefix = null) : ActorStateReadOperation
public sealed class ListKeysOperation(string? continuationToken, string? keyPrefix = null) : ActorStateReadOperation
{
/// <summary>
/// Gets the continuation token for pagination.
@@ -10,7 +10,7 @@ namespace Microsoft.Extensions.AI.Agents.Runtime;
/// </summary>
/// <param name="keys">The collection of keys found in the actor's state.</param>
/// <param name="continuationToken">Optional token for pagination to retrieve additional keys.</param>
public class ListKeysResult(IReadOnlyCollection<string> keys, string? continuationToken) : ActorReadResult
public sealed class ListKeysResult(IReadOnlyCollection<string> keys, string? continuationToken) : ActorReadResult
{
/// <summary>
/// Gets the collection of keys found in the actor's state.
@@ -1,48 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Text.Json;
using System.Threading;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Represents the context of a message being sent within the agent runtime.
/// </summary>
/// <remarks>
/// This includes metadata such as the sender, topic, ahd RPC status.
/// </remarks>
public sealed class MessageContext
{
private string? _messageId;
/// <summary>
/// Gets or sets the unique identifier for this message.
/// </summary>
public string MessageId
{
get => this._messageId ?? Interlocked.CompareExchange(ref this._messageId, Guid.NewGuid().ToString(), null) ?? this._messageId;
set => this._messageId = Throw.IfNullOrEmpty(value);
}
/// <summary>
/// Gets or sets the sender of the message.
/// If <c>null</c>, the sender is unspecified.
/// </summary>
public ActorId? Sender { get; set; }
/// <summary>
/// Gets or sets the topic associated with the message.
/// If <c>null</c>, the message is not tied to a specific topic.
/// </summary>
public TopicId? Topic { get; set; }
/// <summary>
/// Gets or sets a value indicating whether this message is part of an RPC (Remote Procedure Call).
/// </summary>
public bool IsRpc { get; set; }
/// <summary>Gets or sets the serializer options to be used when performing JSON serialization associated with this message.</summary>
public JsonSerializerOptions? SerializerOptions { get; set; }
}
@@ -10,7 +10,7 @@ namespace Microsoft.Extensions.AI.Agents.Runtime;
/// </summary>
/// <param name="eTag">The actor's last-known ETag value.</param>
/// <param name="results">The ordered collection of results.</param>
public class ReadResponse(string eTag, IReadOnlyList<ActorReadResult> results)
public sealed class ReadResponse(string eTag, IReadOnlyList<ActorReadResult> results)
{
/// <summary>
/// Gets the version of the state update.
@@ -1,207 +0,0 @@
// 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.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Provides a base implementation of <see cref="IRuntimeActor"/>.
/// </summary>
public abstract class RuntimeActor : IRuntimeActor
{
private static readonly JsonElement s_emptyElement = JsonDocument.Parse("{}").RootElement;
/// <summary>
/// The activity source for tracing.
/// </summary>
public static readonly ActivitySource TraceSource = new($"{typeof(IRuntimeActor).Namespace}");
private readonly Dictionary<Type, HandlerInvoker> _handlerInvokers = [];
private readonly IAgentRuntime _runtime;
private delegate ValueTask<object?> HandlerInvoker(object? message, MessageContext messageContext, CancellationToken cancellationToken);
/// <summary>
/// Provides logging capabilities used for diagnostic and operational information.
/// </summary>
protected internal ILogger Logger { get; }
/// <summary>
/// Gets the unique identifier of the actor.
/// </summary>
public ActorId Id { get; }
/// <summary>
/// Gets the metadata of the actor.
/// </summary>
public ActorMetadata Metadata { get; }
/// <summary>
/// Initializes a new instance of the RuntimeActor class with the specified identifier, runtime, description, and optional logger.
/// </summary>
/// <param name="id">The unique identifier of the actor.</param>
/// <param name="runtime">The runtime environment in which the actor operates.</param>
/// <param name="description">A brief description of the actor's purpose.</param>
/// <param name="logger">An optional logger for recording diagnostic information.</param>
protected RuntimeActor(
ActorId id,
IAgentRuntime runtime,
string? description = null,
ILogger? logger = null)
{
Throw.IfNull(runtime);
this.Id = id;
this._runtime = runtime;
this.Logger = logger ?? NullLogger.Instance;
this.Metadata = new ActorMetadata(this.Id.Type, this.Id.Key, description);
}
/// <summary>Registers a handler for <typeparamref name="TInput"/>.</summary>
/// <typeparam name="TInput">The type of the input message for the handler.</typeparam>
/// <param name="messageHandler">The handler function that processes the message.</param>
/// <exception cref="InvalidOperationException">Thrown when a handler for the specified type is already registered.</exception>
/// <remarks>
/// The base implementation of <see cref="OnMessageAsync"/> will use these registered handlers to process incoming messages.
/// </remarks>
protected void RegisterMessageHandler<TInput>(Action<TInput, MessageContext> messageHandler)
{
_ = Throw.IfNull(messageHandler);
this.RegisterMessageHandler<TInput, object?>(async (input, ctx, cancellationToken) =>
{
messageHandler(input, ctx);
return null;
});
}
/// <summary>Registers a handler for <typeparamref name="TInput"/>.</summary>
/// <typeparam name="TInput">The type of the input message for the handler.</typeparam>
/// <param name="messageHandler">The handler function that processes the message.</param>
/// <exception cref="InvalidOperationException">Thrown when a handler for the specified type is already registered.</exception>
/// <remarks>
/// The base implementation of <see cref="OnMessageAsync"/> will use these registered handlers to process incoming messages.
/// </remarks>
protected void RegisterMessageHandler<TInput>(Func<TInput, MessageContext, CancellationToken, ValueTask> messageHandler)
{
_ = Throw.IfNull(messageHandler);
this.RegisterMessageHandler<TInput, object?>(async (input, ctx, cancellationToken) =>
{
await messageHandler(input, ctx, cancellationToken).ConfigureAwait(false);
return null;
});
}
/// <summary>Registers a handler for <typeparamref name="TInput"/>.</summary>
/// <typeparam name="TInput">The type of the input message for the handler.</typeparam>
/// <typeparam name="TOutput">The type of the output message for the handler.</typeparam>
/// <param name="messageHandler">The handler function that processes the message.</param>
/// <exception cref="InvalidOperationException">Thrown when a handler for the specified type is already registered.</exception>
/// <remarks>
/// The base implementation of <see cref="OnMessageAsync"/> will use these registered handlers to process incoming messages.
/// </remarks>
protected void RegisterMessageHandler<TInput, TOutput>(Func<TInput, MessageContext, TOutput> messageHandler)
{
_ = Throw.IfNull(messageHandler);
this.RegisterMessageHandler<TInput, object?>(async (input, ctx, cancellationToken) => messageHandler(input, ctx));
}
/// <summary>Registers a handler for <typeparamref name="TInput"/> that produces a <typeparamref name="TOutput"/>.</summary>
/// <typeparam name="TInput">The type of the input message for the handler.</typeparam>
/// <typeparam name="TOutput">The type of the output message for the handler.</typeparam>
/// <param name="messageHandler">The handler function that processes the message.</param>
/// <exception cref="InvalidOperationException">Thrown when a handler for the specified type is already registered.</exception>
/// <remarks>
/// The base implementation of <see cref="OnMessageAsync"/> will use these registered handlers to process incoming messages.
/// </remarks>
protected void RegisterMessageHandler<TInput, TOutput>(Func<TInput, MessageContext, CancellationToken, ValueTask<TOutput>> messageHandler)
{
_ = Throw.IfNull(messageHandler);
if (this._handlerInvokers.ContainsKey(typeof(TInput)))
{
throw new InvalidOperationException($"A handler for type {typeof(TInput)} is already registered.");
}
this._handlerInvokers.Add(
typeof(TInput),
async (message, messageContext, cancellationToken) => await messageHandler((TInput)message!, messageContext, cancellationToken).ConfigureAwait(false));
}
/// <summary>
/// Handles an incoming message by determining its type and invoking the corresponding handler method if available.
/// </summary>
/// <param name="message">The message object to be handled.</param>
/// <param name="messageContext">The context associated with the message.</param>
/// <param name="cancellationToken">A token used to cancel the operation if needed.</param>
/// <returns>A ValueTask that represents the asynchronous operation, containing the response object or null.</returns>
public ValueTask<object?> OnMessageAsync(object message, MessageContext messageContext, CancellationToken cancellationToken = default)
{
// Get the handler for the message type, and invoke it, if it exists.
if (message is not null && this._handlerInvokers.TryGetValue(message.GetType(), out HandlerInvoker? handlerInvoker))
{
return handlerInvoker(message, messageContext, cancellationToken);
}
return new((object?)null);
}
/// <inheritdoc/>
public virtual ValueTask<JsonElement> SaveStateAsync(CancellationToken cancellationToken = default) =>
new(s_emptyElement);
/// <inheritdoc/>
public virtual ValueTask LoadStateAsync(JsonElement state, CancellationToken cancellationToken = default) =>
default;
/// <summary>
/// Sends a message to a specified recipient actor through the runtime.
/// </summary>
/// <param name="actor">The requested actor's type.</param>
/// <param name="cancellationToken">A token used to cancel the operation if needed.</param>
/// <returns>A ValueTask that represents the asynchronous operation, returning the response object or null.</returns>
protected async ValueTask<ActorId?> GetActorAsync(ActorType actor, CancellationToken cancellationToken = default)
{
try
{
return await this._runtime.GetActorAsync(actor, lazy: false, cancellationToken: cancellationToken).ConfigureAwait(false);
}
catch (InvalidOperationException)
{
return null;
}
}
/// <summary>
/// Sends a message to a specified recipient actor through the runtime.
/// </summary>
/// <param name="message">The message object to send.</param>
/// <param name="recipient">The recipient actor's identifier.</param>
/// <param name="messageId">An optional identifier for the message.</param>
/// <param name="cancellationToken">A token used to cancel the operation if needed.</param>
/// <returns>A ValueTask that represents the asynchronous operation, returning the response object or null.</returns>
protected ValueTask<object?> SendMessageAsync(object message, ActorId recipient, string? messageId = null, CancellationToken cancellationToken = default) =>
this._runtime.SendMessageAsync(message, recipient, sender: this.Id, messageId, cancellationToken);
/// <summary>
/// Publishes a message to all actors subscribed to a specific topic through the runtime.
/// </summary>
/// <param name="message">The message object to publish.</param>
/// <param name="topic">The topic identifier to which the message is published.</param>
/// <param name="messageId">An optional identifier for the message.</param>
/// <param name="cancellationToken">A token used to cancel the operation if needed.</param>
/// <returns>A ValueTask that represents the asynchronous publish operation.</returns>
protected ValueTask PublishMessageAsync(object message, TopicId topic, string? messageId = null, CancellationToken cancellationToken = default) =>
this._runtime.PublishMessageAsync(message, topic, sender: this.Id, messageId, cancellationToken);
}
@@ -1,110 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics.CodeAnalysis;
using System.Text.RegularExpressions;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Provides a topic identifier that defines the scope of a broadcast message.
/// </summary>
/// <remarks>
/// The agent runtime implements a publish-subscribe model through its broadcast API,
/// where messages must be published with a specific topic.
/// </remarks>
public readonly partial struct TopicId : IEquatable<TopicId>
{
private const string TypePattern = @"^[\w-.:=]+$";
#if NET
[GeneratedRegex(TypePattern)]
private static partial Regex TypeRegex();
#else
private static Regex TypeRegex() => s_typeRegex;
private static readonly Regex s_typeRegex = new(TypePattern, RegexOptions.Compiled);
#endif
/// <summary>
/// Initializes a new instance of the <see cref="TopicId"/> struct.
/// </summary>
/// <param name="type">The type of the topic. Must match the pattern: <c>^[\w-.:=]+$</c></param>
/// <param name="source">The source of the event.</param>
public TopicId(string type, string? source = null)
{
Throw.IfNull(type);
if (!TypeRegex().IsMatch(type))
{
Throw.ArgumentException(nameof(type), "Invalid type format.");
}
// TODO: What validation should be performed on source? The cited cloudevents spec suggests it should be a URI reference.
this.Type = type;
this.Source = source ?? "default";
}
/// <summary>
/// Gets the type of the event that this <see cref="TopicId"/> represents.
/// </summary>
/// <remarks>
/// This adheres to the CloudEvents specification.
/// <see href="https://github.com/cloudevents/spec/blob/main/cloudevents/spec.md#type">CloudEvents Type</see>.
/// </remarks>
public string Type { get; }
/// <summary>
/// Gets the source that identifies the context in which an event happened.
/// </summary>
/// <remarks>
/// This adheres to the CloudEvents specification.
/// <see href="https://github.com/cloudevents/spec/blob/main/cloudevents/spec.md#source-1">CloudEvents Source</see>.
/// </remarks>
public string Source { get; }
/// <summary>
/// Convert a string of the format "type/key" into an <see cref="TopicId"/>.
/// </summary>
/// <param name="TopicId">The actor ID string.</param>
/// <returns>An instance of <see cref="TopicId"/>.</returns>
public static TopicId Parse(string TopicId)
{
if (!KeyValueParser.TryParse(TopicId, out string? type, out string? key))
{
throw new FormatException($"Invalid TopicId format: '{TopicId}'. Expected format is 'type/key'.");
}
return new TopicId(type, key);
}
/// <inheritdoc />
public override readonly string ToString() => $"{this.Type}/{this.Source}";
/// <inheritdoc />
public override readonly bool Equals([NotNullWhen(true)] object? obj) =>
obj is TopicId other && this.Equals(other);
/// <inheritdoc/>
public readonly bool Equals(TopicId other) =>
this.Type == other.Type && this.Source == other.Source;
/// <inheritdoc />
public override readonly int GetHashCode() =>
HashCode.Combine(this.Type, this.Source);
/// <inheritdoc />
public static bool operator ==(TopicId left, TopicId right) =>
left.Equals(right);
/// <inheritdoc />
public static bool operator !=(TopicId left, TopicId right) =>
!left.Equals(right);
// TODO: Implement < for wildcard matching (type, *)
//public readonly bool IsWildcardMatch(TopicId other)
//{
// return this.Type == other.Type;
//}
}
@@ -1,98 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// This subscription matches on topics based on the exact type and maps to actors using the source of the topic as the actor key.
/// This subscription causes each source to have its own actor instance.
/// </summary>
/// <remarks>
/// Example:
/// <code>
/// var subscription = new TypeSubscription("t1", "a1");
/// </code>
/// In this case:
/// - A <see cref="TopicId"/> with type `"t1"` and source `"s1"` will be handled by an actor of type `"a1"` with key `"s1"`.
/// - A <see cref="TopicId"/> with type `"t1"` and source `"s2"` will be handled by an actor of type `"a1"` with key `"s2"`.
/// </remarks>
public sealed class TypeSubscription : ISubscriptionDefinition
{
/// <summary>
/// Initializes a new instance of the <see cref="TypeSubscription"/> class.
/// </summary>
/// <param name="topicType">The exact topic type to match against.</param>
/// <param name="actorType">Actor type to handle this subscription.</param>
/// <param name="id">Unique identifier for the subscription. If not provided, a new UUID will be generated.</param>
public TypeSubscription(string topicType, ActorType actorType, string? id = null)
{
Throw.IfNullOrEmpty(topicType);
this.TopicType = topicType;
this.ActorType = actorType;
this.Id = id ?? Guid.NewGuid().ToString();
}
/// <summary>
/// Gets the unique identifier of the subscription.
/// </summary>
public string Id { get; }
/// <summary>
/// Gets the exact topic type used for matching.
/// </summary>
public string TopicType { get; }
/// <summary>
/// Gets the actor type that handles this subscription.
/// </summary>
public ActorType ActorType { get; }
/// <summary>
/// Checks if a given <see cref="TopicId"/> matches the subscription based on an exact type match.
/// </summary>
/// <param name="topic">The topic to check.</param>
/// <returns><c>true</c> if the topic's type matches exactly, <c>false</c> otherwise.</returns>
public bool Matches(TopicId topic) => topic.Type == this.TopicType;
/// <summary>
/// Maps a <see cref="TopicId"/> to an <see cref="ActorId"/>. Should only be called if <see cref="Matches"/> returns true.
/// </summary>
/// <param name="topic">The topic to map.</param>
/// <returns>An <see cref="ActorId"/> representing the actor that should handle the topic.</returns>
/// <exception cref="InvalidOperationException">Thrown if the topic does not match the subscription.</exception>
public ActorId MapToActor(TopicId topic)
{
if (!this.Matches(topic))
{
throw new InvalidOperationException("TopicId does not match the subscription.");
}
return new ActorId(this.ActorType, topic.Source);
}
/// <summary>
/// Determines whether the specified object is equal to the current subscription.
/// </summary>
/// <param name="obj">The object to compare with the current instance.</param>
/// <returns><c>true</c> if the specified object is equal to this instance; otherwise, <c>false</c>.</returns>
public override bool Equals([NotNullWhen(true)] object? obj) =>
obj is TypeSubscription other &&
(this.Id == other.Id || (this.ActorType == other.ActorType && this.TopicType == other.TopicType));
/// <summary>
/// Determines whether the specified subscription is equal to the current subscription.
/// </summary>
/// <param name="other">The subscription to compare.</param>
/// <returns><c>true</c> if the subscriptions are equal; otherwise, <c>false</c>.</returns>
public bool Equals(ISubscriptionDefinition? other) => this.Id == other?.Id;
/// <summary>
/// Returns a hash code for this instance.
/// </summary>
/// <returns>A hash code for this instance, suitable for use in hashing algorithms and data structures.</returns>
public override int GetHashCode() => HashCode.Combine(this.Id, this.ActorType, this.TopicType);
}
@@ -9,7 +9,7 @@ namespace Microsoft.Extensions.AI.Agents.Runtime;
/// </summary>
/// <param name="eTag">The actor's updated ETag value after the write operation.</param>
/// <param name="success">Whether the write operation was successful.</param>
public class WriteResponse(string eTag, bool success)
public sealed class WriteResponse(string eTag, bool success)
{
/// <summary>
/// Gets the version of the state update.
@@ -11,6 +11,4 @@ namespace Microsoft.Extensions.AI.Agents.Runtime;
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false)]
[JsonSerializable(typeof(string))]
internal sealed partial class ActorRuntimeJsonContext : JsonSerializerContext
{
}
internal sealed partial class ActorRuntimeJsonContext : JsonSerializerContext;
@@ -5,14 +5,23 @@ namespace Microsoft.Extensions.AI.Agents;
/// <summary>
/// Chat client agent run options.
/// </summary>
internal sealed class ChatClientAgentRunOptions : AgentRunOptions
public sealed class ChatClientAgentRunOptions : AgentRunOptions
{
/// <summary>
/// Initializes a new instance of the <see cref="ChatClientAgentRunOptions"/> class.
/// </summary>
/// <param name="chatOptions">Optional chat options to pass to the agent's invocation.</param>
public ChatClientAgentRunOptions(ChatOptions? chatOptions = null) :
this(null, chatOptions)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="ChatClientAgentRunOptions"/> class.
/// </summary>
/// <param name="source">Optional source <see cref="AgentRunOptions"/> to clone.</param>
/// <param name="chatOptions">Optional chat options to pass to the agent's invocation.</param>
internal ChatClientAgentRunOptions(AgentRunOptions? source = null, ChatOptions? chatOptions = null)
internal ChatClientAgentRunOptions(AgentRunOptions? source, ChatOptions? chatOptions = null)
{
this.ChatOptions = chatOptions;
}
@@ -20,5 +29,5 @@ internal sealed class ChatClientAgentRunOptions : AgentRunOptions
/// <summary>
/// Gets or sets optional chat options to pass to the agent's invocation
/// </summary>
internal ChatOptions? ChatOptions { get; }
public ChatOptions? ChatOptions { get; set; }
}