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
@@ -43,10 +43,9 @@ public class ConcurrentOrchestration_Intro(ITestOutputHelper output) : Orchestra
// Run the orchestration
string input = "What is temperature?";
Console.WriteLine($"\n# INPUT: {input}\n");
OrchestrationResult<string[]> result = await orchestration.InvokeAsync(input);
AgentRunResponse result = await orchestration.RunAsync(input);
string[] output = await result;
Console.WriteLine($"\n# RESULT:\n{string.Join("\n\n", output.Select(text => $"{text}"))}");
Console.WriteLine($"\n# RESULT:\n{string.Join("\n\n", result.Messages.Select(r => $"{r.Text}"))}");
this.DisplayHistory(monitor.History);
}
@@ -32,21 +32,14 @@ public class ConcurrentOrchestration_With_StructuredOutput(ITestOutputHelper out
description: "An expert in entity recognition");
// Define the orchestration with transform
StructuredOutputTransform<Analysis> outputTransform = new(this.CreateChatClient());
ConcurrentOrchestration<string, Analysis> orchestration =
new(agent1, agent2, agent3)
{
LoggerFactory = this.LoggerFactory,
ResultTransform = outputTransform.TransformAsync,
};
ConcurrentOrchestration orchestration = new(agent1, agent2, agent3) { LoggerFactory = this.LoggerFactory };
// Run the orchestration
const string resourceId = "Hamlet_full_play_summary.txt";
string input = Resources.Read(resourceId);
Console.WriteLine($"\n# INPUT: @{resourceId}\n");
OrchestrationResult<Analysis> result = await orchestration.InvokeAsync(input);
Analysis output = await result;
var output = await orchestration.RunAsync<Analysis>(this.CreateChatClient(), input);
Console.WriteLine($"\n# RESULT:\n{JsonSerializer.Serialize(output, s_options)}");
}
@@ -68,8 +68,8 @@ public class GroupChatOrchestration_Intro(ITestOutputHelper output) : Orchestrat
string input = "Create a slogon for a new eletric SUV that is affordable and fun to drive.";
Console.WriteLine($"\n# INPUT: {input}\n");
OrchestrationResult<string> result = await orchestration.InvokeAsync(input);
Console.WriteLine($"\n# RESULT: {await result}");
AgentRunResponse result = await orchestration.RunAsync(input);
Console.WriteLine($"\n# RESULT: {result}");
this.DisplayHistory(monitor.History);
}
@@ -132,8 +132,8 @@ public class GroupChatOrchestration_With_AIManager(ITestOutputHelper output) : O
// Run the orchestration
Console.WriteLine($"\n# INPUT: {topic}\n");
OrchestrationResult<string> result = await orchestration.InvokeAsync(topic);
Console.WriteLine($"\n# RESULT: {await result}");
AgentRunResponse result = await orchestration.RunAsync(topic);
Console.WriteLine($"\n# RESULT: {result}");
this.DisplayHistory(monitor.History);
}
@@ -69,8 +69,8 @@ public class GroupChatOrchestration_With_HumanInTheLoop(ITestOutputHelper output
// Run the orchestration
string input = "Create a slogon for a new eletric SUV that is affordable and fun to drive.";
Console.WriteLine($"\n# INPUT: {input}\n");
OrchestrationResult<string> result = await orchestration.InvokeAsync(input);
Console.WriteLine($"\n# RESULT: {await result}");
AgentRunResponse result = await orchestration.RunAsync(input);
Console.WriteLine($"\n# RESULT: {result}");
this.DisplayHistory(monitor.History);
}
@@ -80,9 +80,9 @@ public class HandoffOrchestration_Intro(ITestOutputHelper output) : Orchestratio
// Run the orchestration
Console.WriteLine($"\n# INPUT:\n{task}\n");
OrchestrationResult<string> result = await orchestration.InvokeAsync(task);
AgentRunResponse result = await orchestration.RunAsync(task);
Console.WriteLine($"\n# RESULT: {await result}");
Console.WriteLine($"\n# RESULT: {result}");
this.DisplayHistory(monitor.History);
}
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Agents.Orchestration;
using Microsoft.Extensions.AI;
@@ -44,7 +45,7 @@ public class HandoffOrchestration_With_StructuredInput(ITestOutputHelper output)
OrchestrationMonitor monitor = new();
// Define the orchestration
HandoffOrchestration<GithubIssue, string> orchestration =
HandoffOrchestration orchestration =
new(OrchestrationHandoffs
.StartWith(triageAgent)
.Add(triageAgent, dotnetAgent, pythonAgent))
@@ -80,8 +81,8 @@ public class HandoffOrchestration_With_StructuredInput(ITestOutputHelper output)
// Run the orchestration
Console.WriteLine($"\n# INPUT:\n{input.Id}: {input.Title}\n");
OrchestrationResult<string> result = await orchestration.InvokeAsync(input);
Console.WriteLine($"\n# RESULT: {await result}");
AgentRunResponse result = await orchestration.RunAsync(JsonSerializer.Serialize(input));
Console.WriteLine($"\n# RESULT: {result}");
Console.WriteLine($"\n# LABELS: {string.Join(",", githubPlugin.Labels["12345"])}\n");
}
@@ -65,8 +65,8 @@ public class SequentialOrchestration_Intro(ITestOutputHelper output) : Orchestra
// Run the orchestration
string input = "An eco-friendly stainless steel water bottle that keeps drinks cold for 24 hours";
Console.WriteLine($"\n# INPUT: {input}\n");
OrchestrationResult<string> result = await orchestration.InvokeAsync(input);
Console.WriteLine($"\n# RESULT: {await result}");
AgentRunResponse result = await orchestration.RunAsync(input);
Console.WriteLine($"\n# RESULT: {result}");
this.DisplayHistory(monitor.History);
}
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.Orchestration;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.AI.Agents;
namespace Orchestration;
@@ -28,7 +29,7 @@ public class SequentialOrchestration_With_Cancellation(ITestOutputHelper output)
string input = "42";
Console.WriteLine($"\n# INPUT: {input}\n");
OrchestrationResult<string> result = await orchestration.InvokeAsync(input);
OrchestratingAgentResponse result = await orchestration.RunAsync([new ChatMessage(ChatRole.User, input)]);
result.Cancel();
await Task.Delay(TimeSpan.FromSeconds(3));
@@ -1,21 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using System.Text.Json.Serialization;
using HelloHttpApi.ApiService;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.AI.Agents;
/// <summary>
/// Source-generated JSON type information for use by all Agents implementations.
/// </summary>
[JsonSourceGenerationOptions(
JsonSerializerDefaults.Web,
UseStringEnumConverter = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false)]
[JsonSerializable(typeof(ChatMessage))]
[JsonSerializable(typeof(List<ChatMessage>))]
[JsonSerializable(typeof(ChatClientAgentThread))]
[JsonSerializable(typeof(ChatClientAgentRunRequest))]
[JsonSerializable(typeof(AgentRunResponseUpdate))]
internal sealed partial class AgentsJsonContext : JsonSerializerContext;
@@ -8,7 +8,7 @@ using Microsoft.Extensions.AI;
using Microsoft.Extensions.AI.Agents;
using Microsoft.Extensions.AI.Agents.Runtime;
internal sealed class ChatClientAgentActor(ChatClientAgent agent, JsonSerializerOptions jsonSerializerOptions, IActorRuntimeContext context, ILogger<ChatClientAgentActor> logger) : IActor
internal sealed class ChatClientAgentActor(AIAgent agent, JsonSerializerOptions jsonSerializerOptions, IActorRuntimeContext context, ILogger<ChatClientAgentActor> logger) : IActor
{
private string? _etag;
private ChatClientAgentThread? _thread;
@@ -36,7 +36,7 @@ internal sealed class ChatClientAgentActor(ChatClientAgent agent, JsonSerializer
}
}
this._thread ??= (ChatClientAgentThread)agent.GetNewThread();
this._thread ??= agent.GetNewThread() as ChatClientAgentThread ?? throw new InvalidOperationException("The agent did not provide a valid thread instance.");
Log.ThreadStateRestored(logger, context.ActorId.ToString(), response.Results[0] is GetValueResult { Value: not null });
while (!cancellationToken.IsCancellationRequested)
@@ -7,6 +7,7 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Microsoft.Agents.Orchestration\Microsoft.Agents.Orchestration.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Extensions.AI.Agents.Abstractions\Microsoft.Extensions.AI.Agents.Abstractions.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Extensions.AI.Agents.Runtime.Abstractions\Microsoft.Extensions.AI.Agents.Runtime.Abstractions.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Extensions.AI.Agents.Runtime\Microsoft.Extensions.AI.Agents.Runtime.csproj" />
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using Microsoft.Agents.Orchestration;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.AI.Agents;
using Microsoft.Extensions.AI.Agents.Runtime;
@@ -9,20 +10,28 @@ namespace HelloHttpApi.ApiService;
public static class HostApplicationBuilderAgentExtensions
{
public static IHostApplicationBuilder AddChatClientAgent(this IHostApplicationBuilder builder, string name, string instructions, string? chatClientKey = null)
public static IHostApplicationBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string instructions, string? chatClientKey = null)
{
var agentKey = $"agent:{name}";
builder.Services.AddKeyedSingleton(agentKey, (sp, key) =>
builder.Services.AddKeyedSingleton<AIAgent>(agentKey, (sp, key) =>
{
var chatClient = chatClientKey is null ? sp.GetRequiredService<IChatClient>() : sp.GetRequiredKeyedService<IChatClient>(chatClientKey);
return new ChatClientAgent(chatClient, instructions, name);
ChatClientAgent triage = new(chatClient, "You are a triage agent. You will determine which agent to hand off the conversation to based on the user's input.", $"{name}_triageAgent");
ChatClientAgent target = new(chatClient, instructions, $"{name}_targetAgent");
ChatClientAgent customerService = new(chatClient, "You are a customer service agent. You will handle rude, angry, or upset customer inquiries, asking them to be more calm and polite.", $"{name}_customerServiceAgent");
return new HandoffOrchestration(OrchestrationHandoffs
.StartWith(triage)
.Add(triage, target, "Hand off to the target agent for handling normal customer requests.")
.Add(triage, customerService, "Hand off to the customer service agent for handling rude customer inquiries."));
});
var actorBuilder = builder.AddActorRuntime();
actorBuilder.AddActorType(
new ActorType(agentKey),
(sp, ctx) => new ChatClientAgentActor(
sp.GetRequiredKeyedService<ChatClientAgent>(agentKey),
sp.GetRequiredKeyedService<AIAgent>(agentKey),
sp.GetService<JsonSerializerOptions>() ?? JsonSerializerOptions.Web,
ctx,
sp.GetRequiredService<ILogger<ChatClientAgentActor>>()));
@@ -14,7 +14,7 @@ builder.Services.AddProblemDetails();
// Configure the chat model and our agent.
builder.AddKeyedChatClient("chat-model");
builder.AddChatClientAgent(
builder.AddAIAgent(
name: "pirate",
instructions: "You are a pirate. Speak like a pirate.",
chatClientKey: "chat-model");
@@ -2,7 +2,6 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.AI.Agents;
@@ -10,11 +9,6 @@ namespace HelloHttpApi.Web;
public class AgentClient(HttpClient httpClient, ILogger<AgentClient> logger)
{
private static readonly JsonSerializerOptions s_jsonOptions = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
public async IAsyncEnumerable<AgentRunResponseUpdate> SendMessageStreamAsync(
string agentName,
string message,
@@ -27,7 +21,7 @@ public class AgentClient(HttpClient httpClient, ILogger<AgentClient> logger)
Messages = [new ChatMessage(ChatRole.User, message)]
};
var content = JsonContent.Create(request, s_jsonOptions.GetTypeInfo<ChatClientAgentRunRequest>(AgentClientJsonContext.Default));
var content = JsonContent.Create(request, AgentClientJsonContext.Default.ChatClientAgentRunRequest);
var requestUri = new Uri($"/invocations/actor/{agentName}/{sessionId}/{requestId}?stream=true", UriKind.Relative);
@@ -82,7 +76,7 @@ public class AgentClient(HttpClient httpClient, ILogger<AgentClient> logger)
Messages = [new ChatMessage(ChatRole.User, message)]
};
var content = JsonContent.Create(request, s_jsonOptions.GetTypeInfo<ChatClientAgentRunRequest>(AgentClientJsonContext.Default));
var content = JsonContent.Create(request, AgentClientJsonContext.Default.ChatClientAgentRunRequest);
var requestUri = new Uri($"/invocations/actor/{agentName}/{sessionId}/{requestId}?stream=false", UriKind.Relative);
@@ -96,7 +90,7 @@ public class AgentClient(HttpClient httpClient, ILogger<AgentClient> logger)
try
{
var agentResponse = await response.Content.ReadFromJsonAsync(s_jsonOptions.GetTypeInfo<AgentResponse>(AgentClientJsonContext.Default), cancellationToken);
var agentResponse = await response.Content.ReadFromJsonAsync(AgentClientJsonContext.Default.AgentResponse, cancellationToken);
return agentResponse ?? new AgentResponse { Content = "No response received", Status = "error" };
}
catch (JsonException ex)
@@ -113,7 +107,7 @@ public class AgentClient(HttpClient httpClient, ILogger<AgentClient> logger)
try
{
var eventData = JsonSerializer.Deserialize(jsonData, s_jsonOptions.GetTypeInfo<EventData>(AgentClientJsonContext.Default));
var eventData = JsonSerializer.Deserialize(jsonData, AgentClientJsonContext.Default.EventData);
if (eventData?.Event != null)
{
var eventElement = eventData.Event.Value;
@@ -121,7 +115,7 @@ public class AgentClient(HttpClient httpClient, ILogger<AgentClient> logger)
// Try to deserialize as AgentRunResponseUpdate for intermediate updates
try
{
var update = JsonSerializer.Deserialize<AgentRunResponseUpdate>(eventElement.GetRawText(), s_jsonOptions);
var update = JsonSerializer.Deserialize<AgentRunResponseUpdate>(eventElement.GetRawText(), AgentAbstractionsJsonUtilities.DefaultOptions);
if (update != null)
{
responseUpdate = update;
@@ -171,32 +165,6 @@ public class AgentResponse
public string Status { get; set; } = "";
}
/// <summary>
/// Provides extension methods for JSON serialization with source generation support.
/// </summary>
internal static class JsonSerializerExtensions
{
/// <summary>
/// Gets the JsonTypeInfo for a type, preferring the one from options if available,
/// otherwise falling back to the source-generated context.
/// </summary>
/// <typeparam name="T">The type to get JsonTypeInfo for.</typeparam>
/// <param name="options">The JsonSerializerOptions to check first.</param>
/// <param name="fallbackContext">The fallback JsonSerializerContext to use if not found in options.</param>
/// <returns>The JsonTypeInfo for the requested type.</returns>
public static JsonTypeInfo<T> GetTypeInfo<T>(this JsonSerializerOptions options, JsonSerializerContext fallbackContext)
{
// Try to get from the options first (if a context is configured)
if (options.TypeInfoResolver?.GetTypeInfo(typeof(T), options) is JsonTypeInfo<T> typeInfo)
{
return typeInfo;
}
// Fall back to the provided source-generated context
return (JsonTypeInfo<T>)fallbackContext.GetTypeInfo(typeof(T))!;
}
}
/// <summary>
/// Source-generated JSON type information for use by AgentClient.
/// </summary>
@@ -206,10 +174,6 @@ internal static class JsonSerializerExtensions
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false)]
[JsonSerializable(typeof(ChatClientAgentRunRequest))]
[JsonSerializable(typeof(ChatMessage))]
[JsonSerializable(typeof(List<ChatMessage>))]
[JsonSerializable(typeof(EventData))]
[JsonSerializable(typeof(AgentRunResponseUpdate))]
[JsonSerializable(typeof(AgentResponse))]
[JsonSerializable(typeof(JsonElement))]
internal sealed partial class AgentClientJsonContext : JsonSerializerContext;
@@ -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; }
}
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.AI.Agents;
@@ -50,12 +51,12 @@ public class ConcurrentOrchestrationTests
ConcurrentOrchestration orchestration = new(mockAgents);
const string InitialInput = "123";
OrchestrationResult<string[]> result = await orchestration.InvokeAsync(InitialInput);
AgentRunResponse result = await orchestration.RunAsync(InitialInput);
// Assert
Assert.NotNull(result);
// Act
return await result.Task;
return result.Messages.Select(m => m.Text).ToArray();
}
}
@@ -1,201 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.AI.Agents;
namespace Microsoft.Agents.Orchestration.UnitTest;
public class DefaultTransformsTests
{
[Fact]
public async Task FromInputAsyncWithEnumerableOfChatMessageReturnsInputAsync()
{
// Arrange
IEnumerable<ChatMessage> input =
[
new(ChatRole.User, "Hello"),
new(ChatRole.Assistant, "Hi there")
];
// Act
IEnumerable<ChatMessage> result = await DefaultTransforms.FromInput(input);
// Assert
Assert.Equal(input, result);
}
[Fact]
public async Task FromInputAsyncWithChatMessageReturnsInputAsListAsync()
{
// Arrange
ChatMessage input = new(ChatRole.User, "Hello");
// Act
IEnumerable<ChatMessage> result = await DefaultTransforms.FromInput(input);
// Assert
Assert.Single(result);
Assert.Equal(input, result.First());
}
[Fact]
public async Task FromInputAsyncWithStringInputReturnsUserChatMessageAsync()
{
// Arrange
string input = "Hello, world!";
// Act
IEnumerable<ChatMessage> result = await DefaultTransforms.FromInput(input);
// Assert
Assert.Single(result);
ChatMessage message = result.First();
Assert.Equal(ChatRole.User, message.Role);
Assert.Equal(input, message.Text);
}
[Fact]
public async Task FromInputAsyncWithObjectInputSerializesAsJsonAsync()
{
// Arrange
TestObject input = new() { Id = 1, Name = "Test" };
// Act
IEnumerable<ChatMessage> result = await DefaultTransforms.FromInput(input);
// Assert
Assert.Single(result);
ChatMessage message = result.First();
Assert.Equal(ChatRole.User, message.Role);
string expectedJson = JsonSerializer.Serialize(input, AgentAbstractionsJsonUtilities.DefaultOptions);
Assert.Equal(expectedJson, message.Text);
}
[Fact]
public async Task ToOutputAsyncWithOutputTypeMatchingInputListReturnsSameListAsync()
{
// Arrange
IList<ChatMessage> input =
[
new(ChatRole.User, "Hello"),
new(ChatRole.Assistant, "Hi there")
];
// Act
IList<ChatMessage> result = await DefaultTransforms.ToOutput<IList<ChatMessage>>(input);
// Assert
Assert.Same(input, result);
}
[Fact]
public async Task ToOutputAsyncWithOutputTypeChatMessageReturnsSingleMessageAsync()
{
// Arrange
IList<ChatMessage> input =
[
new(ChatRole.User, "Hello")
];
// Act
ChatMessage result = await DefaultTransforms.ToOutput<ChatMessage>(input);
// Assert
Assert.Same(input[0], result);
}
[Fact]
public async Task ToOutputAsyncWithOutputTypeStringReturnsContentOfSingleMessageAsync()
{
// Arrange
string expected = "Hello, world!";
IList<ChatMessage> input =
[
new(ChatRole.User, expected)
];
// Act
string result = await DefaultTransforms.ToOutput<string>(input);
// Assert
Assert.Equal(expected, result);
}
[Fact]
public async Task ToOutputAsyncWithOutputTypeDeserializableDeserializesFromContentAsync()
{
// Arrange
TestObject expected = new() { Id = 42, Name = "TestName" };
string json = JsonSerializer.Serialize(expected);
IList<ChatMessage> input =
[
new(ChatRole.User, json)
];
// Act
TestObject result = await DefaultTransforms.ToOutput<TestObject>(input);
// Assert
Assert.Equal(expected.Id, result.Id);
Assert.Equal(expected.Name, result.Name);
}
[Fact]
public async Task ToOutputAsyncWithInvalidJsonThrowsExceptionAsync()
{
// Arrange
IList<ChatMessage> input =
[
new(ChatRole.User, "Not valid JSON")
];
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(async () =>
await DefaultTransforms.ToOutput<TestObject>(input)
);
}
[Fact]
public async Task ToOutputAsyncWithMultipleMessagesAndNonMatchingTypeThrowsExceptionAsync()
{
// Arrange
IList<ChatMessage> input =
[
new(ChatRole.User, "Hello"),
new(ChatRole.Assistant, "Hi there")
];
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(async () =>
await DefaultTransforms.ToOutput<TestObject>(input)
);
}
[Fact]
public async Task ToOutputAsyncWithNullContentHandlesGracefullyAsync()
{
// Arrange
IList<ChatMessage> input =
[
new(ChatRole.User, (string?)null)
];
// Act
string result = await DefaultTransforms.ToOutput<string>(input);
// Assert
Assert.Equal(string.Empty, result);
}
private sealed class TestObject
{
public int Id { get; set; }
public string? Name { get; set; }
}
}
@@ -0,0 +1,60 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.AI.Agents;
namespace Microsoft.Agents.Orchestration.UnitTest;
/// <summary>
/// Tests for the <see cref="GroupChatOrchestration"/> class.
/// </summary>
public class GroupChatOrchestration2Tests
{
[Fact]
public async Task GroupChatOrchestration2WithSingleAgentAsync()
{
// Arrange
MockAgent mockAgent1 = MockAgent.CreateWithResponse(2, "xyz");
// Act: Create and execute the orchestration
string response = await ExecuteOrchestrationAsync(mockAgent1);
// Assert
Assert.Equal(1, mockAgent1.InvokeCount);
Assert.Equal("xyz", response);
}
[Fact]
public async Task GroupChatOrchestration2WithMultipleAgentsAsync()
{
// Arrange
MockAgent mockAgent1 = MockAgent.CreateWithResponse(1, "abc");
MockAgent mockAgent2 = MockAgent.CreateWithResponse(2, "xyz");
MockAgent mockAgent3 = MockAgent.CreateWithResponse(3, "lmn");
// Act: Create and execute the orchestration
string response = await ExecuteOrchestrationAsync(mockAgent1, mockAgent2, mockAgent3);
// Assert
Assert.Equal(1, mockAgent1.InvokeCount);
Assert.Equal(1, mockAgent2.InvokeCount);
Assert.Equal(1, mockAgent3.InvokeCount);
Assert.Equal("lmn", response);
}
private static async Task<string> ExecuteOrchestrationAsync(params AIAgent[] mockAgents)
{
// Act
GroupChatOrchestration orchestration = new(new RoundRobinGroupChatManager() { MaximumInvocationCount = mockAgents.Length }, mockAgents);
const string InitialInput = "123";
AgentRunResponse result = await orchestration.RunAsync(InitialInput);
// Assert
Assert.NotNull(result);
// Return the text from the last message
return result.Messages.LastOrDefault()?.Text ?? string.Empty;
}
}
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.AI.Agents;
@@ -48,12 +49,12 @@ public class GroupChatOrchestrationTests
GroupChatOrchestration orchestration = new(new RoundRobinGroupChatManager() { MaximumInvocationCount = mockAgents.Length }, mockAgents);
const string InitialInput = "123";
OrchestrationResult<string> result = await orchestration.InvokeAsync(InitialInput);
AgentRunResponse result = await orchestration.RunAsync(InitialInput);
// Assert
Assert.NotNull(result);
// Act
return await result.Task;
return result.Messages.Last().Text;
}
}
@@ -92,13 +92,13 @@ public sealed class HandoffOrchestrationTests : IDisposable
// Act
const string InitialInput = "123";
OrchestrationResult<string> result = await orchestration.InvokeAsync(InitialInput);
AgentRunResponse result = await orchestration.RunAsync(InitialInput);
// Assert
Assert.NotNull(result);
// Act
return await result.Task;
return result.Text;
}
private ChatClientAgent CreateMockAgent(string name, string description, params string[] responses)
@@ -1,8 +1,12 @@
// 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.Agents.Runtime;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.AI.Agents;
using Microsoft.Extensions.Logging.Abstractions;
namespace Microsoft.Agents.Orchestration.UnitTest;
@@ -13,45 +17,54 @@ public class OrchestrationResultTests
public async Task ConstructorInitializesPropertiesCorrectlyAsync()
{
// Arrange
OrchestrationContext context = new("TestOrchestration", new TopicId("testTopic"), null, null, NullLoggerFactory.Instance, CancellationToken.None);
TaskCompletionSource<string> tcs = new();
OrchestratingAgentContext context = new()
{
OrchestratingAgent = new MockOrchestratingAgent(),
};
TaskCompletionSource<AgentRunResponse> tcs = new();
// Act
using CancellationTokenSource cancelSource = new();
await using OrchestrationResult<string> result = new(context, tcs, cancelSource, NullLogger.Instance);
await using OrchestratingAgentResponse result = new(context, tcs.Task, cancelSource, NullLogger.Instance);
// Assert
Assert.Equal("TestOrchestration", result.Orchestration);
Assert.Equal(new TopicId("testTopic"), result.Topic);
Assert.Same(context, result.Context);
Assert.Same(tcs.Task, result.Task);
}
[Fact]
public async Task GetValueAsyncReturnsCompletedValueWhenTaskIsCompletedAsync()
{
// Arrange
OrchestrationContext context = new("TestOrchestration", new TopicId("testTopic"), null, null, NullLoggerFactory.Instance, CancellationToken.None);
TaskCompletionSource<string> tcs = new();
OrchestratingAgentContext context = new()
{
OrchestratingAgent = new MockOrchestratingAgent(),
};
TaskCompletionSource<AgentRunResponse> tcs = new();
using CancellationTokenSource cancelSource = new();
await using OrchestrationResult<string> result = new(context, tcs, cancelSource, NullLogger.Instance);
string expectedValue = "Result value";
await using OrchestratingAgentResponse result = new(context, tcs.Task, cancelSource, NullLogger.Instance);
AgentRunResponse expectedValue = new();
// Act
tcs.SetResult(expectedValue);
string actualValue = await result.Task;
// Assert
Assert.Equal(expectedValue, actualValue);
Assert.Same(expectedValue, await result);
}
[Fact]
public async Task GetValueAsyncReturnsCompletedValueWhenCompletionIsDelayedAsync()
{
// Arrange
OrchestrationContext context = new("TestOrchestration", new TopicId("testTopic"), null, null, NullLoggerFactory.Instance, CancellationToken.None);
TaskCompletionSource<int> tcs = new();
OrchestratingAgentContext context = new()
{
OrchestratingAgent = new MockOrchestratingAgent(),
};
TaskCompletionSource<AgentRunResponse> tcs = new();
using CancellationTokenSource cancelSource = new();
await using OrchestrationResult<int> result = new(context, tcs, cancelSource, NullLogger.Instance);
int expectedValue = 42;
await using OrchestratingAgentResponse result = new(context, tcs.Task, cancelSource, NullLogger.Instance);
AgentRunResponse expectedValue = new();
// Act
// Simulate delayed completion in a separate task
@@ -61,9 +74,26 @@ public class OrchestrationResultTests
tcs.SetResult(expectedValue);
});
int actualValue = await result.Task;
// Assert
Assert.Equal(expectedValue, actualValue);
Assert.Same(expectedValue, await result);
}
private sealed class MockOrchestratingAgent() : OrchestratingAgent([new MockAgent()])
{
protected override Task<AgentRunResponse> RunCoreAsync(IReadOnlyCollection<ChatMessage> messages, OrchestratingAgentContext context, CancellationToken cancellationToken) =>
throw new NotSupportedException();
protected override Task<AgentRunResponse> ResumeCoreAsync(JsonElement checkpointState, OrchestratingAgentContext context, CancellationToken cancellationToken) =>
throw new NotSupportedException();
}
private sealed class MockAgent : AIAgent
{
public override AgentThread GetNewThread() =>
throw new NotSupportedException();
public override Task<AgentRunResponse> RunAsync(IReadOnlyCollection<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
public override IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(IReadOnlyCollection<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
}
}
@@ -48,12 +48,12 @@ public class SequentialOrchestrationTests
SequentialOrchestration orchestration = new(mockAgents);
const string InitialInput = "123";
OrchestrationResult<string> result = await orchestration.InvokeAsync(InitialInput);
AgentRunResponse result = await orchestration.RunAsync(InitialInput);
// Assert
Assert.NotNull(result);
// Act
return await result.Task;
return result.Text;
}
}
@@ -1,18 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Extensions.AI.Agents.Runtime.Abstractions.Tests;
public class AgentMetadataTests()
{
[Fact]
public void AgentMetadataShouldInitializeCorrectlyTest()
{
// Arrange & Act
ActorMetadata metadata = new(new ActorType("TestType"), "TestKey", "TestDescription");
// Assert
Assert.Equal("TestType", metadata.Type.Name);
Assert.Equal("TestKey", metadata.Key);
Assert.Equal("TestDescription", metadata.Description);
}
}
@@ -1,89 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Moq;
namespace Microsoft.Extensions.AI.Agents.Runtime.Abstractions.Tests;
public class AgentProxyTests
{
private readonly Mock<IAgentRuntime> _mockRuntime;
private readonly ActorId _agentId;
private readonly IdProxyActor _agentProxy;
public AgentProxyTests()
{
this._mockRuntime = new Mock<IAgentRuntime>();
this._agentId = new ActorId("testType", "testKey");
this._agentProxy = new IdProxyActor(this._mockRuntime.Object, this._agentId);
}
[Fact]
public void IdMatchesAgentIdTest()
{
// Assert
Assert.Equal(this._agentId, this._agentProxy.Id);
}
[Fact]
public void MetadataShouldMatchAgentTest()
{
ActorMetadata expectedMetadata = new(new("testType"), "testKey", "testDescription");
this._mockRuntime.Setup(r => r.GetActorMetadataAsync(this._agentId, default))
.ReturnsAsync(expectedMetadata);
Assert.Equal(expectedMetadata, this._agentProxy.Metadata);
}
[Fact]
public async Task SendMessageResponseTestAsync()
{
// Arrange
object message = new { Content = "Hello" };
ActorId sender = new("senderType", "senderKey");
object response = new { Content = "Response" };
this._mockRuntime.Setup(r => r.SendMessageAsync(message, this._agentId, sender, null, It.IsAny<CancellationToken>()))
.ReturnsAsync(response);
// Act
object? result = await this._agentProxy.SendMessageAsync(message, sender);
// Assert
Assert.Equal(response, result);
}
[Fact]
public async Task LoadStateTestAsync()
{
// Arrange
JsonElement state = JsonDocument.Parse("{\"key\":\"value\"}").RootElement;
this._mockRuntime.Setup(r => r.LoadActorStateAsync(this._agentId, state, default))
.Returns(default(ValueTask));
// Act
await this._agentProxy.LoadStateAsync(state);
// Assert
this._mockRuntime.Verify(r => r.LoadActorStateAsync(this._agentId, state, default), Times.Once);
}
[Fact]
public async Task SaveStateTestAsync()
{
// Arrange
JsonElement expectedState = JsonDocument.Parse("{\"key\":\"value\"}").RootElement;
this._mockRuntime.Setup(r => r.SaveActorStateAsync(this._agentId, default))
.ReturnsAsync(expectedState);
// Act
JsonElement result = await this._agentProxy.SaveStateAsync();
// Assert
Assert.Equal(expectedState, result);
}
}
@@ -1,34 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
namespace Microsoft.Extensions.AI.Agents.Runtime.Abstractions.Tests;
public class AgentTypeTests
{
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("invalid type")] // Agent type must only contain alphanumeric letters or underscores
[InlineData("123invalidType")] // Agent type cannot start with a number
[InlineData("invalid@type")] // Agent type must only contain alphanumeric letters or underscores
[InlineData("invalid-type")] // Agent type cannot alphanumeric underscores.
public void AgentIdShouldThrowArgumentExceptionWithInvalidType(string? invalidType)
{
// Act & Assert
ArgumentException exception = Assert.Throws<ArgumentException>(() => new ActorType(invalidType!));
Assert.Contains("Invalid type", exception.Message);
}
[Fact]
public void ConversionToStringTest()
{
// Arrange
ActorType agentType = new("TestAgent");
// Assert
Assert.Equal("TestAgent", agentType.Name);
Assert.Equal("TestAgent", agentType.ToString());
}
}
@@ -1,322 +0,0 @@
// 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;
namespace Microsoft.Extensions.AI.Agents.Runtime.InProcess.Tests;
public class InProcessRuntimeTests()
{
[Fact]
public async Task RuntimeStatusLifecycleTestAsync()
{
// Arrange & Act
await using InProcessRuntime runtime = new();
// Assert
Assert.Equal(0, runtime.MessageCountForTesting);
runtime.Start();
// Assert
// Invalid to start runtime that is already started
Assert.Throws<InvalidOperationException>(runtime.Start);
Assert.Equal(0, runtime.MessageCountForTesting);
// Act
await runtime.DisposeAsync();
// Assert
Assert.Equal(0, runtime.MessageCountForTesting);
}
[Fact]
public async Task SubscriptionRegistrationLifecycleTestAsync()
{
// Arrange
await using InProcessRuntime runtime = new();
TestSubscription subscription = new("TestTopic", new("MyAgent"));
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(async () => await runtime.RemoveSubscriptionAsync(subscription.Id));
// Arrange
await runtime.AddSubscriptionAsync(subscription);
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(async () => await runtime.AddSubscriptionAsync(subscription));
// Act
await runtime.RemoveSubscriptionAsync(subscription.Id);
}
[Fact]
public async Task AgentRegistrationLifecycleTestAsync()
{
// Arrange
const string AgentType = "MyAgent";
const string AgentDescription = "A test agent";
List<MockAgent> agents = [];
await using InProcessRuntime runtime = new();
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(async () => await runtime.GetActorAsync(AgentType, lazy: false));
// Arrange
await runtime.RegisterActorFactoryAsync(new(AgentType), factoryFunc);
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(async () => await runtime.RegisterActorFactoryAsync(new(AgentType), factoryFunc));
// Act: Lookup by type
ActorId agentId = await runtime.GetActorAsync(AgentType, lazy: false);
// Assert
Assert.Single(agents);
Assert.Single(runtime._actorInstances);
// Act
MockAgent agent = await runtime.TryGetUnderlyingActorInstanceAsync<MockAgent>(agentId);
// Assert
Assert.Equal(agentId, agent.Id);
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(async () => await runtime.TryGetUnderlyingActorInstanceAsync<WrongAgent>(agentId));
// Act: Lookup by ID
ActorId sameId = await runtime.GetActorAsync(agentId, lazy: false);
// Assert
Assert.Equal(agentId, sameId);
// Act: Lookup by Type
sameId = await runtime.GetActorAsync((ActorType)agent.Id.Type, lazy: false);
// Assert
Assert.Equal(agentId, sameId);
// Act: Lookup metadata
ActorMetadata metadata = await runtime.GetActorMetadataAsync(agentId);
// Assert
Assert.Equal(agentId.Type, metadata.Type);
Assert.Equal(AgentDescription, metadata.Description);
Assert.Equal(agentId.Key, metadata.Key);
// Act: Access proxy
IdProxyActor? proxy = await runtime.TryGetActorProxyAsync(agentId);
// Assert
Assert.NotNull(proxy);
Assert.Equal(agentId, proxy.Id);
Assert.Equal(metadata.Type, proxy.Metadata.Type);
Assert.Equal(metadata.Description, proxy.Metadata.Description);
Assert.Equal(metadata.Key, proxy.Metadata.Key);
async ValueTask<MockAgent> factoryFunc(ActorId id, IAgentRuntime runtime)
{
MockAgent agent = new(id, runtime, AgentDescription);
agents.Add(agent);
return agent;
}
}
[Fact]
public async Task AgentStateLifecycleTestAsync()
{
// Arrange
const string AgentType = "MyAgent";
const string TestMessage = "test message";
await using InProcessRuntime firstRuntime = new();
await firstRuntime.RegisterActorFactoryAsync(new(AgentType), factoryFunc);
// Act
ActorId agentId = await firstRuntime.GetActorAsync(AgentType, lazy: false);
// Assert
Assert.Single(firstRuntime._actorInstances);
// Arrange
MockAgent agent = (MockAgent)firstRuntime._actorInstances[agentId];
agent.ReceivedMessages.Add(TestMessage);
// Act
JsonElement agentState = await firstRuntime.SaveActorStateAsync(agentId);
// Arrange
await using InProcessRuntime secondRuntime = new();
await secondRuntime.RegisterActorFactoryAsync(new(AgentType), factoryFunc);
// Act
await secondRuntime.LoadActorStateAsync(agentId, agentState);
// Assert
Assert.Single(secondRuntime._actorInstances);
MockAgent copy = (MockAgent)secondRuntime._actorInstances[agentId];
Assert.Single(copy.ReceivedMessages);
Assert.Equal(TestMessage, copy.ReceivedMessages.Single().ToString());
static async ValueTask<MockAgent> factoryFunc(ActorId id, IAgentRuntime runtime)
{
MockAgent agent = new(id, runtime, "A test agent");
return agent;
}
}
[Fact]
public async Task RuntimeSendMessageTestAsync()
{
// Arrange
await using InProcessRuntime runtime = new();
MockAgent? agent = null;
await runtime.RegisterActorFactoryAsync(new("MyAgent"), async (id, runtime) =>
{
agent = new MockAgent(id, runtime, "A test agent");
return agent;
});
// Act: Ensure the agent is actually created
ActorId agentId = await runtime.GetActorAsync("MyAgent", lazy: false);
// Assert
Assert.NotNull(agent);
Assert.Empty(agent.ReceivedMessages);
// Act: Send message
runtime.Start();
await runtime.SendMessageAsync("TestMessage", agent.Id);
await runtime.DisposeAsync();
// Assert
Assert.Equal(0, runtime.MessageCountForTesting);
Assert.Single(agent.ReceivedMessages);
}
// Agent will not deliver to self
[Fact]
public async Task RuntimeAgentPublishToSelfTestAsync()
{
// Arrange
await using InProcessRuntime runtime = new();
MockAgent? agent = null;
await runtime.RegisterActorFactoryAsync(new("MyAgent"), async (id, runtime) =>
{
agent = new MockAgent(id, runtime, "A test agent");
return agent;
});
// Assert
Assert.Empty(runtime._actorInstances);
// Act: Ensure the agent is actually created
ActorId agentId = await runtime.GetActorAsync("MyAgent", lazy: false);
// Assert
Assert.NotNull(agent);
Assert.Single(runtime._actorInstances);
const string TopicType = "TestTopic";
// Arrange
await runtime.AddSubscriptionAsync(new TestSubscription(TopicType, agentId.Type));
// Act
runtime.Start();
await runtime.PublishMessageAsync("SelfMessage", new TopicId(TopicType), sender: agentId);
await runtime.DisposeAsync();
// Assert
Assert.Empty(agent.ReceivedMessages);
}
[Fact]
public async Task RuntimeShouldSaveLoadStateCorrectlyTestAsync()
{
// Arrange: Create a runtime and register an agent
await using InProcessRuntime runtime = new();
MockAgent? agent = null;
await runtime.RegisterActorFactoryAsync(new("MyAgent"), async (id, runtime) =>
{
agent = new MockAgent(id, runtime, "test agent");
return agent;
});
// Get agent ID and instantiate agent by publishing
ActorId agentId = await runtime.GetActorAsync("MyAgent", lazy: false);
const string TopicType = "TestTopic";
await runtime.AddSubscriptionAsync(new TestSubscription(TopicType, agentId.Type));
runtime.Start();
await runtime.PublishMessageAsync("test", new TopicId(TopicType));
await runtime.DisposeAsync();
// Act: Save the state
JsonElement savedState = await runtime.SaveStateAsync();
// Assert: Ensure the agent's state is stored as a valid JSON type
Assert.NotNull(agent);
Assert.True(savedState.TryGetProperty(agentId.ToString(), out JsonElement agentState));
Assert.Equal(JsonValueKind.Array, agentState.ValueKind);
Assert.Single(agent.ReceivedMessages);
// Arrange: Serialize and Deserialize the state to simulate persistence
string json = JsonSerializer.Serialize(savedState);
Assert.NotNull(json);
Assert.NotEmpty(json);
IDictionary<string, JsonElement> deserializedState = JsonSerializer.Deserialize<IDictionary<string, JsonElement>>(json)
?? throw new InvalidOperationException("Deserialized state is unexpectedly null");
Assert.True(deserializedState.ContainsKey(agentId.ToString()));
// Act: Start new runtime and restore the state
agent = null;
await using InProcessRuntime newRuntime = InProcessRuntime.StartNew();
await newRuntime.RegisterActorFactoryAsync(new("MyAgent"), async (id, runtime) =>
{
agent = new MockAgent(id, runtime, "another agent");
return agent;
});
// Assert: Show that no agent instances exist in the new runtime
Assert.Empty(newRuntime._actorInstances);
// Act: Load the state into the new runtime and show that agent is now instantiated
await newRuntime.LoadStateAsync(savedState);
// Assert
Assert.NotNull(agent);
Assert.Single(newRuntime._actorInstances);
Assert.True(newRuntime._actorInstances.ContainsKey(agentId));
Assert.Single(agent.ReceivedMessages);
}
#pragma warning disable CA1812 // Avoid uninstantiated internal classes
private sealed class WrongAgent : IRuntimeActor
#pragma warning restore CA1812
{
public ActorId Id => throw new NotImplementedException();
public ActorMetadata Metadata => throw new NotImplementedException();
public ValueTask LoadStateAsync(JsonElement state, CancellationToken cancellationToken = default)
{
throw new NotImplementedException();
}
public ValueTask<object?> OnMessageAsync(object message, MessageContext messageContext, CancellationToken cancellationToken = default)
{
throw new NotImplementedException();
}
public ValueTask<JsonElement> SaveStateAsync(CancellationToken cancellationToken = default)
{
throw new NotImplementedException();
}
}
}
@@ -1,34 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
namespace Microsoft.Extensions.AI.Agents.Runtime.Abstractions.Tests;
public class MessageContextTests
{
[Fact]
public void Properties_Roundtrip()
{
MessageContext ctx = new();
string id = ctx.MessageId;
Assert.NotNull(id);
Assert.True(Guid.TryParse(id, out _));
ctx.MessageId = "newid";
Assert.Equal("newid", ctx.MessageId);
Assert.False(ctx.IsRpc);
ctx.IsRpc = true;
Assert.True(ctx.IsRpc);
Assert.Null(ctx.Sender);
ActorId sender = new("type", "key");
ctx.Sender = sender;
Assert.Equal(sender, ctx.Sender);
Assert.Null(ctx.Topic);
TopicId topic = new("type", "source");
ctx.Topic = topic;
Assert.Equal(topic, ctx.Topic);
}
}
@@ -1,172 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Extensions.AI.Agents.Runtime.InProcess.Tests;
public sealed class BasicMessage
{
public string Content { get; set; } = string.Empty;
}
#pragma warning disable RCS1194 // Implement exception constructors
public sealed class TestException : Exception;
#pragma warning restore RCS1194 // Implement exception constructors
public sealed class PublisherAgent : TestAgent
{
public PublisherAgent(ActorId id, IAgentRuntime runtime, string description, IList<TopicId> targetTopics) : base(id, runtime, description)
{
this.RegisterMessageHandler<BasicMessage>(async (item, messageContext, cancellationToken) =>
{
this.ReceivedMessages.Add(item);
foreach (TopicId targetTopic in targetTopics)
{
await this.PublishMessageAsync(
new BasicMessage { Content = $"@{targetTopic}: {item.Content}" },
targetTopic,
cancellationToken: cancellationToken);
}
});
}
}
public sealed class SendOnAgent : TestAgent
{
public SendOnAgent(ActorId id, IAgentRuntime runtime, string description, IList<Guid> targetKeys) : base(id, runtime, description)
{
this.RegisterMessageHandler<BasicMessage>(async (item, messageContext, cancellationToken) =>
{
foreach (Guid targetKey in targetKeys)
{
ActorId targetId = new(nameof(ReceiverAgent), targetKey.ToString());
BasicMessage response = new() { Content = $"@{targetKey}: {item.Content}" };
await this.SendMessageAsync(response, targetId, cancellationToken: cancellationToken);
}
});
}
}
public sealed class ReceiverAgent : TestAgent
{
public List<BasicMessage> Messages { get; } = [];
public ReceiverAgent(ActorId id, IAgentRuntime runtime, string description) : base(id, runtime, description)
{
this.RegisterMessageHandler<BasicMessage>((item, messageContext) => this.Messages.Add(item));
}
}
public sealed class ProcessorAgent : TestAgent
{
public ProcessorAgent(ActorId id, IAgentRuntime runtime, Func<string, string> processFunc, string description) : base(id, runtime, description)
{
this.RegisterMessageHandler<BasicMessage, BasicMessage>(async (item, messageContext, cancellationtoken) =>
{
return new BasicMessage() { Content = processFunc.Invoke(((BasicMessage)item).Content) };
});
}
}
public sealed class CancelAgent : TestAgent
{
public CancelAgent(ActorId id, IAgentRuntime runtime, string description) : base(id, runtime, description)
{
this.RegisterMessageHandler<BasicMessage>((item, messageContext) =>
{
CancellationToken canceledToken = new(canceled: true);
canceledToken.ThrowIfCancellationRequested();
});
}
}
public sealed class ErrorAgent : TestAgent
{
public ErrorAgent(ActorId id, IAgentRuntime runtime, string description) : base(id, runtime, description)
{
this.RegisterMessageHandler<BasicMessage>((item, messageContext) =>
{
this.DidThrow = true;
throw new TestException();
});
}
public bool DidThrow { get; private set; }
}
public sealed class MessagingTestFixture
{
private Dictionary<Type, object> AgentsTypeMap { get; } = [];
public InProcessRuntime Runtime { get; } = new();
public ValueTask<ActorType> RegisterFactoryMapInstances<TAgent>(ActorType type, Func<ActorId, IAgentRuntime, ValueTask<TAgent>> factory)
where TAgent : IRuntimeActor
{
async ValueTask<TAgent> WrappedFactory(ActorId id, IAgentRuntime runtime)
{
TAgent agent = await factory(id, runtime);
this.GetAgentInstances<TAgent>()[id] = agent;
return agent;
}
return this.Runtime.RegisterActorFactoryAsync(type, WrappedFactory);
}
public Dictionary<ActorId, TAgent> GetAgentInstances<TAgent>() where TAgent : IRuntimeActor
{
if (!this.AgentsTypeMap.TryGetValue(typeof(TAgent), out object? maybeAgentMap) ||
maybeAgentMap is not Dictionary<ActorId, TAgent> result)
{
this.AgentsTypeMap[typeof(TAgent)] = result = [];
}
return result;
}
public async ValueTask RegisterReceiverAgentAsync(string? agentNameSuffix = null, params string[] topicTypes)
{
await this.RegisterFactoryMapInstances(
new($"{nameof(ReceiverAgent)}{agentNameSuffix ?? string.Empty}"),
(id, runtime) => new ValueTask<ReceiverAgent>(new ReceiverAgent(id, runtime, string.Empty)));
foreach (string topicType in topicTypes)
{
await this.Runtime.AddSubscriptionAsync(new TestSubscription(topicType, new($"{nameof(ReceiverAgent)}{agentNameSuffix ?? string.Empty}")));
}
}
public async ValueTask RegisterErrorAgentAsync(string? agentNameSuffix = null, params string[] topicTypes)
{
await this.RegisterFactoryMapInstances(
new($"{nameof(ErrorAgent)}{agentNameSuffix ?? string.Empty}"),
(id, runtime) => new ValueTask<ErrorAgent>(new ErrorAgent(id, runtime, string.Empty)));
foreach (string topicType in topicTypes)
{
await this.Runtime.AddSubscriptionAsync(new TestSubscription(topicType, new($"{nameof(ErrorAgent)}{agentNameSuffix ?? string.Empty}")));
}
}
public async ValueTask RunPublishTestAsync(TopicId sendTarget, object message, string? messageId = null)
{
messageId ??= Guid.NewGuid().ToString();
this.Runtime.Start();
await this.Runtime.PublishMessageAsync(message, sendTarget, messageId: messageId);
await this.Runtime.DisposeAsync();
}
public async ValueTask<object?> RunSendTestAsync(ActorId sendTarget, object message, string? messageId = null)
{
messageId ??= Guid.NewGuid().ToString();
this.Runtime.Start();
object? result = await this.Runtime.SendMessageAsync(message, sendTarget, messageId: messageId);
await this.Runtime.DisposeAsync();
return result;
}
}
@@ -1,110 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading.Tasks;
namespace Microsoft.Extensions.AI.Agents.Runtime.InProcess.Tests;
public class PublishMessageTests
{
[Fact]
public async Task Test_PublishMessage_SuccessAsync()
{
MessagingTestFixture fixture = new();
await fixture.RegisterReceiverAgentAsync(topicTypes: "TestTopic");
await fixture.RegisterReceiverAgentAsync("2", topicTypes: "TestTopic");
await fixture.RunPublishTestAsync(new TopicId("TestTopic"), new BasicMessage { Content = "1" });
var values = fixture.GetAgentInstances<ReceiverAgent>().Values;
Assert.Equal(2, values.Count);
Assert.All(values, receiverAgent =>
{
Assert.NotNull(receiverAgent.Messages);
Assert.Single(receiverAgent.Messages);
Assert.Contains(receiverAgent.Messages, m => m.Content == "1");
});
}
[Fact]
public async Task Test_PublishMessage_SingleFailureAsync()
{
MessagingTestFixture fixture = new();
await fixture.RegisterErrorAgentAsync(topicTypes: "TestTopic");
// Test that we wrap single errors appropriately
await Assert.ThrowsAsync<TestException>(async () => await fixture.RunPublishTestAsync(new TopicId("TestTopic"), new BasicMessage { Content = "1" }));
var values = fixture.GetAgentInstances<ReceiverAgent>().Values;
}
[Fact]
public async Task Test_PublishMessage_MultipleFailuresAsync()
{
MessagingTestFixture fixture = new();
await fixture.RegisterErrorAgentAsync(topicTypes: "TestTopic");
await fixture.RegisterErrorAgentAsync("2", topicTypes: "TestTopic");
// What we are really testing here is that a single exception does not prevent sending to the remaining agents
await Assert.ThrowsAsync<TestException>(async () => await fixture.RunPublishTestAsync(new TopicId("TestTopic"), new BasicMessage { Content = "1" }));
var values = fixture.GetAgentInstances<ErrorAgent>().Values;
Assert.Equal(2, values.Count);
}
[Fact]
public async Task Test_PublishMessage_MixedSuccessFailureAsync()
{
MessagingTestFixture fixture = new();
await fixture.RegisterReceiverAgentAsync(topicTypes: "TestTopic");
await fixture.RegisterReceiverAgentAsync("2", topicTypes: "TestTopic");
await fixture.RegisterErrorAgentAsync(topicTypes: "TestTopic");
await fixture.RegisterErrorAgentAsync("2", topicTypes: "TestTopic");
// What we are really testing here is that raising exceptions does not prevent sending to the remaining agents
await Assert.ThrowsAsync<TestException>(async () => await fixture.RunPublishTestAsync(new TopicId("TestTopic"), new BasicMessage { Content = "1" }));
var agents = fixture.GetAgentInstances<ReceiverAgent>().Values;
Assert.Equal(2, agents.Count);
Assert.All(agents, receiverAgent =>
{
Assert.NotNull(receiverAgent.Messages);
Assert.Single(receiverAgent.Messages);
Assert.Contains(receiverAgent.Messages, m => m.Content == "1");
});
var errors = fixture.GetAgentInstances<ErrorAgent>().Values;
Assert.Equal(2, errors.Count);
}
[Fact]
public async Task Test_PublishMessage_RecurrentPublishSucceedsAsync()
{
MessagingTestFixture fixture = new();
await fixture.RegisterFactoryMapInstances(
new(nameof(PublisherAgent)),
(id, runtime) => new ValueTask<PublisherAgent>(new PublisherAgent(id, runtime, string.Empty, [new TopicId("TestTopic")])));
await fixture.Runtime.AddSubscriptionAsync(new TestSubscription("RunTest", new(nameof(PublisherAgent))));
await fixture.RegisterReceiverAgentAsync(topicTypes: "TestTopic");
await fixture.RegisterReceiverAgentAsync("2", topicTypes: "TestTopic");
await fixture.RunPublishTestAsync(new TopicId("RunTest"), new BasicMessage { Content = "1" });
TopicId testTopicId = new("TestTopic");
var values = fixture.GetAgentInstances<ReceiverAgent>().Values;
Assert.Equal(2, values.Count);
Assert.All(values, receiver =>
{
Assert.NotNull(receiver.Messages);
Assert.Single(receiver.Messages);
Assert.Contains(receiver.Messages, m => m.Content == $"@{testTopicId}: 1");
});
}
}

Some files were not shown because too many files have changed in this diff Show More