// 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;
///
/// Base class for multi-agent agent orchestration patterns.
///
public abstract partial class OrchestratingAgent : AIAgent
{
/// Key used to persist state with the runtime.
private const string StateKey = "State";
///
/// Initializes a new instance of the class.
///
/// Specifies the agents participating in this orchestration.
/// An optional name for this agent.
protected OrchestratingAgent(IReadOnlyList agents, string? name = null)
{
_ = Throw.IfNullOrEmpty(agents);
this.Agents = agents;
this.Name = name;
}
///
public override string? Name { get; }
///
/// Gets the list of member targets involved in the orchestration.
///
protected IReadOnlyList Agents { get; }
/// Gets the serializer options to use by the orchestration.
public JsonSerializerOptions? SerializerOptions { get; set; }
///
/// Gets the associated logger.
///
public ILoggerFactory LoggerFactory { get; set; } = NullLoggerFactory.Instance;
///
/// Optional callback that is invoked for every agent response.
///
public Func, ValueTask>? ResponseCallback { get; set; }
///
/// Optional callback that is invoked for every agent update.
///
public Func? StreamingResponseCallback { get; set; }
///
public sealed override async Task RunAsync(
IReadOnlyCollection messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
_ = Throw.IfNull(messages);
if (thread is not null)
{
if (thread.MessageStore is null)
{
throw new InvalidOperationException("An agent service managed thread is not supported by this agent.");
}
List messagesList = [];
await foreach (var threadMessage in thread.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);
}
///
public sealed override async IAsyncEnumerable RunStreamingAsync(
IReadOnlyCollection 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;
}
}
///
/// Initiates processing of the orchestration.
///
/// The input message.
/// Optional parameters for agent invocation.
/// The runtime associated with the orchestration.
/// The to monitor for cancellation requests. The default is .
public async ValueTask RunAsync(
IReadOnlyCollection 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 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);
}
///
/// Initiates processing of the orchestration.
///
/// The input message.
/// The context for this operation.
/// A cancellation token that can be used to cancel the operation.
protected abstract Task RunCoreAsync(IReadOnlyCollection messages, OrchestratingAgentContext context, CancellationToken cancellationToken);
///
/// Resumes processing of the orchestration.
///
/// The last checkpoint state available from which to resume the operation.
/// The context for this operation.
/// A cancellation token that can be used to cancel the operation.
protected abstract Task ResumeCoreAsync(JsonElement checkpointState, OrchestratingAgentContext context, CancellationToken cancellationToken);
///
/// Runs the agent with input messages and respond with both streamed and regular messages.
///
/// The agent being run
/// The associated orchestration context for this run.
/// The list of chat messages to send.
/// Options to use when invoking the agent.
/// A cancellation token that can be used to cancel the operation.
/// A task that returns the response .
protected static async ValueTask RunAsync(AIAgent agent, OrchestratingAgentContext context, IReadOnlyCollection 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 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;
}
/// Writes the specified checkpoint state to the runtime.
/// The state to persist.
/// The context for the orchestrating operation.
/// A cancellation token that can be used to cancel the operation.
/// A Task that completes when the asynchronous operation quiesces.
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;
}
}
}
/// Read checkpoint information, if it exists, for the specified context.
/// The context for the orchestrating operation.
/// A cancellation token that can be used to cancel the operation.
/// The loaded state, or null if it doesn't exist.
protected async ValueTask 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 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);
}
}
}