diff --git a/dotnet/samples/GettingStarted/Orchestration/ConcurrentOrchestration_Intro.cs b/dotnet/samples/GettingStarted/Orchestration/ConcurrentOrchestration_Intro.cs index 1ab57d9e6b..6f152caaa6 100644 --- a/dotnet/samples/GettingStarted/Orchestration/ConcurrentOrchestration_Intro.cs +++ b/dotnet/samples/GettingStarted/Orchestration/ConcurrentOrchestration_Intro.cs @@ -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 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); } diff --git a/dotnet/samples/GettingStarted/Orchestration/ConcurrentOrchestration_With_StructuredOutput.cs b/dotnet/samples/GettingStarted/Orchestration/ConcurrentOrchestration_With_StructuredOutput.cs index 1b6ce726e2..1f4ad9ac04 100644 --- a/dotnet/samples/GettingStarted/Orchestration/ConcurrentOrchestration_With_StructuredOutput.cs +++ b/dotnet/samples/GettingStarted/Orchestration/ConcurrentOrchestration_With_StructuredOutput.cs @@ -32,21 +32,14 @@ public class ConcurrentOrchestration_With_StructuredOutput(ITestOutputHelper out description: "An expert in entity recognition"); // Define the orchestration with transform - StructuredOutputTransform outputTransform = new(this.CreateChatClient()); - ConcurrentOrchestration 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 result = await orchestration.InvokeAsync(input); - Analysis output = await result; + var output = await orchestration.RunAsync(this.CreateChatClient(), input); Console.WriteLine($"\n# RESULT:\n{JsonSerializer.Serialize(output, s_options)}"); } diff --git a/dotnet/samples/GettingStarted/Orchestration/GroupChatOrchestration_Intro.cs b/dotnet/samples/GettingStarted/Orchestration/GroupChatOrchestration_Intro.cs index 9a47dc7293..9ef505adeb 100644 --- a/dotnet/samples/GettingStarted/Orchestration/GroupChatOrchestration_Intro.cs +++ b/dotnet/samples/GettingStarted/Orchestration/GroupChatOrchestration_Intro.cs @@ -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 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); } diff --git a/dotnet/samples/GettingStarted/Orchestration/GroupChatOrchestration_With_AIManager.cs b/dotnet/samples/GettingStarted/Orchestration/GroupChatOrchestration_With_AIManager.cs index 185bccdc52..fbbc6ba317 100644 --- a/dotnet/samples/GettingStarted/Orchestration/GroupChatOrchestration_With_AIManager.cs +++ b/dotnet/samples/GettingStarted/Orchestration/GroupChatOrchestration_With_AIManager.cs @@ -132,8 +132,8 @@ public class GroupChatOrchestration_With_AIManager(ITestOutputHelper output) : O // Run the orchestration Console.WriteLine($"\n# INPUT: {topic}\n"); - OrchestrationResult 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); } diff --git a/dotnet/samples/GettingStarted/Orchestration/GroupChatOrchestration_With_HumanInTheLoop.cs b/dotnet/samples/GettingStarted/Orchestration/GroupChatOrchestration_With_HumanInTheLoop.cs index 7418cc54d8..d35892ecb9 100644 --- a/dotnet/samples/GettingStarted/Orchestration/GroupChatOrchestration_With_HumanInTheLoop.cs +++ b/dotnet/samples/GettingStarted/Orchestration/GroupChatOrchestration_With_HumanInTheLoop.cs @@ -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 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); } diff --git a/dotnet/samples/GettingStarted/Orchestration/HandoffOrchestration_Intro.cs b/dotnet/samples/GettingStarted/Orchestration/HandoffOrchestration_Intro.cs index cee35fa613..eceb911598 100644 --- a/dotnet/samples/GettingStarted/Orchestration/HandoffOrchestration_Intro.cs +++ b/dotnet/samples/GettingStarted/Orchestration/HandoffOrchestration_Intro.cs @@ -80,9 +80,9 @@ public class HandoffOrchestration_Intro(ITestOutputHelper output) : Orchestratio // Run the orchestration Console.WriteLine($"\n# INPUT:\n{task}\n"); - OrchestrationResult 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); } diff --git a/dotnet/samples/GettingStarted/Orchestration/HandoffOrchestration_With_StructuredInput.cs b/dotnet/samples/GettingStarted/Orchestration/HandoffOrchestration_With_StructuredInput.cs index 4778570fcb..45fa575dec 100644 --- a/dotnet/samples/GettingStarted/Orchestration/HandoffOrchestration_With_StructuredInput.cs +++ b/dotnet/samples/GettingStarted/Orchestration/HandoffOrchestration_With_StructuredInput.cs @@ -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 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 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"); } diff --git a/dotnet/samples/GettingStarted/Orchestration/SequentialOrchestration_Intro.cs b/dotnet/samples/GettingStarted/Orchestration/SequentialOrchestration_Intro.cs index 579aecf96f..f0bfe28d3e 100644 --- a/dotnet/samples/GettingStarted/Orchestration/SequentialOrchestration_Intro.cs +++ b/dotnet/samples/GettingStarted/Orchestration/SequentialOrchestration_Intro.cs @@ -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 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); } diff --git a/dotnet/samples/GettingStarted/Orchestration/SequentialOrchestration_With_Cancellation.cs b/dotnet/samples/GettingStarted/Orchestration/SequentialOrchestration_With_Cancellation.cs index 6b93c8590b..537efd5a62 100644 --- a/dotnet/samples/GettingStarted/Orchestration/SequentialOrchestration_With_Cancellation.cs +++ b/dotnet/samples/GettingStarted/Orchestration/SequentialOrchestration_With_Cancellation.cs @@ -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 result = await orchestration.InvokeAsync(input); + OrchestratingAgentResponse result = await orchestration.RunAsync([new ChatMessage(ChatRole.User, input)]); result.Cancel(); await Task.Delay(TimeSpan.FromSeconds(3)); diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/AgentsJsonContext.cs b/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/AgentsJsonContext.cs deleted file mode 100644 index be8f843439..0000000000 --- a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/AgentsJsonContext.cs +++ /dev/null @@ -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; -/// -/// Source-generated JSON type information for use by all Agents implementations. -/// -[JsonSourceGenerationOptions( - JsonSerializerDefaults.Web, - UseStringEnumConverter = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - WriteIndented = false)] -[JsonSerializable(typeof(ChatMessage))] -[JsonSerializable(typeof(List))] -[JsonSerializable(typeof(ChatClientAgentThread))] -[JsonSerializable(typeof(ChatClientAgentRunRequest))] -[JsonSerializable(typeof(AgentRunResponseUpdate))] -internal sealed partial class AgentsJsonContext : JsonSerializerContext; diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/ChatClientAgentActor.cs b/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/ChatClientAgentActor.cs index 221f287f3c..973400fbfc 100644 --- a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/ChatClientAgentActor.cs +++ b/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/ChatClientAgentActor.cs @@ -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 logger) : IActor +internal sealed class ChatClientAgentActor(AIAgent agent, JsonSerializerOptions jsonSerializerOptions, IActorRuntimeContext context, ILogger 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) diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/HelloHttpApi.ApiService.csproj b/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/HelloHttpApi.ApiService.csproj index 2d8579447a..e3f02a5f4c 100644 --- a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/HelloHttpApi.ApiService.csproj +++ b/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/HelloHttpApi.ApiService.csproj @@ -7,6 +7,7 @@ + diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/HostApplicationBuilderAgentExtensions.cs b/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/HostApplicationBuilderAgentExtensions.cs index ef09f456f5..7f02ecbcc6 100644 --- a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/HostApplicationBuilderAgentExtensions.cs +++ b/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/HostApplicationBuilderAgentExtensions.cs @@ -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(agentKey, (sp, key) => { var chatClient = chatClientKey is null ? sp.GetRequiredService() : sp.GetRequiredKeyedService(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(agentKey), + sp.GetRequiredKeyedService(agentKey), sp.GetService() ?? JsonSerializerOptions.Web, ctx, sp.GetRequiredService>())); diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/Program.cs b/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/Program.cs index e10c4b9477..4b963646b6 100644 --- a/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/Program.cs +++ b/dotnet/samples/HelloHttpApi/HelloHttpApi.ApiService/Program.cs @@ -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"); diff --git a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/AgentClient.cs b/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/AgentClient.cs index 46349509b4..7761cfdc31 100644 --- a/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/AgentClient.cs +++ b/dotnet/samples/HelloHttpApi/HelloHttpApi.Web/AgentClient.cs @@ -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 logger) { - private static readonly JsonSerializerOptions s_jsonOptions = new(JsonSerializerDefaults.Web) - { - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull - }; - public async IAsyncEnumerable SendMessageStreamAsync( string agentName, string message, @@ -27,7 +21,7 @@ public class AgentClient(HttpClient httpClient, ILogger logger) Messages = [new ChatMessage(ChatRole.User, message)] }; - var content = JsonContent.Create(request, s_jsonOptions.GetTypeInfo(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 logger) Messages = [new ChatMessage(ChatRole.User, message)] }; - var content = JsonContent.Create(request, s_jsonOptions.GetTypeInfo(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 logger) try { - var agentResponse = await response.Content.ReadFromJsonAsync(s_jsonOptions.GetTypeInfo(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 logger) try { - var eventData = JsonSerializer.Deserialize(jsonData, s_jsonOptions.GetTypeInfo(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 logger) // Try to deserialize as AgentRunResponseUpdate for intermediate updates try { - var update = JsonSerializer.Deserialize(eventElement.GetRawText(), s_jsonOptions); + var update = JsonSerializer.Deserialize(eventElement.GetRawText(), AgentAbstractionsJsonUtilities.DefaultOptions); if (update != null) { responseUpdate = update; @@ -171,32 +165,6 @@ public class AgentResponse public string Status { get; set; } = ""; } -/// -/// Provides extension methods for JSON serialization with source generation support. -/// -internal static class JsonSerializerExtensions -{ - /// - /// Gets the JsonTypeInfo for a type, preferring the one from options if available, - /// otherwise falling back to the source-generated context. - /// - /// The type to get JsonTypeInfo for. - /// The JsonSerializerOptions to check first. - /// The fallback JsonSerializerContext to use if not found in options. - /// The JsonTypeInfo for the requested type. - public static JsonTypeInfo GetTypeInfo(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 typeInfo) - { - return typeInfo; - } - - // Fall back to the provided source-generated context - return (JsonTypeInfo)fallbackContext.GetTypeInfo(typeof(T))!; - } -} - /// /// Source-generated JSON type information for use by AgentClient. /// @@ -206,10 +174,6 @@ internal static class JsonSerializerExtensions DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, WriteIndented = false)] [JsonSerializable(typeof(ChatClientAgentRunRequest))] -[JsonSerializable(typeof(ChatMessage))] -[JsonSerializable(typeof(List))] [JsonSerializable(typeof(EventData))] -[JsonSerializable(typeof(AgentRunResponseUpdate))] [JsonSerializable(typeof(AgentResponse))] -[JsonSerializable(typeof(JsonElement))] internal sealed partial class AgentClientJsonContext : JsonSerializerContext; diff --git a/dotnet/src/Microsoft.Agents.Orchestration/AIAgentExtensions.cs b/dotnet/src/Microsoft.Agents.Orchestration/AIAgentExtensions.cs new file mode 100644 index 0000000000..e6ca47e595 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/AIAgentExtensions.cs @@ -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; + +/// Provides extensions for orchestrating s. +public static class AIAgentExtensions +{ + private const string DefaultInstructions = "Respond with JSON that is populated by using the information in this conversation."; + + /// + /// Runs the agent with the messages, then uses the chat client to process the agent's output and return a structured response. + /// + /// The type of the result expected from the chat client response. + /// The AI agent to be run. + /// The chat client used to process the messages. + /// The message to be processed. + /// An optional thread context for the agent execution. + /// Optional settings that influence the agent's execution. + /// Optional serializer options to control how is deserialized. + /// A token to monitor for cancellation requests. + /// A task representing the asynchronous operation, with a result of type containing the + /// structured response. + public static ValueTask RunAsync( + 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( + agent, + chatClient, + [new ChatMessage(ChatRole.User, message)], + thread, + runOptions, + serializerOptions, + cancellationToken); + } + + /// + /// Runs the agent with the messages, then uses the chat client to process the agent's output and return a structured response. + /// + /// The type of the result expected from the chat client response. + /// The AI agent to be run. + /// The chat client used to process the messages. + /// A collection of chat messages to be processed. + /// An optional thread context for the agent execution. + /// Optional settings that influence the agent's execution. + /// Optional serializer options to control how is deserialized. + /// A token to monitor for cancellation requests. + /// A task representing the asynchronous operation, with a result of type containing the + /// structured response. + public static async ValueTask RunAsync( + this AIAgent agent, + IChatClient chatClient, + IReadOnlyCollection 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( + response.Messages, + serializerOptions: serializerOptions ?? AIJsonUtilities.DefaultOptions, + new ChatOptions() { Instructions = DefaultInstructions }, + cancellationToken: cancellationToken).ConfigureAwait(false); + + // Parse and return the results. + return result.Result; + } +} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/AgentActor.cs b/dotnet/src/Microsoft.Agents.Orchestration/AgentActor.cs deleted file mode 100644 index 9c46a45480..0000000000 --- a/dotnet/src/Microsoft.Agents.Orchestration/AgentActor.cs +++ /dev/null @@ -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; - -/// -/// An actor that represents an . -/// -public abstract class AgentActor : OrchestrationActor -{ - /// - /// Initializes a new instance of the class. - /// - /// The unique identifier of the agent. - /// The runtime associated with the agent. - /// The orchestration context. - /// An . - /// The logger to use for the actor - 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(); - } - - /// - /// Gets the associated agent. - /// - protected AIAgent Agent { get; } - - /// - /// Gets the current conversation thread used during agent communication. - /// - protected AgentThread Thread { get; private set; } - - /// - /// Reset the conversation thread. - /// - protected void ResetThread() - { - this.Thread = this.Agent.GetNewThread(); - } - - /// - /// Invokes the agent for a non-streamed responses. - /// - /// The messages to send. - /// The options for running the agent. - /// A cancellation token for the operation. - /// A task that represents the asynchronous operation. - /// - /// This method is not intended to be called directly; instead, use . - /// This method exists to be overridden in derived classes in order to customize the invocation of the agent by . - /// - protected virtual Task InvokeCoreAsync( - IReadOnlyCollection messages, AgentRunOptions? options, CancellationToken cancellationToken) => - this.Agent.RunAsync([.. messages], this.Thread, options, cancellationToken); - - /// - /// Invokes the agent for a streamed responses. - /// - /// The messages to send. - /// The options for running the agent. - /// A cancellation token for the operation. - /// A task that represents the asynchronous operation. - /// - /// This method is not intended to be called directly; instead, use . - /// This method exists to be overridden in derived classes in order to customize the invocation of the agent by . - /// - protected virtual IAsyncEnumerable InvokeStreamingCoreAsync( - IReadOnlyCollection messages, AgentRunOptions? options, CancellationToken cancellationToken) => - this.Agent.RunStreamingAsync(messages, this.Thread, options, cancellationToken); - - /// - /// Runs the agent with input messages and respond with both streamed and regular messages. - /// - /// The list of chat messages to send. - /// A cancellation token that can be used to cancel the operation. - /// A task that returns the response . - protected async ValueTask RunAsync(IEnumerable 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 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(); - } -} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/AgentOrchestration.RequestActor.cs b/dotnet/src/Microsoft.Agents.Orchestration/AgentOrchestration.RequestActor.cs deleted file mode 100644 index 0cc1d13a74..0000000000 --- a/dotnet/src/Microsoft.Agents.Orchestration/AgentOrchestration.RequestActor.cs +++ /dev/null @@ -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 -{ - /// - /// Actor responsible for receiving final message and transforming it into the output type. - /// - private sealed class RequestActor : OrchestrationActor - { - private readonly Func>> _transform; - private readonly Func, ValueTask> _action; - private readonly TaskCompletionSource _completionSource; - - /// - /// Initializes a new instance of the class. - /// - /// The unique identifier of the agent. - /// The runtime associated with the agent. - /// The orchestration context. - /// A function that transforms an input of type TInput into a source type TSource. - /// Optional TaskCompletionSource to signal orchestration completion. - /// An asynchronous function that processes the resulting source. - /// The logger to use for the actor - public RequestActor( - ActorId id, - IAgentRuntime runtime, - OrchestrationContext context, - Func>> transform, - TaskCompletionSource completionSource, - Func, ValueTask> action, - ILogger? logger = null) - : base(id, runtime, context, $"{id.Type}_Actor", logger) - { - this._transform = transform; - this._action = action; - this._completionSource = completionSource; - - this.RegisterMessageHandler(this.HandleAsync); - } - - /// - /// Handles the incoming message by transforming the input and executing the corresponding action asynchronously. - /// - /// The input message of type TInput. - /// The context of the message, providing additional details. - /// A token to cancel the operation if needed. - /// A ValueTask representing the asynchronous operation. - private async ValueTask HandleAsync(TInput item, MessageContext messageContext, CancellationToken cancellationToken) - { - this.Logger.LogOrchestrationRequestInvoke(this.Context.Orchestration, this.Id); - try - { - IEnumerable 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; - } - } - } -} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/AgentOrchestration.ResultActor.cs b/dotnet/src/Microsoft.Agents.Orchestration/AgentOrchestration.ResultActor.cs deleted file mode 100644 index 44f4d672cf..0000000000 --- a/dotnet/src/Microsoft.Agents.Orchestration/AgentOrchestration.ResultActor.cs +++ /dev/null @@ -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 -{ - /// - /// Actor responsible for receiving the resultant message, transforming it, and handling further orchestration. - /// - private sealed class ResultActor : OrchestrationActor - { - private readonly TaskCompletionSource _completionSource; - private readonly Func> _transformResult; - private readonly Func, JsonSerializerOptions?, CancellationToken, ValueTask> _transform; - - /// - /// Initializes a new instance of the class. - /// - /// The unique identifier of the agent. - /// The runtime associated with the agent. - /// The orchestration context. - /// A delegate that transforms a TResult instance into a ChatMessage. - /// A delegate that transforms a ChatMessage into a TOutput instance. - /// Optional TaskCompletionSource to signal orchestration completion. - /// The logger to use for the actor - public ResultActor( - ActorId id, - IAgentRuntime runtime, - OrchestrationContext context, - Func> transformResult, - Func, JsonSerializerOptions?, CancellationToken, ValueTask> transformOutput, - TaskCompletionSource completionSource, - ILogger>? logger = null) - : base(id, runtime, context, $"{id.Type}_Actor", logger) - { - this._completionSource = completionSource; - this._transformResult = transformResult; - this._transform = transformOutput; - - this.RegisterMessageHandler(this.HandleAsync); - } - - /// - /// 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. - /// - /// The result item to process. - /// The context associated with the message. - /// A token to cancel the operation if needed. - /// A ValueTask representing asynchronous operation. - 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 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; - } - } - } -} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/AgentOrchestration.cs b/dotnet/src/Microsoft.Agents.Orchestration/AgentOrchestration.cs deleted file mode 100644 index 07d4c30594..0000000000 --- a/dotnet/src/Microsoft.Agents.Orchestration/AgentOrchestration.cs +++ /dev/null @@ -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; - -/// -/// Base class for multi-agent agent orchestration patterns. -/// -/// The type of the input to the orchestration. -/// The type of the result output by the orchestration. -public abstract partial class AgentOrchestration -{ - /// - /// Initializes a new instance of the class. - /// - /// Specifies the member agents or orchestrations participating in this orchestration. - 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; - } - - /// - /// Gets the description of the orchestration. - /// - public string Description { get; init; } = string.Empty; - - /// - /// Gets the name of the orchestration. - /// - public string Name { get; init; } = string.Empty; - - /// - /// Gets the associated logger. - /// - public ILoggerFactory LoggerFactory { get; init; } = NullLoggerFactory.Instance; - - /// - /// Transforms the orchestration input into a source input suitable for processing. - /// - public Func>>? InputTransform { get; set; } - - /// - /// Transforms the processed result into the final output form. - /// - public Func, JsonSerializerOptions?, CancellationToken, ValueTask>? ResultTransform { get; set; } - - /// - /// Optional callback that is invoked for every agent response. - /// - public Func, ValueTask>? ResponseCallback { get; set; } - - /// - /// Optional callback that is invoked for every agent update. - /// - public Func? StreamingResponseCallback { get; set; } - - /// - /// Gets the list of member targets involved in the orchestration. - /// - protected IReadOnlyList Members { get; } - - /// - /// Orchestration identifier without generic parameters for use in - /// agent type and topic formatting as well as logging. - /// - protected string OrchestrationLabel { get; } - - /// - /// Initiates processing of the orchestration. - /// - /// The input message. - /// The runtime associated with the orchestration. - /// A cancellation token that can be used to cancel the operation. - public async ValueTask> 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 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(context, completion, orchestrationCancelSource, logger, temporaryRuntime); - } - - /// - /// Initiates processing according to the orchestration pattern. - /// - /// The runtime associated with the orchestration. - /// The unique identifier for the orchestration session. - /// The input to be transformed and processed. - /// The initial agent type used for starting the orchestration. - protected abstract ValueTask StartAsync(IAgentRuntime runtime, TopicId topic, IEnumerable input, ActorType? entryAgent); - - /// - /// Orchestration specific registration, including members and returns an optional entry agent. - /// - /// The runtime targeted for registration. - /// The orchestration context. - /// A registration context. - /// The logger to use during registration - /// The entry AgentType for the orchestration, if any. - protected abstract ValueTask RegisterOrchestrationAsync(IAgentRuntime runtime, OrchestrationContext context, RegistrationContext registrar, ILogger logger); - - /// - /// Formats and returns a unique AgentType based on the provided topic and suffix. - /// - /// The topic identifier used in formatting the agent type. - /// A suffix to differentiate the agent type. - /// A formatted AgentType object. - protected ActorType FormatAgentType(TopicId topic, string suffix) => new($"{topic.Type}_{suffix}"); - - /// - /// Registers the orchestration's root and boot agents, setting up completion and target routing. - /// - /// The runtime targeted for registration. - /// The orchestration context. - /// A TaskCompletionSource for the orchestration. - /// The actor type used for handoff. Only defined for nested orchestrations. - /// The AgentType representing the orchestration entry point. - private async ValueTask RegisterAsync(IAgentRuntime runtime, OrchestrationContext context, TaskCompletionSource 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); - 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, - completion, - input => this.StartAsync(runtime, context.Topic, input, entryAgent), - context.LoggerFactory.CreateLogger()); - return new ValueTask(actor); - }).ConfigureAwait(false); - - logger.LogOrchestrationRegistrationDone(context.Orchestration, context.Topic); - - return orchestrationEntry; - } - - /// - /// A context used during registration (). - /// - public sealed class RegistrationContext( - ActorType agentType, - IAgentRuntime runtime, - OrchestrationContext context, - TaskCompletionSource completion, - Func, JsonSerializerOptions?, CancellationToken, ValueTask> outputTransform) - { - /// - /// Register the final result type. - /// - public async ValueTask RegisterResultTypeAsync(Func> resultTransform) - { - // Register actor for final result - ActorType registeredType = - await runtime.RegisterOrchestrationAgentAsync( - agentType, - (agentId, runtime) => - { - ResultActor actor = - new(agentId, - runtime, - context, - resultTransform, - outputTransform, - completion, - context.LoggerFactory.CreateLogger>()); - return new ValueTask(actor); - }).ConfigureAwait(false); - - return registeredType; - } - } -} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/AgentOrchestrationLogMessages.cs b/dotnet/src/Microsoft.Agents.Orchestration/AgentOrchestrationLogMessages.cs deleted file mode 100644 index 9008ff8e35..0000000000 --- a/dotnet/src/Microsoft.Agents.Orchestration/AgentOrchestrationLogMessages.cs +++ /dev/null @@ -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; - -/// -/// Extensions for logging . -/// -/// -/// This extension uses the to -/// generate logging code at compile time to achieve optimized code. -/// -[ExcludeFromCodeCoverage] -internal static partial class AgentOrchestrationLogMessages -{ - /// - /// Logs the start of the registration phase for an orchestration. - /// - [LoggerMessage( - Level = LogLevel.Trace, - Message = "REGISTER {Orchestration} Start: {Topic}")] - public static partial void LogOrchestrationRegistrationStart( - this ILogger logger, - string orchestration, - TopicId topic); - - /// - /// Logs pattern actor registration. - /// - [LoggerMessage( - Level = LogLevel.Information, - Message = "REGISTER ACTOR {Orchestration} {label}: {AgentType}")] - public static partial void LogRegisterActor( - this ILogger logger, - string orchestration, - ActorType agentType, - string label); - - /// - /// Logs agent actor registration. - /// - [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); - - /// - /// Logs the end of the registration phase for an orchestration. - /// - [LoggerMessage( - Level = LogLevel.Trace, - Message = "REGISTER {Orchestration} Complete: {Topic}")] - public static partial void LogOrchestrationRegistrationDone( - this ILogger logger, - string orchestration, - TopicId topic); - - /// - /// Logs an orchestration invocation - /// - [LoggerMessage( - Level = LogLevel.Information, - Message = "INVOKE {Orchestration}: {Topic}")] - public static partial void LogOrchestrationInvoke( - this ILogger logger, - string orchestration, - TopicId topic); - - /// - /// Logs that the orchestration has started successfully and - /// yielded control back to the caller. - /// - [LoggerMessage( - Level = LogLevel.Trace, - Message = "YIELD {Orchestration}: {Topic}")] - public static partial void LogOrchestrationYield( - this ILogger logger, - string orchestration, - TopicId topic); - - /// - /// Logs the start an orchestration (top/outer). - /// - [LoggerMessage( - Level = LogLevel.Information, - Message = "START {Orchestration}: {AgentId}")] - public static partial void LogOrchestrationStart( - this ILogger logger, - string orchestration, - ActorId agentId); - - /// - /// Logs that orchestration request actor is active - /// - [LoggerMessage( - Level = LogLevel.Information, - Message = "INIT {Orchestration}: {AgentId}")] - public static partial void LogOrchestrationRequestInvoke( - this ILogger logger, - string orchestration, - ActorId agentId); - - /// - /// Logs that orchestration request actor experienced an unexpected failure. - /// - [LoggerMessage( - Level = LogLevel.Error, - Message = "FAILURE {Orchestration}: {AgentId}")] - public static partial void LogOrchestrationRequestFailure( - this ILogger logger, - string orchestration, - ActorId agentId, - Exception exception); - - /// - /// Logs that orchestration result actor is active - /// - [LoggerMessage( - Level = LogLevel.Information, - Message = "EXIT {Orchestration}: {AgentId}")] - public static partial void LogOrchestrationResultInvoke( - this ILogger logger, - string orchestration, - ActorId agentId); - - /// - /// Logs that orchestration result actor experienced an unexpected failure. - /// - [LoggerMessage( - Level = LogLevel.Error, - Message = "FAILURE {Orchestration}: {AgentId}")] - public static partial void LogOrchestrationResultFailure( - this ILogger logger, - string orchestration, - ActorId agentId, - Exception exception); -} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/AgentRuntimeExtensions.cs b/dotnet/src/Microsoft.Agents.Orchestration/AgentRuntimeExtensions.cs deleted file mode 100644 index 9ae4fcf822..0000000000 --- a/dotnet/src/Microsoft.Agents.Orchestration/AgentRuntimeExtensions.cs +++ /dev/null @@ -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; - -/// -/// Extension methods for . -/// -internal static class AgentRuntimeExtensions -{ - /// - /// Sends a message to the specified agent. - /// - 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); - - /// - /// Registers an agent factory for the specified agent type and associates it with the runtime. - /// - /// The runtime targeted for registration. - /// The type of agent to register. - /// The factory function for creating the agent. - /// The registered agent type. - public static async ValueTask RegisterOrchestrationAgentAsync(this IAgentRuntime runtime, ActorType agentType, Func> 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; - } - - /// - /// Subscribes the specified agent type to its own dedicated topic. - /// - /// The runtime for managing the subscription. - /// The agent type to subscribe. - public static Task SubscribeAsync(this IAgentRuntime runtime, ActorType agentType) => - runtime.AddSubscriptionAsync(new TypeSubscription(agentType.Name, agentType)).AsTask(); - - /// - /// Subscribes the specified agent type to the provided topics. - /// - /// The runtime for managing the subscription. - /// The agent type to subscribe. - /// A variable list of topics for subscription. - 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); - } - } -} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Concurrent/ConcurrentActor.cs b/dotnet/src/Microsoft.Agents.Orchestration/Concurrent/ConcurrentActor.cs deleted file mode 100644 index 13485c0835..0000000000 --- a/dotnet/src/Microsoft.Agents.Orchestration/Concurrent/ConcurrentActor.cs +++ /dev/null @@ -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; - -/// -/// An used with the . -/// -internal sealed class ConcurrentActor : AgentActor -{ - private readonly ActorType _handoffActor; - - /// - /// Initializes a new instance of the class. - /// - /// The unique identifier of the agent. - /// The runtime associated with the agent. - /// The orchestration context. - /// An . - /// Identifies the actor collecting results. - /// The logger to use for the actor - public ConcurrentActor(ActorId id, IAgentRuntime runtime, OrchestrationContext context, AIAgent agent, ActorType resultActor, ILogger? logger = null) - : base(id, runtime, context, agent, logger) - { - this._handoffActor = resultActor; - - this.RegisterMessageHandler(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); - } -} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Concurrent/ConcurrentMessages.cs b/dotnet/src/Microsoft.Agents.Orchestration/Concurrent/ConcurrentMessages.cs deleted file mode 100644 index c095f8f9c1..0000000000 --- a/dotnet/src/Microsoft.Agents.Orchestration/Concurrent/ConcurrentMessages.cs +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Generic; -using Microsoft.Extensions.AI; - -namespace Microsoft.Agents.Orchestration; - -/// -/// Common messages used by the . -/// -internal static class ConcurrentMessages -{ - /// - /// The input task for a . - /// - public sealed record Request(IList Messages); - - /// - /// A result from a . - /// - public sealed record Result(ChatMessage Message); -} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Concurrent/ConcurrentOrchestration.String.cs b/dotnet/src/Microsoft.Agents.Orchestration/Concurrent/ConcurrentOrchestration.String.cs deleted file mode 100644 index 81d49f6f3d..0000000000 --- a/dotnet/src/Microsoft.Agents.Orchestration/Concurrent/ConcurrentOrchestration.String.cs +++ /dev/null @@ -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; - -/// -/// An orchestration that broadcasts the input message to each agent. -/// -public sealed class ConcurrentOrchestration : ConcurrentOrchestration -{ - /// - /// Initializes a new instance of the class. - /// - /// The agents to be orchestrated. - public ConcurrentOrchestration(params AIAgent[] members) - : base(members) - { - this.ResultTransform = - (response, _, cancellationToken) => - { - string[] result = [.. response.Select(r => r.Text)]; - return new ValueTask(result); - }; - } -} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Concurrent/ConcurrentOrchestration.cs b/dotnet/src/Microsoft.Agents.Orchestration/Concurrent/ConcurrentOrchestration.cs deleted file mode 100644 index f40aa856d6..0000000000 --- a/dotnet/src/Microsoft.Agents.Orchestration/Concurrent/ConcurrentOrchestration.cs +++ /dev/null @@ -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; - -/// -/// An orchestration that broadcasts the input message to each agent. -/// -/// -/// TOutput must be an array type for . -/// -public class ConcurrentOrchestration - : AgentOrchestration -{ - /// - /// Initializes a new instance of the class. - /// - /// The agents participating in the orchestration. - public ConcurrentOrchestration(params AIAgent[] agents) - : base(agents) - { - } - - /// - protected override ValueTask StartAsync(IAgentRuntime runtime, TopicId topic, IEnumerable input, ActorType? entryAgent) - { - return runtime.PublishMessageAsync(new ConcurrentMessages.Request([.. input]), topic); - } - - /// - protected override async ValueTask RegisterOrchestrationAsync(IAgentRuntime runtime, OrchestrationContext context, RegistrationContext registrar, ILogger logger) - { - ActorType outputType = await registrar.RegisterResultTypeAsync(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()); - 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()); - return new ValueTask(actor); - }).ConfigureAwait(false); - - logger.LogRegisterActor(this.OrchestrationLabel, agentType, "MEMBER", agentCount); - - await runtime.SubscribeAsync(agentType, context.Topic).ConfigureAwait(false); - } - - return null; - } -} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Concurrent/ConcurrentOrchestrationLogMessages.cs b/dotnet/src/Microsoft.Agents.Orchestration/Concurrent/ConcurrentOrchestrationLogMessages.cs deleted file mode 100644 index d74eea7965..0000000000 --- a/dotnet/src/Microsoft.Agents.Orchestration/Concurrent/ConcurrentOrchestrationLogMessages.cs +++ /dev/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; - -/// -/// Extensions for logging . -/// -/// -/// This extension uses the to -/// generate logging code at compile time to achieve optimized code. -/// -[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); - - /// - /// Logs result capture. - /// - [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); -} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Concurrent/ConcurrentResultActor.cs b/dotnet/src/Microsoft.Agents.Orchestration/Concurrent/ConcurrentResultActor.cs deleted file mode 100644 index c8741501a4..0000000000 --- a/dotnet/src/Microsoft.Agents.Orchestration/Concurrent/ConcurrentResultActor.cs +++ /dev/null @@ -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; - -/// -/// Actor for capturing each message. -/// -internal sealed class ConcurrentResultActor : OrchestrationActor -{ - private readonly ConcurrentQueue _results; - private readonly ActorType _orchestrationType; - private readonly int _expectedCount; - private int _resultCount; - - /// - /// Initializes a new instance of the class. - /// - /// The unique identifier of the agent. - /// The runtime associated with the agent. - /// The orchestration context. - /// Identifies the orchestration agent. - /// The expected number of messages to be received. - /// The logger to use for the actor - 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(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); - } - } -} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/ConcurrentOrchestration.cs b/dotnet/src/Microsoft.Agents.Orchestration/ConcurrentOrchestration.cs new file mode 100644 index 0000000000..e920929b21 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/ConcurrentOrchestration.cs @@ -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; + +/// Provides an orchestrating agent that broadcasts the input message to each agent and then aggregates the result into a single response. +public partial class ConcurrentOrchestration : OrchestratingAgent +{ + private Func>? _aggregationFunc; + + /// Initializes a new instance of the class. + /// The agents participating in the orchestration. + public ConcurrentOrchestration(params AIAgent[] subagents) : base(subagents) + { + } + + /// Gets or sets the function to use to aggregate an from each participating agent into a single . + /// The default function takes the last message from each response and puts those messages into a new response instance. + public Func> 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; + } + + /// + protected override Task RunCoreAsync(IReadOnlyCollection messages, OrchestratingAgentContext context, CancellationToken cancellationToken) => + this.ResumeAsync(messages, new AgentRunResponse?[this.Agents.Count], context, cancellationToken); + + /// + protected override Task 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); + } + + /// + private async Task ResumeAsync( + IReadOnlyCollection input, AgentRunResponse?[] completed, OrchestratingAgentContext context, CancellationToken cancellationToken) + { + List 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 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 Messages, AgentRunResponse?[] Completed); +} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/DefaultTransforms.cs b/dotnet/src/Microsoft.Agents.Orchestration/DefaultTransforms.cs deleted file mode 100644 index 054004bb6f..0000000000 --- a/dotnet/src/Microsoft.Agents.Orchestration/DefaultTransforms.cs +++ /dev/null @@ -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> FromInput(TInput input, JsonSerializerOptions? serializerOptions = null, CancellationToken cancellationToken = default) - { - serializerOptions ??= AgentAbstractionsJsonUtilities.DefaultOptions; - return new(input switch - { - IEnumerable 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 ToOutput(IList 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)}."); - } -} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/GroupChat/GroupChatAgentActor.cs b/dotnet/src/Microsoft.Agents.Orchestration/GroupChat/GroupChatAgentActor.cs deleted file mode 100644 index d66ba89628..0000000000 --- a/dotnet/src/Microsoft.Agents.Orchestration/GroupChat/GroupChatAgentActor.cs +++ /dev/null @@ -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; - -/// -/// An used with the . -/// -internal sealed class GroupChatAgentActor : AgentActor -{ - private readonly List _cache; - - /// - /// Initializes a new instance of the class. - /// - /// The unique identifier of the agent. - /// The runtime associated with the agent. - /// The orchestration context. - /// An . - /// The logger to use for the actor - public GroupChatAgentActor(ActorId id, IAgentRuntime runtime, OrchestrationContext context, AIAgent agent, ILogger? logger = null) - : base(id, runtime, context, agent, logger) - { - this._cache = []; - - this.RegisterMessageHandler((item, ctx) => this._cache.AddRange(item.Messages)); - this.RegisterMessageHandler((item, ctx) => this.ResetThread()); - this.RegisterMessageHandler(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); - } -} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/GroupChat/GroupChatManagerActor.cs b/dotnet/src/Microsoft.Agents.Orchestration/GroupChat/GroupChatManagerActor.cs deleted file mode 100644 index 15a995f51d..0000000000 --- a/dotnet/src/Microsoft.Agents.Orchestration/GroupChat/GroupChatManagerActor.cs +++ /dev/null @@ -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; - -/// -/// An used to manage a . -/// -internal sealed class GroupChatManagerActor : OrchestrationActor -{ - /// - /// A common description for the manager. - /// - 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 _chat; - private readonly GroupChatTeam _team; - - /// - /// Initializes a new instance of the class. - /// - /// The unique identifier of the agent. - /// The runtime associated with the agent. - /// The orchestration context. - /// The manages the flow of the group-chat. - /// The team of agents being orchestrated - /// Identifies the orchestration agent. - /// The logger to use for the actor - 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(this.HandleAsync); - this.RegisterMessageHandler(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); - } - - /// - 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 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 terminateResult = await this._manager.ShouldTerminate(this._chat, cancellationToken).ConfigureAwait(false); - this.Logger.LogChatManagerTerminate(this.Id, terminateResult.Value, terminateResult.Reason); - if (terminateResult.Value) - { - GroupChatManagerResult 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 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); - } -} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/GroupChat/GroupChatMessages.cs b/dotnet/src/Microsoft.Agents.Orchestration/GroupChat/GroupChatMessages.cs deleted file mode 100644 index a70236e70b..0000000000 --- a/dotnet/src/Microsoft.Agents.Orchestration/GroupChat/GroupChatMessages.cs +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Generic; -using Microsoft.Extensions.AI; - -namespace Microsoft.Agents.Orchestration; - -/// -/// Common messages used for agent chat patterns. -/// -internal static class GroupChatMessages -{ - /// - /// Broadcast a message to all . - /// - public sealed record Group(IEnumerable Messages); - - /// - /// Reset/clear the conversation history for all . - /// - public sealed class Reset; - - /// - /// The final result. - /// - public sealed record Result(ChatMessage Message); - - /// - /// Signal a to respond. - /// - public sealed class Speak; - - /// - /// The input task. - /// - public sealed record InputTask(IEnumerable Messages) - { - /// - /// Gets an input task that does not require any action. - /// - public static InputTask None { get; } = new([]); - } -} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/GroupChat/GroupChatOrchestration.String.cs b/dotnet/src/Microsoft.Agents.Orchestration/GroupChat/GroupChatOrchestration.String.cs deleted file mode 100644 index ab549b86e1..0000000000 --- a/dotnet/src/Microsoft.Agents.Orchestration/GroupChat/GroupChatOrchestration.String.cs +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Extensions.AI.Agents; - -namespace Microsoft.Agents.Orchestration; - -/// -/// An orchestration that broadcasts the input message to each agent. -/// -public sealed class GroupChatOrchestration : GroupChatOrchestration -{ - /// - /// Initializes a new instance of the class. - /// - /// The manages the flow of the group-chat. - /// The agents to be orchestrated. - public GroupChatOrchestration(GroupChatManager manager, params AIAgent[] members) - : base(manager, members) - { - } -} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/GroupChat/GroupChatOrchestration.cs b/dotnet/src/Microsoft.Agents.Orchestration/GroupChat/GroupChatOrchestration.cs index 0a10e8242b..c79656b299 100644 --- a/dotnet/src/Microsoft.Agents.Orchestration/GroupChat/GroupChatOrchestration.cs +++ b/dotnet/src/Microsoft.Agents.Orchestration/GroupChat/GroupChatOrchestration.cs @@ -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; /// -/// An orchestration that coordinates a group-chat. +/// An orchestration that coordinates a group-chat using a manager to control conversation flow. /// -public class GroupChatOrchestration : - AgentOrchestration +public sealed partial class GroupChatOrchestration : OrchestratingAgent { - internal const string DefaultAgentDescription = "A helpful agent."; - private readonly GroupChatManager _manager; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// - /// The manages the flow of the group-chat. + /// The manager that controls the flow of the group-chat. /// The agents participating in the orchestration. - 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; + /// Gets or sets a callback invoked when user input is requested. + public Func>? InteractiveCallback { get; set; } + + /// + protected override Task RunCoreAsync(IReadOnlyCollection messages, OrchestratingAgentContext context, CancellationToken cancellationToken) + { + List allMessages = [.. messages]; + int originalMessageCount = allMessages.Count; + return this.ResumeAsync(allMessages, originalMessageCount, context, cancellationToken); } /// - protected override ValueTask StartAsync(IAgentRuntime runtime, TopicId topic, IEnumerable input, ActorType? entryAgent) + protected override Task 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); } - /// - protected override async ValueTask RegisterOrchestrationAsync(IAgentRuntime runtime, OrchestrationContext context, RegistrationContext registrar, ILogger logger) + private async Task ResumeAsync( + List allMessages, int originalMessageCount, OrchestratingAgentContext context, CancellationToken cancellationToken) { - ActorType outputType = await registrar.RegisterResultTypeAsync(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()); - return new ValueTask(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 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()); - return new ValueTask(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 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 AllMessages, int OriginalMessageCount); } diff --git a/dotnet/src/Microsoft.Agents.Orchestration/GroupChat/GroupChatOrchestrationLogMessages.cs b/dotnet/src/Microsoft.Agents.Orchestration/GroupChat/GroupChatOrchestrationLogMessages.cs deleted file mode 100644 index 83446d778f..0000000000 --- a/dotnet/src/Microsoft.Agents.Orchestration/GroupChat/GroupChatOrchestrationLogMessages.cs +++ /dev/null @@ -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; - -/// -/// Extensions for logging . -/// -/// -/// This extension uses the to -/// generate logging code at compile time to achieve optimized code. -/// -[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); -} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Handoff/HandoffActor.cs b/dotnet/src/Microsoft.Agents.Orchestration/Handoff/HandoffActor.cs deleted file mode 100644 index f6c8c1a151..0000000000 --- a/dotnet/src/Microsoft.Agents.Orchestration/Handoff/HandoffActor.cs +++ /dev/null @@ -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; - -/// -/// An actor used with the . -/// -internal sealed partial class HandoffActor : AgentActor -{ - private readonly ChatClientAgent _chatAgent; - private readonly HandoffLookup _handoffs; - private readonly ActorType _resultHandoff; - private readonly List _cache; - private readonly ChatOptions _options; - - private string? _handoffAgent; - private string? _taskSummary; - - /// - /// Initializes a new instance of the class. - /// - /// The unique identifier of the agent. - /// The runtime associated with the agent. - /// The orchestration context. - /// An .> - /// The handoffs available to this agent - /// The handoff agent for capturing the result. - /// The logger to use for the actor - public HandoffActor(ActorId id, IAgentRuntime runtime, OrchestrationContext context, ChatClientAgent agent, HandoffLookup handoffs, ActorType resultHandoff, ILogger? 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(this.Handle); - this.RegisterMessageHandler(this.HandleAsync); - this.RegisterMessageHandler(this.Handle); - } - - /// - protected override Task InvokeCoreAsync( - IReadOnlyCollection messages, AgentRunOptions? options, CancellationToken cancellationToken) => - this._chatAgent.RunAsync([.. messages], this.Thread, options, this._options, cancellationToken); - - /// - protected override IAsyncEnumerable InvokeStreamingCoreAsync( - IReadOnlyCollection messages, AgentRunOptions? options, CancellationToken cancellationToken) => - this._chatAgent.RunStreamingAsync(messages, this.Thread, options, this._options, cancellationToken); - - /// - /// Gets or sets the callback to be invoked for interactive input. - /// - public Func>? 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 CreateHandoffFunctions() - { - List 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 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; - } - } - - /// Regex that flags any character other than ASCII digits or letters or the underscore. -#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 -} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Handoff/HandoffMessages.cs b/dotnet/src/Microsoft.Agents.Orchestration/Handoff/HandoffMessages.cs deleted file mode 100644 index a4b68b3bb0..0000000000 --- a/dotnet/src/Microsoft.Agents.Orchestration/Handoff/HandoffMessages.cs +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Generic; -using Microsoft.Extensions.AI; - -namespace Microsoft.Agents.Orchestration; - -/// -/// A message that describes the input task and captures results for a . -/// -internal static class HandoffMessages -{ - /// - /// The input message. - /// - public sealed record InputTask(IList Messages); - - /// - /// The final result. - /// - public sealed record Result(ChatMessage Message); - - /// - /// Signals the handoff to another agent. - /// - public sealed class Request; - - /// - /// Broadcast an agent response to all actors in the orchestration. - /// - public sealed record Response(ChatMessage Message); -} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Handoff/HandoffOrchestration.String.cs b/dotnet/src/Microsoft.Agents.Orchestration/Handoff/HandoffOrchestration.String.cs deleted file mode 100644 index 873b73e977..0000000000 --- a/dotnet/src/Microsoft.Agents.Orchestration/Handoff/HandoffOrchestration.String.cs +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Extensions.AI.Agents; - -namespace Microsoft.Agents.Orchestration; - -/// -/// An orchestration that passes the input message to the first agent, and -/// then the subsequent result to the next agent, etc... -/// -public sealed class HandoffOrchestration : HandoffOrchestration -{ - /// - /// Initializes a new instance of the class. - /// - /// Defines the handoff connections for each agent. - /// The agents to be orchestrated. - public HandoffOrchestration(OrchestrationHandoffs handoffs, params AIAgent[] members) - : base(handoffs, members) - { - } -} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Handoff/HandoffOrchestration.cs b/dotnet/src/Microsoft.Agents.Orchestration/Handoff/HandoffOrchestration.cs deleted file mode 100644 index 2d515b9332..0000000000 --- a/dotnet/src/Microsoft.Agents.Orchestration/Handoff/HandoffOrchestration.cs +++ /dev/null @@ -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; - -/// -/// An orchestration that provides the input message to the first agent -/// and sequentially passes each agent result to the next agent. -/// -public class HandoffOrchestration : AgentOrchestration -{ - private readonly OrchestrationHandoffs _handoffs; - - /// - /// Initializes a new instance of the class. - /// - /// Defines the handoff connections for each agent. - /// Additional agents participating in the orchestration that weren't passed to . - 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 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; - } - - /// - /// Gets or sets the callback to be invoked for interactive input. - /// - public Func>? InteractiveCallback { get; init; } - - /// - protected override async ValueTask StartAsync(IAgentRuntime runtime, TopicId topic, IEnumerable 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); - } - - /// - protected override async ValueTask RegisterOrchestrationAsync(IAgentRuntime runtime, OrchestrationContext context, RegistrationContext registrar, ILogger logger) - { - ActorType outputType = await registrar.RegisterResultTypeAsync(response => [response.Message]).ConfigureAwait(false); - - // Each agent handsoff its result to the next agent. - Dictionary agentMap = []; - Dictionary 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()) - { - InteractiveCallback = this.InteractiveCallback - }; - return new ValueTask(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 handoffs in this._handoffs) - { - // Retrieve the map for the agent (every agent had an empty map created) - HandoffLookup agentHandoffs = handoffMap[handoffs.Key]; - foreach (KeyValuePair 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}"); -} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Handoff/HandoffOrchestrationLogMessages.cs b/dotnet/src/Microsoft.Agents.Orchestration/Handoff/HandoffOrchestrationLogMessages.cs deleted file mode 100644 index ee9c1f1a73..0000000000 --- a/dotnet/src/Microsoft.Agents.Orchestration/Handoff/HandoffOrchestrationLogMessages.cs +++ /dev/null @@ -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; - -/// -/// Extensions for logging . -/// -/// -/// This extension uses the to -/// generate logging code at compile time to achieve optimized code. -/// -[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); -} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Handoffs/HandoffOrchestration.cs b/dotnet/src/Microsoft.Agents.Orchestration/Handoffs/HandoffOrchestration.cs new file mode 100644 index 0000000000..09449de2f0 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/Handoffs/HandoffOrchestration.cs @@ -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; + +/// +/// An orchestration that provides the input message to the first agent +/// and sequentially passes each agent result to the next agent. +/// +public sealed partial class HandoffOrchestration : OrchestratingAgent +{ + private readonly OrchestrationHandoffs _handoffs; + + /// + /// Initializes a new instance of the class. + /// + /// Defines the handoff connections for each agent. + /// Additional agents participating in the orchestration that weren't passed to . + 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 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; + } + + /// Gets or sets a callback invoked when no next handoff is selected in order to supply + public Func>? InteractiveCallback { get; set; } + + /// + protected override Task RunCoreAsync(IReadOnlyCollection messages, OrchestratingAgentContext context, CancellationToken cancellationToken) + { + List allMessages = [.. messages]; + int originalMessageCount = allMessages.Count; + return this.ResumeAsync(this._handoffs.FirstAgentName, allMessages, originalMessageCount, context, cancellationToken); + } + + /// + protected override Task 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); + } + + /// + private async Task ResumeAsync( + string? nextAgent, List 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 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 handoffTools) + { + HashSet? removeToolNames = null; + HashSet? 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 CreateHandoffFunctions(bool needsEndTask) + { + List 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 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 AllMessages, int OriginalMessageCount); + + /// Regex that flags any character other than ASCII digits or letters or the underscore. +#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 +} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Handoff/Handoffs.cs b/dotnet/src/Microsoft.Agents.Orchestration/Handoffs/Handoffs.cs similarity index 93% rename from dotnet/src/Microsoft.Agents.Orchestration/Handoff/Handoffs.cs rename to dotnet/src/Microsoft.Agents.Orchestration/Handoffs/Handoffs.cs index d1aac7efd2..50d36f0001 100644 --- a/dotnet/src/Microsoft.Agents.Orchestration/Handoff/Handoffs.cs +++ b/dotnet/src/Microsoft.Agents.Orchestration/Handoffs/Handoffs.cs @@ -36,7 +36,7 @@ public sealed class OrchestrationHandoffs : Dictionary /// /// The first agent to be invoked (prior to any handoff). 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 /// The updated instance. 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 } 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 /// The handoff description. /// The updated instance. 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); + } /// /// 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 /// The handoff description. /// The updated instance. 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); + } /// /// Adds a handoff relationship from a source agent name/ID to a target agent name/ID with a custom description. diff --git a/dotnet/src/Microsoft.Agents.Orchestration/OrchestratingAgent.cs b/dotnet/src/Microsoft.Agents.Orchestration/OrchestratingAgent.cs new file mode 100644 index 0000000000..5a926c4d43 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/OrchestratingAgent.cs @@ -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; + +/// +/// Base class for multi-agent agent orchestration patterns. +/// +public abstract partial class OrchestratingAgent : AIAgent +{ + /// Key used to persist state with the runtime. + private const string StateKey = "State"; + + /// + /// Initializes a new instance of the class. + /// + /// Specifies the agents participating in this orchestration. + protected OrchestratingAgent(IReadOnlyList agents) + { + _ = Throw.IfNullOrEmpty(agents); + + this.Agents = agents; + } + + /// + /// Gets the list of member targets involved in the orchestration. + /// + protected IReadOnlyList Agents { get; } + + /// Gets the serializer options to use by the orchestration. + public JsonSerializerOptions? SerializerOptions { get; set; } + + /// + /// Gets the associated logger. + /// + public ILoggerFactory LoggerFactory { get; set; } = NullLoggerFactory.Instance; + + /// + /// Optional callback that is invoked for every agent response. + /// + public Func, ValueTask>? ResponseCallback { get; set; } + + /// + /// Optional callback that is invoked for every agent update. + /// + public Func? StreamingResponseCallback { get; set; } + + /// + public sealed override async Task RunAsync( + IReadOnlyCollection messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(messages); + + if (thread is not null) + { + if (thread 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 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); + } + + /// + public sealed override async IAsyncEnumerable RunStreamingAsync( + IReadOnlyCollection messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + // TODO: There should be a RunAsync overload that returns an OrchestratingAgentStreamingResponse, which this then delegates to. + + var response = await this.RunAsync(messages, thread, options, cancellationToken).ConfigureAwait(false); + foreach (var update in response.ToAgentRunResponseUpdates()) + { + yield return update; + } + } + + /// + public sealed override AgentThread GetNewThread() => new ChatClientAgentThread(); + + /// + /// Initiates processing of the orchestration. + /// + /// The input message. + /// Optional parameters for agent invocation. + /// The runtime associated with the orchestration. + /// The to monitor for cancellation requests. The default is . + public async ValueTask RunAsync( + IReadOnlyCollection messages, + AgentRunOptions? options = null, + IActorRuntimeContext? runtime = null, + CancellationToken cancellationToken = default) + { + Throw.IfNull(messages, nameof(messages)); + cancellationToken.ThrowIfCancellationRequested(); + + ILogger logger = this.LoggerFactory.CreateLogger(this.GetType().Name); + + OrchestratingAgentContext context = new() + { + OrchestratingAgent = this, + Runtime = runtime, + Options = options, + Logger = logger, + }; + + LogOrchestrationInvoked(logger, this.DisplayName, context.Id); + + CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cancellationToken = cts.Token; + + JsonElement? checkpoint = await this.ReadCheckpointAsync(context, cancellationToken).ConfigureAwait(false); + Task completion = checkpoint is null ? + this.RunCoreAsync(messages, context, cancellationToken) : + this.ResumeCoreAsync(checkpoint.Value, context, cancellationToken); + + if (logger.IsEnabled(LogLevel.Trace)) + { + _ = LogCompletionAsync(logger, context, completion); + } + + return new OrchestratingAgentResponse(context, completion, cts, logger); + } + + /// + /// Initiates processing of the orchestration. + /// + /// The input message. + /// The context for this operation. + /// A cancellation token that can be used to cancel the operation. + protected abstract Task RunCoreAsync(IReadOnlyCollection messages, OrchestratingAgentContext context, CancellationToken cancellationToken); + + /// + /// Resumes processing of the orchestration. + /// + /// The last checkpoint state available from which to resume the operation. + /// The context for this operation. + /// A cancellation token that can be used to cancel the operation. + protected abstract Task ResumeCoreAsync(JsonElement checkpointState, OrchestratingAgentContext context, CancellationToken cancellationToken); + + /// + /// Runs the agent with input messages and respond with both streamed and regular messages. + /// + /// The agent being run + /// The associated orchestration context for this run. + /// The list of chat messages to send. + /// Options to use when invoking the agent. + /// A cancellation token that can be used to cancel the operation. + /// A task that returns the response . + protected static async ValueTask RunAsync(AIAgent agent, OrchestratingAgentContext context, IReadOnlyCollection input, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + { + // Utilize streaming iff a streaming callback is provided; otherwise, use the non-streaming API. + AgentRunResponse response; + if (context.OrchestratingAgent?.StreamingResponseCallback is { } streamingCallback) + { + // For streaming, enumerate all the updates, invoking the callback for each, and storing them all. + // Then convert them all into a single response instance. + List updates = []; + + await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync(input, options: options ?? context.Options, cancellationToken: cancellationToken).ConfigureAwait(false)) + { + updates.Add(update); + await streamingCallback(update).ConfigureAwait(false); + } + + response = updates.ToAgentRunResponse(); + } + else + { + // For non-streaming, just invoke the non-streaming method and get back the response. + response = await agent.RunAsync(input, options: options ?? context.Options, cancellationToken: cancellationToken).ConfigureAwait(false); + } + + // Regardless of whether we invoked streaming callbacks for individual updates, invoke the non-streaming callback with the final response instance. + // This can be used as an indication of completeness if someone otherwise only cares about the streaming updates. + if (context.OrchestratingAgent?.ResponseCallback is { } responseCallback) + { + await responseCallback.Invoke(response.Messages).ConfigureAwait(false); + } + + return response; + } + + /// + protected sealed override TThreadType ValidateOrCreateThreadType(AgentThread? thread, Func constructThread) => + base.ValidateOrCreateThreadType(thread, constructThread); + + /// Writes the specified checkpoint state to the runtime. + /// The state to persist. + /// The context for the orchestrating operation. + /// A cancellation token that can be used to cancel the operation. + /// A Task that completes when the asynchronous operation quiesces. + protected async Task WriteCheckpointAsync(JsonElement state, OrchestratingAgentContext context, CancellationToken cancellationToken) + { + _ = Throw.IfNull(context); + + if (context.Runtime is not null) + { + while (true) + { + var response = await context.Runtime.WriteAsync( + new ActorWriteOperationBatch(context.ETag ?? "", [new SetValueOperation(StateKey, state)]), + cancellationToken).ConfigureAwait(false); + + if (response.Success) + { + break; + } + + // If the write failed, there was a concurrency conflict where someone else updated the state. + // But we don't actually care about consistency between the previous checkpoint and the current one, + // so we just retry the write with the new etag. + context.ETag = response.ETag; + } + } + } + + /// Read checkpoint information, if it exists, for the specified context. + /// The context for the orchestrating operation. + /// A cancellation token that can be used to cancel the operation. + /// The loaded state, or null if it doesn't exist. + protected async ValueTask ReadCheckpointAsync(OrchestratingAgentContext context, CancellationToken cancellationToken) + { + _ = Throw.IfNull(context); + + if (context.Runtime is not null) + { + ReadResponse response = await context.Runtime.ReadAsync( + new ActorReadOperationBatch([new GetValueOperation(StateKey)]), + cancellationToken).ConfigureAwait(false); + + context.ETag = response.ETag; + + if (response.Results is { } results && + results[results.Count - 1] is GetValueResult { Value: not null } getValueResult) + { + return getValueResult.Value.Value; + } + } + + return default; + } + + [LoggerMessage(Level = LogLevel.Trace, Message = "{Orchestration} started ('{Id}')")] + private static partial void LogOrchestrationInvoked(ILogger logger, string orchestration, string id); + + [LoggerMessage(Level = LogLevel.Trace, Message = "{Orchestration} completed ('{Id}'). Result: '{Result}'")] + private static partial void LogOrchestrationResult(ILogger logger, string orchestration, string id, string result); + + [LoggerMessage(Level = LogLevel.Trace, Message = "{Orchestration} cancellation requested ('{Id}')")] + internal static partial void LogOrchestrationCancellationRequested(ILogger logger, string orchestration, string id); + + [LoggerMessage(Level = LogLevel.Trace, Message = "{Orchestration} failed ('{Id}')")] + private static partial void LogOrchestrationFailure(ILogger logger, string orchestration, string id, Exception error); + + [LoggerMessage(Level = LogLevel.Trace, Message = "{Orchestration} invoking agent '{Agent}' ('{Id}')")] + private static partial void LogOrchestrationSubagentRunning(ILogger logger, string orchestration, string id, string agent); + + [LoggerMessage(Level = LogLevel.Trace, Message = "{Orchestration} completed agent '{Agent}' ('{Id}')")] + private static partial void LogOrchestrationSubagentCompleted(ILogger logger, string orchestration, string id, string agent); + + private protected void LogOrchestrationSubagentRunning(OrchestratingAgentContext context, AIAgent agent) => + LogOrchestrationSubagentRunning(context.Logger, context.ToString(), context.Id, agent.DisplayName); + + private protected void LogOrchestrationSubagentCompleted(OrchestratingAgentContext context, AIAgent agent) => + LogOrchestrationSubagentCompleted(context.Logger, context.ToString(), context.Id, agent.DisplayName); + + private static async Task LogCompletionAsync(ILogger logger, OrchestratingAgentContext context, Task completion) + { + try + { + AgentRunResponse result = await completion.ConfigureAwait(false); + + if (logger.IsEnabled(LogLevel.Trace)) + { + JsonSerializerOptions jso = context.OrchestratingAgent?.SerializerOptions ?? AgentAbstractionsJsonUtilities.DefaultOptions; + LogOrchestrationResult(logger, context.ToString(), context.Id, JsonSerializer.Serialize(result, jso.GetTypeInfo(typeof(AgentRunResponse)))); + } + } + catch (Exception ex) + { + LogOrchestrationFailure(logger, context.ToString(), context.Id, ex); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/OrchestratingAgentContext.cs b/dotnet/src/Microsoft.Agents.Orchestration/OrchestratingAgentContext.cs new file mode 100644 index 0000000000..3e3a42b2d9 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/OrchestratingAgentContext.cs @@ -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; + +/// +/// Provides contextual information for an orchestration operation, including logging, and response callback. +/// +public sealed class OrchestratingAgentContext +{ + private ILogger? _logger; + private string? _id; + + /// Gets the orchestrating agent associated with this operation. + public OrchestratingAgent? OrchestratingAgent { get; set; } + + /// Gets the associated agent runtime, if one is being used. + public IActorRuntimeContext? Runtime { get; set; } + + /// Gets the options associated with the orchestration run. + public AgentRunOptions? Options { get; set; } + + /// Gets or sets the last version number provided by the runtime for checkpoint state. + public string? ETag { get; set; } + + /// Gets or sets an ID to use for the orchestration operation. + public string Id + { + get + { + this._id ??= this.Runtime?.ActorId.ToString() ?? Guid.NewGuid().ToString("N"); + return this._id; + } + } + + /// + /// Gets the associated logger for this operation. + /// + public ILogger Logger + { + get => this._logger ?? NullLogger.Instance; + set => this._logger = value; + } + + /// + public override string ToString() => + this.OrchestratingAgent?.DisplayName ?? + nameof(OrchestratingAgentContext); +} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/OrchestratingAgentResponse.cs b/dotnet/src/Microsoft.Agents.Orchestration/OrchestratingAgentResponse.cs new file mode 100644 index 0000000000..bf8ba48b9f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/OrchestratingAgentResponse.cs @@ -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; + +/// +/// Represents the result of an orchestrating agent. +/// This class encapsulates the asynchronous completion of an orchestration process. +/// +public sealed partial class OrchestratingAgentResponse : IAsyncDisposable +{ + private readonly CancellationTokenSource _cancelSource; + private readonly ILogger _logger; + + internal OrchestratingAgentResponse( + OrchestratingAgentContext context, + Task completion, + CancellationTokenSource orchestrationCancelSource, + ILogger logger) + { + this.Context = context; + this._cancelSource = orchestrationCancelSource; + this.Task = completion; + this._logger = logger; + } + + /// Gets the associated with this response. + public OrchestratingAgentContext Context { get; } + + /// + /// Releases all resources used by the instance. + /// + public ValueTask DisposeAsync() + { + this._cancelSource.Dispose(); + return default; + } + + /// + /// Gets a task that represents the completion of the orchestration result. + /// + public Task Task { get; } + + /// + /// Requests cancellation of the orchestration associated with this result. + /// + /// Thrown if this instance has been disposed. + public void Cancel() + { + OrchestratingAgent.LogOrchestrationCancellationRequested(this._logger, this.Context.ToString(), this.Context.Id); + this._cancelSource.Cancel(); + } + + /// Enable directly awaiting an by using 's awaiter. + [EditorBrowsable(EditorBrowsableState.Never)] + public TaskAwaiter GetAwaiter() => this.Task.GetAwaiter(); +} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/OrchestrationActor.cs b/dotnet/src/Microsoft.Agents.Orchestration/OrchestrationActor.cs deleted file mode 100644 index 651cd10016..0000000000 --- a/dotnet/src/Microsoft.Agents.Orchestration/OrchestrationActor.cs +++ /dev/null @@ -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; - -/// -/// Base abstractions for any actor that participates in an orchestration. -/// -public abstract class OrchestrationActor : RuntimeActor -{ - /// - /// Initializes a new instance of the class. - /// - protected OrchestrationActor(ActorId id, IAgentRuntime runtime, OrchestrationContext context, string? description = null, ILogger? logger = null) - : base(id, runtime, description, logger) - { - this.Context = context; - } - - /// - /// The orchestration context. - /// - protected OrchestrationContext Context { get; } - - /// - /// Sends a message to a specified recipient agent-type through the runtime. - /// - /// The message object to send. - /// The recipient agent's type. - /// A token used to cancel the operation if needed. - /// The agent identifier, if it exists. - protected ValueTask PublishMessageAsync( - object message, - ActorType agentType, - CancellationToken cancellationToken = default) => - base.PublishMessageAsync(message, new TopicId(agentType.Name), messageId: null, cancellationToken); -} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/OrchestrationContext.cs b/dotnet/src/Microsoft.Agents.Orchestration/OrchestrationContext.cs deleted file mode 100644 index 84e4e318a5..0000000000 --- a/dotnet/src/Microsoft.Agents.Orchestration/OrchestrationContext.cs +++ /dev/null @@ -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; - -/// -/// Provides contextual information for an orchestration operation, including topic, cancellation, logging, and response callback. -/// -public sealed class OrchestrationContext -{ - internal OrchestrationContext( - string orchestration, - TopicId topic, - Func, ValueTask>? responseCallback, - Func? streamingCallback, - ILoggerFactory loggerFactory, - CancellationToken cancellationToken) - { - this.Orchestration = orchestration; - this.Topic = topic; - this.ResponseCallback = responseCallback; - this.StreamingResponseCallback = streamingCallback; - this.LoggerFactory = loggerFactory; - this.CancellationToken = cancellationToken; - } - - /// - /// Gets the name or identifier of the orchestration. - /// - public string Orchestration { get; } - - /// - /// Gets the identifier associated with orchestration topic. - /// - /// - /// All orchestration actors are subscribed to this topic. - /// - public TopicId Topic { get; } - - /// - /// Gets the cancellation token that can be used to observe cancellation requests for the orchestration. - /// - public CancellationToken CancellationToken { get; } - - /// - /// Gets the associated logger factory for creating loggers within the orchestration context. - /// - public ILoggerFactory LoggerFactory { get; } - - /// - /// Optional callback that is invoked for every agent response. - /// - public Func, ValueTask>? ResponseCallback { get; } - - /// - /// Optional callback that is invoked for every agent response. - /// - public Func? StreamingResponseCallback { get; } -} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/OrchestrationJsonContext.cs b/dotnet/src/Microsoft.Agents.Orchestration/OrchestrationJsonContext.cs new file mode 100644 index 0000000000..1ef15b385a --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/OrchestrationJsonContext.cs @@ -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; diff --git a/dotnet/src/Microsoft.Agents.Orchestration/OrchestrationResult.cs b/dotnet/src/Microsoft.Agents.Orchestration/OrchestrationResult.cs deleted file mode 100644 index c898a0c9c2..0000000000 --- a/dotnet/src/Microsoft.Agents.Orchestration/OrchestrationResult.cs +++ /dev/null @@ -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; - -/// -/// Represents the result of an orchestration operation that yields a value of type . -/// This class encapsulates the asynchronous completion of an orchestration process. -/// -/// The type of the value produced by the orchestration. -public sealed partial class OrchestrationResult : IAsyncDisposable -{ - private readonly OrchestrationContext _context; - private readonly CancellationTokenSource _cancelSource; - private readonly TaskCompletionSource _completion; - private readonly ILogger _logger; - private readonly IAsyncDisposable? _additionalDisposable; - private bool _isDisposed; - - internal OrchestrationResult(OrchestrationContext context, TaskCompletionSource completion, CancellationTokenSource orchestrationCancelSource, ILogger logger, IAsyncDisposable? additionalDisposable = null) - { - this._cancelSource = orchestrationCancelSource; - this._context = context; - this._completion = completion; - this._logger = logger; - this._additionalDisposable = additionalDisposable; - } - - /// - /// Releases all resources used by the instance. - /// - public async ValueTask DisposeAsync() - { - if (!this._isDisposed) - { - this._isDisposed = true; - - this._cancelSource.Dispose(); - - if (this._additionalDisposable is { } ad) - { - await ad.DisposeAsync().ConfigureAwait(false); - } - } - } - - /// - /// Gets the orchestration name associated with this orchestration result. - /// - public string Orchestration => this._context.Orchestration; - - /// - /// Gets the topic identifier associated with this orchestration result. - /// - public TopicId Topic => this._context.Topic; - - /// - /// Gets a task that represents the completion of the orchestration result. - /// - public Task Task => this._completion.Task; - - /// - /// Cancel the orchestration associated with this result. - /// - /// Thrown if this instance has been disposed. - /// - /// Cancellation is not expected to immediately halt the orchestration. Messages that - /// are already in-flight may still be processed. - /// - 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(); - } - - /// Enable directly awaiting an by using 's awaiter. - [EditorBrowsable(EditorBrowsableState.Never)] - public TaskAwaiter GetAwaiter() => this.Task.GetAwaiter(); - - /// - /// Logs canceled the orchestration. - /// - [LoggerMessage( - Level = LogLevel.Error, - Message = "CANCELED {Orchestration}: {Topic}")] - private partial void LogOrchestrationResultCanceled( - string orchestration, - TopicId topic); -} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Sequential/SequentialActor.cs b/dotnet/src/Microsoft.Agents.Orchestration/Sequential/SequentialActor.cs deleted file mode 100644 index f4b3f17ef3..0000000000 --- a/dotnet/src/Microsoft.Agents.Orchestration/Sequential/SequentialActor.cs +++ /dev/null @@ -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; - -/// -/// An actor used with the . -/// -internal sealed class SequentialActor : AgentActor -{ - private readonly ActorType _nextAgent; - - /// - /// Initializes a new instance of the class. - /// - /// The unique identifier of the agent. - /// The runtime associated with the agent. - /// The orchestration context. - /// An . - /// The identifier of the next agent for which to handoff the result - /// The logger to use for the actor - public SequentialActor(ActorId id, IAgentRuntime runtime, OrchestrationContext context, AIAgent agent, ActorType nextAgent, ILogger? logger = null) - : base(id, runtime, context, agent, logger) - { - logger?.LogInformation("ACTOR {ActorId} {NextAgent}", this.Id, nextAgent); - this._nextAgent = nextAgent; - - this.RegisterMessageHandler(this.HandleAsync); - this.RegisterMessageHandler(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 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); - } -} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Sequential/SequentialMessages.cs b/dotnet/src/Microsoft.Agents.Orchestration/Sequential/SequentialMessages.cs deleted file mode 100644 index 632e511222..0000000000 --- a/dotnet/src/Microsoft.Agents.Orchestration/Sequential/SequentialMessages.cs +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Generic; -using Microsoft.Extensions.AI; - -namespace Microsoft.Agents.Orchestration; - -/// -/// A message that describes the input task and captures results for a . -/// -internal static class SequentialMessages -{ - /// - /// Represents a request containing a sequence of chat messages to be processed by the sequential orchestration. - /// - public sealed record Request(IList Messages); - - /// - /// Represents a response containing the result message from the sequential orchestration. - /// - public sealed record Response(ChatMessage Message); -} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Sequential/SequentialOrchestration.String.cs b/dotnet/src/Microsoft.Agents.Orchestration/Sequential/SequentialOrchestration.String.cs deleted file mode 100644 index b935f93d6d..0000000000 --- a/dotnet/src/Microsoft.Agents.Orchestration/Sequential/SequentialOrchestration.String.cs +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Extensions.AI.Agents; - -namespace Microsoft.Agents.Orchestration; - -/// -/// An orchestration that passes the input message to the first agent, and -/// then the subsequent result to the next agent, etc... -/// -public sealed class SequentialOrchestration : SequentialOrchestration -{ - /// - /// Initializes a new instance of the class. - /// - /// The agents to be orchestrated. - public SequentialOrchestration(params AIAgent[] members) - : base(members) - { - } -} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Sequential/SequentialOrchestration.cs b/dotnet/src/Microsoft.Agents.Orchestration/Sequential/SequentialOrchestration.cs deleted file mode 100644 index 6a2deb8884..0000000000 --- a/dotnet/src/Microsoft.Agents.Orchestration/Sequential/SequentialOrchestration.cs +++ /dev/null @@ -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; - -/// -/// An orchestration that provides the input message to the first agent -/// and sequentially passes each agent result to the next agent. -/// -public class SequentialOrchestration : AgentOrchestration -{ - /// - /// Initializes a new instance of the class. - /// - /// The agents participating in the orchestration. - public SequentialOrchestration(params AIAgent[] agents) - : base(agents) - { - } - - /// - protected override async ValueTask StartAsync(IAgentRuntime runtime, TopicId topic, IEnumerable 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); - } - - /// - protected override async ValueTask RegisterOrchestrationAsync(IAgentRuntime runtime, OrchestrationContext context, RegistrationContext registrar, ILogger logger) - { - ActorType outputType = await registrar.RegisterResultTypeAsync(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 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()); - return new ValueTask(actor); - }); - } - - private ActorType GetAgentType(TopicId topic, int index) => this.FormatAgentType(topic, $"Agent_{index + 1}"); -} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Sequential/SequentialOrchestrationLogMessages.cs b/dotnet/src/Microsoft.Agents.Orchestration/Sequential/SequentialOrchestrationLogMessages.cs deleted file mode 100644 index 6d647a7519..0000000000 --- a/dotnet/src/Microsoft.Agents.Orchestration/Sequential/SequentialOrchestrationLogMessages.cs +++ /dev/null @@ -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; - -/// -/// Extensions for logging . -/// -/// -/// This extension uses the to -/// generate logging code at compile time to achieve optimized code. -/// -[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); -} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/SequentialOrchestration.cs b/dotnet/src/Microsoft.Agents.Orchestration/SequentialOrchestration.cs new file mode 100644 index 0000000000..7ec5b9f828 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/SequentialOrchestration.cs @@ -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; + +/// Provides an orchestration that passes messages sequentially through a series of agents. +public sealed partial class SequentialOrchestration : OrchestratingAgent +{ + /// Initializes a new instance of the class. + /// The agents participating in the orchestration. + public SequentialOrchestration(params AIAgent[] agents) : base(agents) + { + } + + /// + protected override Task RunCoreAsync(IReadOnlyCollection messages, OrchestratingAgentContext context, CancellationToken cancellationToken) => + this.ResumeAsync(0, messages, context, cancellationToken); + + /// + protected override Task 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); + } + + /// + private async Task ResumeAsync(int i, IReadOnlyCollection 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 ?? [.. 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 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 Messages); +} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Transforms/StructuredOutputTransform.cs b/dotnet/src/Microsoft.Agents.Orchestration/Transforms/StructuredOutputTransform.cs deleted file mode 100644 index 3a1af8bd0c..0000000000 --- a/dotnet/src/Microsoft.Agents.Orchestration/Transforms/StructuredOutputTransform.cs +++ /dev/null @@ -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; - -/// -/// Populates the target result type into a structured output. -/// -/// The .NET type of the structured-output to deserialization target. -public sealed class StructuredOutputTransform -{ - 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; - - /// - /// Initializes a new instance of the class. - /// - /// The chat completion service to use for generating responses. - /// The prompt execution settings to use for the chat completion service. - public StructuredOutputTransform(IChatClient client, ChatOptions? chatOptions = null) - { - Throw.IfNull(client, nameof(client)); - - this._client = client; - this._options = chatOptions; - } - - /// - /// Gets or sets the instructions to be used as the system message for the chat completion. - /// - public string Instructions { get; init; } = DefaultInstructions; - - /// - /// Transforms the provided into a strongly-typed structured output by invoking the chat completion service and deserializing the response. - /// - /// The chat messages to process. - /// The JSON serializer options to use when performing any JSON serialization. - /// A cancellation token to observe while waiting for the task to complete. - /// The structured output of type . - /// Thrown if the response cannot be deserialized into . - public async ValueTask TransformAsync(IList messages, JsonSerializerOptions? serializerOptions = null, CancellationToken cancellationToken = default) - { - Throw.IfNull(messages); - - ChatResponse response = await this._client.GetResponseAsync( - [ - new ChatMessage(ChatRole.System, this.Instructions), - .. messages, - ], - serializerOptions ?? AgentAbstractionsJsonUtilities.DefaultOptions, - this._options, - cancellationToken: cancellationToken).ConfigureAwait(false); - - return response.Result; - } -} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/AIAgent.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/AIAgent.cs index 334e5b5b30..d0cd678957 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/AIAgent.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/AIAgent.cs @@ -30,10 +30,7 @@ public abstract class AIAgent /// /// Gets a display name for the agent, which is either the or if the name is not set. /// - public virtual string DisplayName - { - get => this.Name ?? this.Id; - } + public virtual string DisplayName => this.Name ?? this.Id; /// /// Gets the description of the agent (optional). diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/AgentAbstractionsJsonUtilities.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/AgentAbstractionsJsonUtilities.cs index 524b0b96a5..65830d4b09 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/AgentAbstractionsJsonUtilities.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/AgentAbstractionsJsonUtilities.cs @@ -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] diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorJsonContext.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorJsonContext.cs index df87360eb4..2652143485 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorJsonContext.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorJsonContext.cs @@ -6,36 +6,36 @@ using System.Text.Json.Serialization; namespace Microsoft.Extensions.AI.Agents.Runtime; /// -/// Source-generated JSON type information for use by all Actor abstractions. +/// Source-generated JSON type information for use by all agent runtime abstractions. /// [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; diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorMetadata.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorMetadata.cs deleted file mode 100644 index 04ba72acb7..0000000000 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorMetadata.cs +++ /dev/null @@ -1,67 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; - -namespace Microsoft.Extensions.AI.Agents.Runtime; - -/// -/// Represents metadata associated with an actor, including its type, unique key, and description. -/// -public readonly struct ActorMetadata : IEquatable -{ - /// - /// Initializes a new instance of the class with the specified type, key, and description. - /// - /// The type of the actor. - /// The unique key associated with the actor. - /// A brief description of the actor. - 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; - } - - /// - /// Gets an identifier that associates an actor with a specific factory function. - /// - public ActorType Type { get; } - - /// - /// A unique key identifying the actor instance. - /// Strings may only be composed of alphanumeric letters (a-z, 0-9), or underscores (_). - /// - public string Key { get; } - - /// - /// A brief description of the actor's purpose or functionality. - /// - public string? Description { get; } - - /// - public override readonly bool Equals(object? obj) => - obj is ActorMetadata actorMetadata && this.Equals(actorMetadata); - - /// - public readonly bool Equals(ActorMetadata other) => - this.Type == other.Type && - this.Key == other.Key && - this.Description == other.Description; - - /// - public override readonly int GetHashCode() => - HashCode.Combine(this.Type, this.Key, this.Description); - - /// - public static bool operator ==(ActorMetadata left, ActorMetadata right) => - left.Equals(right); - - /// - public static bool operator !=(ActorMetadata left, ActorMetadata right) => - !(left == right); -} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorReadOperationBatch.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorReadOperationBatch.cs index 6a44e35095..1956a63450 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorReadOperationBatch.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorReadOperationBatch.cs @@ -9,7 +9,7 @@ namespace Microsoft.Extensions.AI.Agents.Runtime; /// Represents a batch of read operations to be performed on an actor. /// /// The collection of read operations to perform. -public class ActorReadOperationBatch(IReadOnlyList operations) +public sealed class ActorReadOperationBatch(IReadOnlyList operations) { /// /// Gets the collection of read operations to perform. diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorRequest.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorRequest.cs index fa019e1cdd..d53819a83f 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorRequest.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorRequest.cs @@ -8,7 +8,7 @@ namespace Microsoft.Extensions.AI.Agents.Runtime; /// /// Represents a request to be sent to an actor. /// -public class ActorRequest(ActorId actorId, string messageId, string method, JsonElement @params) +public sealed class ActorRequest(ActorId actorId, string messageId, string method, JsonElement @params) { /// /// Gets or sets the identifier of the target actor. diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorRequestUpdate.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorRequestUpdate.cs index 77e8c301c6..f56a7699b1 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorRequestUpdate.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorRequestUpdate.cs @@ -10,7 +10,7 @@ namespace Microsoft.Extensions.AI.Agents.Runtime; /// /// Represents an update to an actor request's status and data. /// -public class ActorRequestUpdate(RequestStatus status, JsonElement data) +public sealed class ActorRequestUpdate(RequestStatus status, JsonElement data) { /// /// Gets the updated status of the request. diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorResponse.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorResponse.cs index 7a97807faa..f11846c3f7 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorResponse.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorResponse.cs @@ -8,7 +8,7 @@ namespace Microsoft.Extensions.AI.Agents.Runtime; /// /// Represents a response handle for an actor request, providing access to the result and status updates. /// -public class ActorResponse +public sealed class ActorResponse { /// /// Gets the identifier of the actor that is processing the request. diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorWriteOperationBatch.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorWriteOperationBatch.cs index 7f56e958ad..5b681da6bc 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorWriteOperationBatch.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ActorWriteOperationBatch.cs @@ -10,7 +10,7 @@ namespace Microsoft.Extensions.AI.Agents.Runtime; /// /// The ETag for optimistic concurrency control. /// The collection of write operations to perform. -public class ActorWriteOperationBatch(string eTag, IReadOnlyCollection operations) +public sealed class ActorWriteOperationBatch(string eTag, IReadOnlyCollection operations) { /// /// Gets the collection of write operations to perform. diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/AgentRuntimeExtensions.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/AgentRuntimeExtensions.cs deleted file mode 100644 index a517da7881..0000000000 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/AgentRuntimeExtensions.cs +++ /dev/null @@ -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; - -/// -/// Provides extension methods for the agent runtime. -/// -public static class AgentRuntimeExtensions -{ - /// - /// Retrieves an actor by its type. - /// - /// The agent runtime. - /// The type of the actor. - /// An optional key to specify variations of the actor. Defaults to "default". - /// If true, the actor is fetched lazily. - /// A token to cancel the operation if needed. - /// A task representing the asynchronous operation, returning the actor's ID. - public static ValueTask 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); - } - - /// - /// Retrieves an actor by its string representation. - /// - /// The agent runtime. - /// The string representation of the actor. - /// An optional key to specify variations of the actor. Defaults to "default". - /// If true, the actor is fetched lazily. - /// A token to cancel the operation if needed. - /// A task representing the asynchronous operation, returning the actor's ID. - public static ValueTask 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); - } - - /// - /// Registers an actor factory with the runtime, associating it with a specific actor type. - /// - /// The type of actor created by the factory. - /// The agent runtime. - /// The actor type to associate with the factory. - /// A function that asynchronously creates the actor instance. - /// A token to cancel the operation if needed. - /// A task representing the asynchronous operation, returning the registered actor type. - public static ValueTask RegisterActorFactoryAsync( - this IAgentRuntime agentRuntime, - ActorType type, - Func> factoryFunc, - CancellationToken cancellationToken = default) - where TActor : IRuntimeActor - { - Throw.IfNull(agentRuntime); - Throw.IfNull(factoryFunc); - - return agentRuntime.RegisterActorFactoryAsync( - type, - async ValueTask (actorId, runtime) => await factoryFunc(actorId, runtime).ConfigureAwait(false), - cancellationToken); - } -} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/GetValueOperation.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/GetValueOperation.cs index 66b4f09e64..4131f4936d 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/GetValueOperation.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/GetValueOperation.cs @@ -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. /// /// The key corresponding to the value to read from the actor's state. -public class GetValueOperation(string key) : ActorStateReadOperation +public sealed class GetValueOperation(string key) : ActorStateReadOperation { /// /// Gets the key corresponding to the value to read from the actor's state. diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/GetValueResult.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/GetValueResult.cs index 024a45f441..82bfeb685e 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/GetValueResult.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/GetValueResult.cs @@ -9,7 +9,7 @@ namespace Microsoft.Extensions.AI.Agents.Runtime; /// Represents the result of a get value operation containing the retrieved value. /// /// The value retrieved from the actor's state, or null if not found. -public class GetValueResult(JsonElement? value) : ActorReadResult +public sealed class GetValueResult(JsonElement? value) : ActorReadResult { /// /// Gets the value retrieved from the actor's state. diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/IAgentRuntime.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/IAgentRuntime.cs deleted file mode 100644 index feb9261c37..0000000000 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/IAgentRuntime.cs +++ /dev/null @@ -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; - -/// -/// Defines the runtime environment for actors, managing message sending, subscriptions, actor resolution, and state persistence, -/// all in support of agent-based architectures. -/// -public interface IAgentRuntime : ISaveState -{ - /// - /// Sends a message to an actor and gets a response. - /// This method should be used to communicate directly with an actor. - /// - /// The message to send. - /// The actor to send the message to. - /// The actor sending the message. Should be null if sent from an external source. - /// A unique identifier for the message. If null, a new ID will be generated. - /// A token to cancel the operation if needed. - /// A task representing the asynchronous operation, returning the response from the actor. - ValueTask SendMessageAsync(object message, ActorId recipient, ActorId? sender = null, string? messageId = null, CancellationToken cancellationToken = default); - - /// - /// Publishes a message to all agents subscribed to the given topic. - /// No responses are expected from publishing. - /// - /// The message to publish. - /// The topic to publish the message to. - /// The actor sending the message. Defaults to null. - /// A unique message ID. If null, a new one will be generated. - /// A token to cancel the operation if needed. - /// A task representing the asynchronous operation. - ValueTask PublishMessageAsync(object message, TopicId topic, ActorId? sender = null, string? messageId = null, CancellationToken cancellationToken = default); - - /// - /// Retrieves an actor by its unique identifier. - /// - /// The unique identifier of the actor. - /// If true, the actor is fetched lazily. - /// A token to cancel the operation if needed. - /// A task representing the asynchronous operation, returning the actor's ID. - ValueTask GetActorAsync(ActorId actorId, bool lazy = true, CancellationToken cancellationToken = default); - - /// - /// Saves the state of an actor. - /// The result must be JSON serializable. - /// - /// The ID of the actor whose state is being saved. - /// A token to cancel the operation if needed. - /// A task representing the asynchronous operation, returning a dictionary of the saved state. - ValueTask SaveActorStateAsync(ActorId actorId, CancellationToken cancellationToken = default); - - /// - /// Loads the saved state into an actor. - /// - /// The ID of the actor whose state is being restored. - /// The state dictionary to restore. - /// A token to cancel the operation if needed. - /// A task representing the asynchronous operation. - ValueTask LoadActorStateAsync(ActorId actorId, JsonElement state, CancellationToken cancellationToken = default); - - /// - /// Retrieves metadata for an actor. - /// - /// The ID of the actor. - /// A token to cancel the operation if needed. - /// A task representing the asynchronous operation, returning the actor's metadata. - ValueTask GetActorMetadataAsync(ActorId actorId, CancellationToken cancellationToken = default); - - /// - /// Adds a new subscription for the runtime to handle when processing published messages. - /// - /// The subscription to add. - /// A token to cancel the operation if needed. - /// A task representing the asynchronous operation. - ValueTask AddSubscriptionAsync(ISubscriptionDefinition subscription, CancellationToken cancellationToken = default); - - /// - /// Removes a subscription from the runtime. - /// - /// The unique identifier of the subscription to remove. - /// A token to cancel the operation if needed. - /// A task representing the asynchronous operation. - /// Thrown if the subscription does not exist. - ValueTask RemoveSubscriptionAsync(string subscriptionId, CancellationToken cancellationToken = default); - - /// - /// Registers an actor factory with the runtime, associating it with a specific actor type. - /// The type must be unique. - /// - /// The actor type to associate with the factory. - /// A function that asynchronously creates the actor instance. - /// A token to cancel the operation if needed. - /// A task representing the asynchronous operation, returning the registered . - ValueTask RegisterActorFactoryAsync(ActorType type, Func> factoryFunc, CancellationToken cancellationToken = default); - - /// - /// Attempts to retrieve an for the specified actor. - /// - /// The ID of the actor. - /// A token to cancel the operation if needed. - /// A task representing the asynchronous operation, returning an if successful. - ValueTask TryGetActorProxyAsync(ActorId actorId, CancellationToken cancellationToken = default); -} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/IRuntimeActor.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/IRuntimeActor.cs deleted file mode 100644 index 6bba64fa4b..0000000000 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/IRuntimeActor.cs +++ /dev/null @@ -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; - -/// -/// Represents an actor within the runtime that can process messages, maintain state, and be closed when no longer needed. -/// -public interface IRuntimeActor : ISaveState -{ - /// - /// Gets the unique identifier of the actor. - /// - ActorId Id { get; } - - /// - /// Gets metadata associated with the actor. - /// - ActorMetadata Metadata { get; } - - /// - /// Handles an incoming message for the actor. - /// This should only be called by the runtime, not by other actors. - /// - /// The received message. The type should match one of the expected subscription types. - /// The context of the message, providing additional metadata. - /// A token to cancel the operation if needed. - /// - /// A task representing the asynchronous operation, returning a response to the message. - /// The response can be null if no reply is necessary. - /// - /// Thrown if the message was canceled. - ValueTask OnMessageAsync(object message, MessageContext messageContext, CancellationToken cancellationToken = default); -} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ISaveState.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ISaveState.cs deleted file mode 100644 index d9776db5f4..0000000000 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ISaveState.cs +++ /dev/null @@ -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. - -/// -/// Defines a contract for saving and loading the state of an object as JSON. -/// -public interface ISaveState -{ - /// - /// Saves the current state of the object. - /// - /// A token to cancel the operation if needed. - /// - /// 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. - /// - ValueTask SaveStateAsync(CancellationToken cancellationToken = default); - - /// - /// Loads a previously saved state into the object. - /// - /// - /// A dictionary representing the saved state. The structure of the state - /// is implementation-defined but must be JSON serializable. - /// - /// A token to cancel the operation if needed. - /// A task representing the asynchronous operation. - ValueTask LoadStateAsync(JsonElement state, CancellationToken cancellationToken = default); -} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ISubscriptionDefinition.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ISubscriptionDefinition.cs deleted file mode 100644 index 32844f597a..0000000000 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ISubscriptionDefinition.cs +++ /dev/null @@ -1,51 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Diagnostics.CodeAnalysis; - -namespace Microsoft.Extensions.AI.Agents.Runtime; - -/// -/// Defines a subscription that matches topics and maps them to actors. -/// -public interface ISubscriptionDefinition -{ - /// - /// Gets the unique identifier of the subscription. - /// - string Id { get; } - - /// - /// Determines whether the specified object is equal to the current subscription. - /// - /// The object to compare with the current instance. - /// true if the specified object is equal to this instance; otherwise, false. - bool Equals([NotNullWhen(true)] object? obj); - - /// - /// Determines whether the specified subscription is equal to the current subscription. - /// - /// The subscription to compare. - /// true if the subscriptions are equal; otherwise, false. - bool Equals(ISubscriptionDefinition? other); - - /// - /// Returns a hash code for this subscription. - /// - /// A hash code for the subscription. - int GetHashCode(); - - /// - /// Checks if a given matches the subscription. - /// - /// The topic to check. - /// true if the topic matches the subscription; otherwise, false. - bool Matches(TopicId topic); - - /// - /// Maps a to an . - /// Should only be called if returns true. - /// - /// The topic to map. - /// The that should handle the topic. - ActorId MapToActor(TopicId topic); -} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/IdProxyActor.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/IdProxyActor.cs deleted file mode 100644 index 6cafeacb9d..0000000000 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/IdProxyActor.cs +++ /dev/null @@ -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; - -/// -/// Provides an actor proxy that allows you to use an in place of its associated . -/// -public sealed class IdProxyActor : IRuntimeActor -{ - /// The runtime instance used to interact with actors. - private readonly IAgentRuntime _runtime; - /// The metadata for the actor, lazy-loaded. - private ActorMetadata? _metadata; - - /// - /// Initializes a new instance of the class. - /// - public IdProxyActor(IAgentRuntime runtime, ActorId actorId) - { - Throw.IfNull(runtime); - - this.Id = actorId; - this._runtime = runtime; - } - - /// - public ActorId Id { get; } - - /// - 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 - - /// - public ValueTask SendMessageAsync(object message, ActorId sender, string? messageId = null, CancellationToken cancellationToken = default) => - this._runtime.SendMessageAsync(message, this.Id, sender, messageId, cancellationToken); - - /// - public ValueTask LoadStateAsync(JsonElement state, CancellationToken cancellationToken = default) => - this._runtime.LoadActorStateAsync(this.Id, state, cancellationToken); - - /// - public ValueTask SaveStateAsync(CancellationToken cancellationToken = default) => - this._runtime.SaveActorStateAsync(this.Id, cancellationToken); - - /// - ValueTask IRuntimeActor.OnMessageAsync(object message, MessageContext messageContext, CancellationToken cancellationToken) => - new((object?)null); -} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/InProcessRuntime.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/InProcessRuntime.cs deleted file mode 100644 index 6eb8b1de44..0000000000 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/InProcessRuntime.cs +++ /dev/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; - -/// Provides an in-process/in-memory implementation of the agent runtime. -public sealed partial class InProcessRuntime : IAgentRuntime, IAsyncDisposable -{ - private static readonly UnboundedChannelOptions s_singleReaderOptions = new(); - - private readonly Dictionary>> _actorFactories = []; - private readonly Dictionary _subscriptions = []; - private readonly Channel _messages = Channel.CreateUnbounded(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 _actorInstances = []; - - /// Initializes a new instance of the in-memory runtime. - public InProcessRuntime() { } - - /// Gets the number of pending work items. - /// Internal for testing purposes. - internal int MessageCountForTesting => this._remainingWork - (1 - this._signaledCompletion); - - /// Creates and starts a new instance. - /// The started runtime. - public static InProcessRuntime StartNew() - { - InProcessRuntime runtime = new(); - runtime.Start(); - return runtime; - } - - /// Starts the runtime. - /// Thrown if the runtime is already started. - 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)); - } - - /// - 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); - } - } - - /// - 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); - } - - /// - public ValueTask 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); - } - - /// - public async ValueTask GetActorAsync(ActorId actorId, bool lazy = true, CancellationToken cancellationToken = default) - { - if (!lazy) - { - await this.EnsureActorAsync(actorId, cancellationToken).ConfigureAwait(false); - } - - return actorId; - } - - /// - public async ValueTask GetActorMetadataAsync(ActorId actorId, CancellationToken cancellationToken = default) - { - IRuntimeActor actor = await this.EnsureActorAsync(actorId, cancellationToken).ConfigureAwait(false); - return actor.Metadata; - } - - /// - public async ValueTask TryGetUnderlyingActorInstanceAsync(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; - } - - /// - 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); - } - - /// - public async ValueTask SaveActorStateAsync(ActorId actorId, CancellationToken cancellationToken = default) - { - IRuntimeActor actor = await this.EnsureActorAsync(actorId, cancellationToken).ConfigureAwait(false); - return await actor.SaveStateAsync(cancellationToken).ConfigureAwait(false); - } - - /// - 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; - } - - /// - 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; - } - - /// - 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); - } - } - } - - /// - public async ValueTask SaveStateAsync(CancellationToken cancellationToken = default) - { - Dictionary state = []; - foreach (KeyValuePair actor in this._actorInstances) - { - state[actor.Key.ToString()] = await actor.Value.SaveStateAsync(cancellationToken).ConfigureAwait(false); - } - - return JsonSerializer.SerializeToElement(state, InProcessRuntimeContext.Default.DictionaryStringJsonElement); - } - - /// - public async ValueTask RegisterActorFactoryAsync(ActorType type, Func> factoryFunc, CancellationToken cancellationToken = default) - { - Throw.IfNull(factoryFunc); - ThrowIfInvalid(this._actorFactories.ContainsKey(type), "Actor type already registered."); - - this._actorFactories.Add(type, factoryFunc); - - return type; - } - - /// - public async ValueTask TryGetActorProxyAsync(ActorId actorId, CancellationToken cancellationToken = default) => - new(this, actorId); - - private async Task RunAsync(CancellationToken cancellationToken) - { - try - { - Dictionary 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> s_publishServicer = - async (MessageToProcess message, CancellationToken cancellationToken) => - { - Debug.Assert(message.Topic.HasValue); - - List? tasks = null; - TopicId topic = message.Topic!.Value; - foreach (KeyValuePair 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> 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 EnsureActorAsync(ActorId actorId, CancellationToken cancellationToken) - { - if (!this._actorInstances.TryGetValue(actorId, out IRuntimeActor? actor)) - { - this._actorFactories.TryGetValue(actorId.Type, out Func>? 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))] - 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> 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 ResultTcs { get; } = new(); - private Func> 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); - } - } - } -} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/KeyValueParserExtensions.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/KeyValueParser.cs similarity index 100% rename from dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/KeyValueParserExtensions.cs rename to dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/KeyValueParser.cs diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ListKeysOperation.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ListKeysOperation.cs index 981a66501d..91e5dbefda 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ListKeysOperation.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ListKeysOperation.cs @@ -9,7 +9,7 @@ namespace Microsoft.Extensions.AI.Agents.Runtime; /// /// Optional token for pagination to continue listing from a previous operation. /// Optional prefix to filter keys. Only keys starting with this prefix will be returned. -public class ListKeysOperation(string? continuationToken, string? keyPrefix = null) : ActorStateReadOperation +public sealed class ListKeysOperation(string? continuationToken, string? keyPrefix = null) : ActorStateReadOperation { /// /// Gets the continuation token for pagination. diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ListKeysResult.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ListKeysResult.cs index a177aeec59..cc9d1cd057 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ListKeysResult.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ListKeysResult.cs @@ -10,7 +10,7 @@ namespace Microsoft.Extensions.AI.Agents.Runtime; /// /// The collection of keys found in the actor's state. /// Optional token for pagination to retrieve additional keys. -public class ListKeysResult(IReadOnlyCollection keys, string? continuationToken) : ActorReadResult +public sealed class ListKeysResult(IReadOnlyCollection keys, string? continuationToken) : ActorReadResult { /// /// Gets the collection of keys found in the actor's state. diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/MessageContext.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/MessageContext.cs deleted file mode 100644 index ea92307f6d..0000000000 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/MessageContext.cs +++ /dev/null @@ -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; - -/// -/// Represents the context of a message being sent within the agent runtime. -/// -/// -/// This includes metadata such as the sender, topic, ahd RPC status. -/// -public sealed class MessageContext -{ - private string? _messageId; - - /// - /// Gets or sets the unique identifier for this message. - /// - public string MessageId - { - get => this._messageId ?? Interlocked.CompareExchange(ref this._messageId, Guid.NewGuid().ToString(), null) ?? this._messageId; - set => this._messageId = Throw.IfNullOrEmpty(value); - } - - /// - /// Gets or sets the sender of the message. - /// If null, the sender is unspecified. - /// - public ActorId? Sender { get; set; } - - /// - /// Gets or sets the topic associated with the message. - /// If null, the message is not tied to a specific topic. - /// - public TopicId? Topic { get; set; } - - /// - /// Gets or sets a value indicating whether this message is part of an RPC (Remote Procedure Call). - /// - public bool IsRpc { get; set; } - - /// Gets or sets the serializer options to be used when performing JSON serialization associated with this message. - public JsonSerializerOptions? SerializerOptions { get; set; } -} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ReadResponse.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ReadResponse.cs index 6d7c6d74e1..6d0c41cf5b 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ReadResponse.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/ReadResponse.cs @@ -10,7 +10,7 @@ namespace Microsoft.Extensions.AI.Agents.Runtime; /// /// The actor's last-known ETag value. /// The ordered collection of results. -public class ReadResponse(string eTag, IReadOnlyList results) +public sealed class ReadResponse(string eTag, IReadOnlyList results) { /// /// Gets the version of the state update. diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/RuntimeActor.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/RuntimeActor.cs deleted file mode 100644 index 7191cd1dd1..0000000000 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/RuntimeActor.cs +++ /dev/null @@ -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; - -/// -/// Provides a base implementation of . -/// -public abstract class RuntimeActor : IRuntimeActor -{ - private static readonly JsonElement s_emptyElement = JsonDocument.Parse("{}").RootElement; - - /// - /// The activity source for tracing. - /// - public static readonly ActivitySource TraceSource = new($"{typeof(IRuntimeActor).Namespace}"); - - private readonly Dictionary _handlerInvokers = []; - private readonly IAgentRuntime _runtime; - - private delegate ValueTask HandlerInvoker(object? message, MessageContext messageContext, CancellationToken cancellationToken); - - /// - /// Provides logging capabilities used for diagnostic and operational information. - /// - protected internal ILogger Logger { get; } - - /// - /// Gets the unique identifier of the actor. - /// - public ActorId Id { get; } - - /// - /// Gets the metadata of the actor. - /// - public ActorMetadata Metadata { get; } - - /// - /// Initializes a new instance of the RuntimeActor class with the specified identifier, runtime, description, and optional logger. - /// - /// The unique identifier of the actor. - /// The runtime environment in which the actor operates. - /// A brief description of the actor's purpose. - /// An optional logger for recording diagnostic information. - 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); - } - - /// Registers a handler for . - /// The type of the input message for the handler. - /// The handler function that processes the message. - /// Thrown when a handler for the specified type is already registered. - /// - /// The base implementation of will use these registered handlers to process incoming messages. - /// - protected void RegisterMessageHandler(Action messageHandler) - { - _ = Throw.IfNull(messageHandler); - - this.RegisterMessageHandler(async (input, ctx, cancellationToken) => - { - messageHandler(input, ctx); - return null; - }); - } - - /// Registers a handler for . - /// The type of the input message for the handler. - /// The handler function that processes the message. - /// Thrown when a handler for the specified type is already registered. - /// - /// The base implementation of will use these registered handlers to process incoming messages. - /// - protected void RegisterMessageHandler(Func messageHandler) - { - _ = Throw.IfNull(messageHandler); - - this.RegisterMessageHandler(async (input, ctx, cancellationToken) => - { - await messageHandler(input, ctx, cancellationToken).ConfigureAwait(false); - return null; - }); - } - - /// Registers a handler for . - /// The type of the input message for the handler. - /// The type of the output message for the handler. - /// The handler function that processes the message. - /// Thrown when a handler for the specified type is already registered. - /// - /// The base implementation of will use these registered handlers to process incoming messages. - /// - protected void RegisterMessageHandler(Func messageHandler) - { - _ = Throw.IfNull(messageHandler); - - this.RegisterMessageHandler(async (input, ctx, cancellationToken) => messageHandler(input, ctx)); - } - - /// Registers a handler for that produces a . - /// The type of the input message for the handler. - /// The type of the output message for the handler. - /// The handler function that processes the message. - /// Thrown when a handler for the specified type is already registered. - /// - /// The base implementation of will use these registered handlers to process incoming messages. - /// - protected void RegisterMessageHandler(Func> 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)); - } - - /// - /// Handles an incoming message by determining its type and invoking the corresponding handler method if available. - /// - /// The message object to be handled. - /// The context associated with the message. - /// A token used to cancel the operation if needed. - /// A ValueTask that represents the asynchronous operation, containing the response object or null. - public ValueTask 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); - } - - /// - public virtual ValueTask SaveStateAsync(CancellationToken cancellationToken = default) => - new(s_emptyElement); - - /// - public virtual ValueTask LoadStateAsync(JsonElement state, CancellationToken cancellationToken = default) => - default; - - /// - /// Sends a message to a specified recipient actor through the runtime. - /// - /// The requested actor's type. - /// A token used to cancel the operation if needed. - /// A ValueTask that represents the asynchronous operation, returning the response object or null. - protected async ValueTask GetActorAsync(ActorType actor, CancellationToken cancellationToken = default) - { - try - { - return await this._runtime.GetActorAsync(actor, lazy: false, cancellationToken: cancellationToken).ConfigureAwait(false); - } - catch (InvalidOperationException) - { - return null; - } - } - - /// - /// Sends a message to a specified recipient actor through the runtime. - /// - /// The message object to send. - /// The recipient actor's identifier. - /// An optional identifier for the message. - /// A token used to cancel the operation if needed. - /// A ValueTask that represents the asynchronous operation, returning the response object or null. - protected ValueTask SendMessageAsync(object message, ActorId recipient, string? messageId = null, CancellationToken cancellationToken = default) => - this._runtime.SendMessageAsync(message, recipient, sender: this.Id, messageId, cancellationToken); - - /// - /// Publishes a message to all actors subscribed to a specific topic through the runtime. - /// - /// The message object to publish. - /// The topic identifier to which the message is published. - /// An optional identifier for the message. - /// A token used to cancel the operation if needed. - /// A ValueTask that represents the asynchronous publish operation. - protected ValueTask PublishMessageAsync(object message, TopicId topic, string? messageId = null, CancellationToken cancellationToken = default) => - this._runtime.PublishMessageAsync(message, topic, sender: this.Id, messageId, cancellationToken); -} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/TopicId.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/TopicId.cs deleted file mode 100644 index 1de3456956..0000000000 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/TopicId.cs +++ /dev/null @@ -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; - -/// -/// Provides a topic identifier that defines the scope of a broadcast message. -/// -/// -/// The agent runtime implements a publish-subscribe model through its broadcast API, -/// where messages must be published with a specific topic. -/// -public readonly partial struct TopicId : IEquatable -{ - 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 - - /// - /// Initializes a new instance of the struct. - /// - /// The type of the topic. Must match the pattern: ^[\w-.:=]+$ - /// The source of the event. - 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"; - } - - /// - /// Gets the type of the event that this represents. - /// - /// - /// This adheres to the CloudEvents specification. - /// CloudEvents Type. - /// - public string Type { get; } - - /// - /// Gets the source that identifies the context in which an event happened. - /// - /// - /// This adheres to the CloudEvents specification. - /// CloudEvents Source. - /// - public string Source { get; } - - /// - /// Convert a string of the format "type/key" into an . - /// - /// The actor ID string. - /// An instance of . - 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); - } - - /// - public override readonly string ToString() => $"{this.Type}/{this.Source}"; - - /// - public override readonly bool Equals([NotNullWhen(true)] object? obj) => - obj is TopicId other && this.Equals(other); - - /// - public readonly bool Equals(TopicId other) => - this.Type == other.Type && this.Source == other.Source; - - /// - public override readonly int GetHashCode() => - HashCode.Combine(this.Type, this.Source); - - /// - public static bool operator ==(TopicId left, TopicId right) => - left.Equals(right); - - /// - 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; - //} -} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/TypeSubscription.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/TypeSubscription.cs deleted file mode 100644 index d1274ddb72..0000000000 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/TypeSubscription.cs +++ /dev/null @@ -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; - -/// -/// 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. -/// -/// -/// Example: -/// -/// var subscription = new TypeSubscription("t1", "a1"); -/// -/// In this case: -/// - A with type `"t1"` and source `"s1"` will be handled by an actor of type `"a1"` with key `"s1"`. -/// - A with type `"t1"` and source `"s2"` will be handled by an actor of type `"a1"` with key `"s2"`. -/// -public sealed class TypeSubscription : ISubscriptionDefinition -{ - /// - /// Initializes a new instance of the class. - /// - /// The exact topic type to match against. - /// Actor type to handle this subscription. - /// Unique identifier for the subscription. If not provided, a new UUID will be generated. - public TypeSubscription(string topicType, ActorType actorType, string? id = null) - { - Throw.IfNullOrEmpty(topicType); - - this.TopicType = topicType; - this.ActorType = actorType; - this.Id = id ?? Guid.NewGuid().ToString(); - } - - /// - /// Gets the unique identifier of the subscription. - /// - public string Id { get; } - - /// - /// Gets the exact topic type used for matching. - /// - public string TopicType { get; } - - /// - /// Gets the actor type that handles this subscription. - /// - public ActorType ActorType { get; } - - /// - /// Checks if a given matches the subscription based on an exact type match. - /// - /// The topic to check. - /// true if the topic's type matches exactly, false otherwise. - public bool Matches(TopicId topic) => topic.Type == this.TopicType; - - /// - /// Maps a to an . Should only be called if returns true. - /// - /// The topic to map. - /// An representing the actor that should handle the topic. - /// Thrown if the topic does not match the subscription. - 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); - } - - /// - /// Determines whether the specified object is equal to the current subscription. - /// - /// The object to compare with the current instance. - /// true if the specified object is equal to this instance; otherwise, false. - public override bool Equals([NotNullWhen(true)] object? obj) => - obj is TypeSubscription other && - (this.Id == other.Id || (this.ActorType == other.ActorType && this.TopicType == other.TopicType)); - - /// - /// Determines whether the specified subscription is equal to the current subscription. - /// - /// The subscription to compare. - /// true if the subscriptions are equal; otherwise, false. - public bool Equals(ISubscriptionDefinition? other) => this.Id == other?.Id; - - /// - /// Returns a hash code for this instance. - /// - /// A hash code for this instance, suitable for use in hashing algorithms and data structures. - public override int GetHashCode() => HashCode.Combine(this.Id, this.ActorType, this.TopicType); -} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/WriteResponse.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/WriteResponse.cs index 072ae1ed1f..c321b2f196 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/WriteResponse.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/WriteResponse.cs @@ -9,7 +9,7 @@ namespace Microsoft.Extensions.AI.Agents.Runtime; /// /// The actor's updated ETag value after the write operation. /// Whether the write operation was successful. -public class WriteResponse(string eTag, bool success) +public sealed class WriteResponse(string eTag, bool success) { /// /// Gets the version of the state update. diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/ActorRuntimeJsonContext.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/ActorRuntimeJsonContext.cs index 833d6324e5..865bea794e 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/ActorRuntimeJsonContext.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Runtime/ActorRuntimeJsonContext.cs @@ -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; diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents/ChatCompletion/ChatClientAgentRunOptions.cs b/dotnet/src/Microsoft.Extensions.AI.Agents/ChatCompletion/ChatClientAgentRunOptions.cs index f3043a35f8..46ecbe3a5a 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents/ChatCompletion/ChatClientAgentRunOptions.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents/ChatCompletion/ChatClientAgentRunOptions.cs @@ -5,14 +5,23 @@ namespace Microsoft.Extensions.AI.Agents; /// /// Chat client agent run options. /// -internal sealed class ChatClientAgentRunOptions : AgentRunOptions +public sealed class ChatClientAgentRunOptions : AgentRunOptions { + /// + /// Initializes a new instance of the class. + /// + /// Optional chat options to pass to the agent's invocation. + public ChatClientAgentRunOptions(ChatOptions? chatOptions = null) : + this(null, chatOptions) + { + } + /// /// Initializes a new instance of the class. /// /// Optional source to clone. /// Optional chat options to pass to the agent's invocation. - 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 /// /// Gets or sets optional chat options to pass to the agent's invocation /// - internal ChatOptions? ChatOptions { get; } + public ChatOptions? ChatOptions { get; set; } } diff --git a/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/ConcurrentOrchestrationTests.cs b/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/ConcurrentOrchestrationTests.cs index 888bfbda78..481a623d7f 100644 --- a/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/ConcurrentOrchestrationTests.cs +++ b/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/ConcurrentOrchestrationTests.cs @@ -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 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(); } } diff --git a/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/DefaultTransformsTests.cs b/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/DefaultTransformsTests.cs deleted file mode 100644 index 7abeaf22a6..0000000000 --- a/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/DefaultTransformsTests.cs +++ /dev/null @@ -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 input = - [ - new(ChatRole.User, "Hello"), - new(ChatRole.Assistant, "Hi there") - ]; - - // Act - IEnumerable result = await DefaultTransforms.FromInput(input); - - // Assert - Assert.Equal(input, result); - } - - [Fact] - public async Task FromInputAsyncWithChatMessageReturnsInputAsListAsync() - { - // Arrange - ChatMessage input = new(ChatRole.User, "Hello"); - - // Act - IEnumerable 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 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 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 input = - [ - new(ChatRole.User, "Hello"), - new(ChatRole.Assistant, "Hi there") - ]; - - // Act - IList result = await DefaultTransforms.ToOutput>(input); - - // Assert - Assert.Same(input, result); - } - - [Fact] - public async Task ToOutputAsyncWithOutputTypeChatMessageReturnsSingleMessageAsync() - { - // Arrange - IList input = - [ - new(ChatRole.User, "Hello") - ]; - - // Act - ChatMessage result = await DefaultTransforms.ToOutput(input); - - // Assert - Assert.Same(input[0], result); - } - - [Fact] - public async Task ToOutputAsyncWithOutputTypeStringReturnsContentOfSingleMessageAsync() - { - // Arrange - string expected = "Hello, world!"; - IList input = - [ - new(ChatRole.User, expected) - ]; - - // Act - string result = await DefaultTransforms.ToOutput(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 input = - [ - new(ChatRole.User, json) - ]; - - // Act - TestObject result = await DefaultTransforms.ToOutput(input); - - // Assert - Assert.Equal(expected.Id, result.Id); - Assert.Equal(expected.Name, result.Name); - } - - [Fact] - public async Task ToOutputAsyncWithInvalidJsonThrowsExceptionAsync() - { - // Arrange - IList input = - [ - new(ChatRole.User, "Not valid JSON") - ]; - - // Act & Assert - await Assert.ThrowsAsync(async () => - await DefaultTransforms.ToOutput(input) - ); - } - - [Fact] - public async Task ToOutputAsyncWithMultipleMessagesAndNonMatchingTypeThrowsExceptionAsync() - { - // Arrange - IList input = - [ - new(ChatRole.User, "Hello"), - new(ChatRole.Assistant, "Hi there") - ]; - - // Act & Assert - await Assert.ThrowsAsync(async () => - await DefaultTransforms.ToOutput(input) - ); - } - - [Fact] - public async Task ToOutputAsyncWithNullContentHandlesGracefullyAsync() - { - // Arrange - IList input = - [ - new(ChatRole.User, (string?)null) - ]; - - // Act - string result = await DefaultTransforms.ToOutput(input); - - // Assert - Assert.Equal(string.Empty, result); - } - - private sealed class TestObject - { - public int Id { get; set; } - public string? Name { get; set; } - } -} diff --git a/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/GroupChatOrchestration2Tests.cs b/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/GroupChatOrchestration2Tests.cs new file mode 100644 index 0000000000..2fa90149e3 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/GroupChatOrchestration2Tests.cs @@ -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; + +/// +/// Tests for the class. +/// +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 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; + } +} diff --git a/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/GroupChatOrchestrationTests.cs b/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/GroupChatOrchestrationTests.cs index 4a3734e9d1..00a2c4f041 100644 --- a/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/GroupChatOrchestrationTests.cs +++ b/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/GroupChatOrchestrationTests.cs @@ -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 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; } } diff --git a/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/HandoffOrchestrationTests.cs b/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/HandoffOrchestrationTests.cs index 2e6735a890..6e055b27c6 100644 --- a/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/HandoffOrchestrationTests.cs +++ b/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/HandoffOrchestrationTests.cs @@ -92,13 +92,13 @@ public sealed class HandoffOrchestrationTests : IDisposable // Act const string InitialInput = "123"; - OrchestrationResult 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) diff --git a/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/OrchestrationResultTests.cs b/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/OrchestrationResultTests.cs index 94dda8f6e5..42b1fc55cb 100644 --- a/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/OrchestrationResultTests.cs +++ b/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/OrchestrationResultTests.cs @@ -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 tcs = new(); + OrchestratingAgentContext context = new() + { + OrchestratingAgent = new MockOrchestratingAgent(), + }; + TaskCompletionSource tcs = new(); // Act using CancellationTokenSource cancelSource = new(); - await using OrchestrationResult 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 tcs = new(); + OrchestratingAgentContext context = new() + { + OrchestratingAgent = new MockOrchestratingAgent(), + }; + TaskCompletionSource tcs = new(); using CancellationTokenSource cancelSource = new(); - await using OrchestrationResult 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 tcs = new(); + OrchestratingAgentContext context = new() + { + OrchestratingAgent = new MockOrchestratingAgent(), + }; + + TaskCompletionSource tcs = new(); using CancellationTokenSource cancelSource = new(); - await using OrchestrationResult 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 RunCoreAsync(IReadOnlyCollection messages, OrchestratingAgentContext context, CancellationToken cancellationToken) => + throw new NotSupportedException(); + + protected override Task 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 RunAsync(IReadOnlyCollection messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + public override IAsyncEnumerable RunStreamingAsync(IReadOnlyCollection messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) => + throw new NotSupportedException(); } } diff --git a/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/SequentialOrchestrationTests.cs b/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/SequentialOrchestrationTests.cs index b5488a9596..99461340e5 100644 --- a/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/SequentialOrchestrationTests.cs +++ b/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/SequentialOrchestrationTests.cs @@ -48,12 +48,12 @@ public class SequentialOrchestrationTests SequentialOrchestration orchestration = new(mockAgents); const string InitialInput = "123"; - OrchestrationResult result = await orchestration.InvokeAsync(InitialInput); + AgentRunResponse result = await orchestration.RunAsync(InitialInput); // Assert Assert.NotNull(result); // Act - return await result.Task; + return result.Text; } } diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.Runtime.Abstractions.UnitTests/AgentMetaDataTests.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.Runtime.Abstractions.UnitTests/AgentMetaDataTests.cs deleted file mode 100644 index c64c1f3a02..0000000000 --- a/dotnet/tests/Microsoft.Extensions.AI.Agents.Runtime.Abstractions.UnitTests/AgentMetaDataTests.cs +++ /dev/null @@ -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); - } -} diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.Runtime.Abstractions.UnitTests/AgentProxyTests.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.Runtime.Abstractions.UnitTests/AgentProxyTests.cs deleted file mode 100644 index 7e6f9bcd5d..0000000000 --- a/dotnet/tests/Microsoft.Extensions.AI.Agents.Runtime.Abstractions.UnitTests/AgentProxyTests.cs +++ /dev/null @@ -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 _mockRuntime; - private readonly ActorId _agentId; - private readonly IdProxyActor _agentProxy; - - public AgentProxyTests() - { - this._mockRuntime = new Mock(); - 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())) - .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); - } -} diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.Runtime.Abstractions.UnitTests/AgentTypeTests.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.Runtime.Abstractions.UnitTests/AgentTypeTests.cs deleted file mode 100644 index eedb8c9733..0000000000 --- a/dotnet/tests/Microsoft.Extensions.AI.Agents.Runtime.Abstractions.UnitTests/AgentTypeTests.cs +++ /dev/null @@ -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(() => 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()); - } -} diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.Runtime.Abstractions.UnitTests/InProcessRuntimeTests.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.Runtime.Abstractions.UnitTests/InProcessRuntimeTests.cs deleted file mode 100644 index 7f06f98208..0000000000 --- a/dotnet/tests/Microsoft.Extensions.AI.Agents.Runtime.Abstractions.UnitTests/InProcessRuntimeTests.cs +++ /dev/null @@ -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(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(async () => await runtime.RemoveSubscriptionAsync(subscription.Id)); - - // Arrange - await runtime.AddSubscriptionAsync(subscription); - - // Act & Assert - await Assert.ThrowsAsync(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 agents = []; - await using InProcessRuntime runtime = new(); - - // Act & Assert - await Assert.ThrowsAsync(async () => await runtime.GetActorAsync(AgentType, lazy: false)); - - // Arrange - await runtime.RegisterActorFactoryAsync(new(AgentType), factoryFunc); - - // Act & Assert - await Assert.ThrowsAsync(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(agentId); - - // Assert - Assert.Equal(agentId, agent.Id); - - // Act & Assert - await Assert.ThrowsAsync(async () => await runtime.TryGetUnderlyingActorInstanceAsync(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 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 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 deserializedState = JsonSerializer.Deserialize>(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 OnMessageAsync(object message, MessageContext messageContext, CancellationToken cancellationToken = default) - { - throw new NotImplementedException(); - } - - public ValueTask SaveStateAsync(CancellationToken cancellationToken = default) - { - throw new NotImplementedException(); - } - } -} diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.Runtime.Abstractions.UnitTests/MessageContextTests.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.Runtime.Abstractions.UnitTests/MessageContextTests.cs deleted file mode 100644 index 68a14b8569..0000000000 --- a/dotnet/tests/Microsoft.Extensions.AI.Agents.Runtime.Abstractions.UnitTests/MessageContextTests.cs +++ /dev/null @@ -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); - } -} diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.Runtime.Abstractions.UnitTests/MessagingTestFixture.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.Runtime.Abstractions.UnitTests/MessagingTestFixture.cs deleted file mode 100644 index 80eb7eb47e..0000000000 --- a/dotnet/tests/Microsoft.Extensions.AI.Agents.Runtime.Abstractions.UnitTests/MessagingTestFixture.cs +++ /dev/null @@ -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 targetTopics) : base(id, runtime, description) - { - this.RegisterMessageHandler(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 targetKeys) : base(id, runtime, description) - { - this.RegisterMessageHandler(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 Messages { get; } = []; - - public ReceiverAgent(ActorId id, IAgentRuntime runtime, string description) : base(id, runtime, description) - { - this.RegisterMessageHandler((item, messageContext) => this.Messages.Add(item)); - } -} - -public sealed class ProcessorAgent : TestAgent -{ - public ProcessorAgent(ActorId id, IAgentRuntime runtime, Func processFunc, string description) : base(id, runtime, description) - { - this.RegisterMessageHandler(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((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((item, messageContext) => - { - this.DidThrow = true; - throw new TestException(); - }); - } - public bool DidThrow { get; private set; } -} - -public sealed class MessagingTestFixture -{ - private Dictionary AgentsTypeMap { get; } = []; - public InProcessRuntime Runtime { get; } = new(); - - public ValueTask RegisterFactoryMapInstances(ActorType type, Func> factory) - where TAgent : IRuntimeActor - { - async ValueTask WrappedFactory(ActorId id, IAgentRuntime runtime) - { - TAgent agent = await factory(id, runtime); - this.GetAgentInstances()[id] = agent; - return agent; - } - - return this.Runtime.RegisterActorFactoryAsync(type, WrappedFactory); - } - - public Dictionary GetAgentInstances() where TAgent : IRuntimeActor - { - if (!this.AgentsTypeMap.TryGetValue(typeof(TAgent), out object? maybeAgentMap) || - maybeAgentMap is not Dictionary 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(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(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 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; - } -} diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.Runtime.Abstractions.UnitTests/PublishMessageTests.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.Runtime.Abstractions.UnitTests/PublishMessageTests.cs deleted file mode 100644 index 75a369de35..0000000000 --- a/dotnet/tests/Microsoft.Extensions.AI.Agents.Runtime.Abstractions.UnitTests/PublishMessageTests.cs +++ /dev/null @@ -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().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(async () => await fixture.RunPublishTestAsync(new TopicId("TestTopic"), new BasicMessage { Content = "1" })); - - var values = fixture.GetAgentInstances().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(async () => await fixture.RunPublishTestAsync(new TopicId("TestTopic"), new BasicMessage { Content = "1" })); - - var values = fixture.GetAgentInstances().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(async () => await fixture.RunPublishTestAsync(new TopicId("TestTopic"), new BasicMessage { Content = "1" })); - - var agents = fixture.GetAgentInstances().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().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(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().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"); - }); - } -} diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.Runtime.Abstractions.UnitTests/SendMessageTests.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.Runtime.Abstractions.UnitTests/SendMessageTests.cs deleted file mode 100644 index a6b93a9532..0000000000 --- a/dotnet/tests/Microsoft.Extensions.AI.Agents.Runtime.Abstractions.UnitTests/SendMessageTests.cs +++ /dev/null @@ -1,90 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Threading.Tasks; - -namespace Microsoft.Extensions.AI.Agents.Runtime.InProcess.Tests; - -public class SendMessageTests -{ - [Fact] - public async Task Test_SendMessage_ReturnsValueAsync() - { - static string ProcessFunc(string s) => $"Processed({s})"; - - MessagingTestFixture fixture = new(); - - await fixture.RegisterFactoryMapInstances(new(nameof(ProcessorAgent)), - (id, runtime) => new ValueTask(new ProcessorAgent(id, runtime, ProcessFunc, string.Empty))); - - ActorId targetAgent = new(nameof(ProcessorAgent), Guid.NewGuid().ToString()); - object? maybeResult = await fixture.RunSendTestAsync(targetAgent, new BasicMessage { Content = "1" }); - - Assert.Equal("Processed(1)", Assert.IsType(maybeResult).Content); - } - - [Fact] - public async Task Test_SendMessage_CancellationAsync() - { - MessagingTestFixture fixture = new(); - - await fixture.RegisterFactoryMapInstances(new(nameof(CancelAgent)), - (id, runtime) => new ValueTask(new CancelAgent(id, runtime, string.Empty))); - - ActorId targetAgent = new(nameof(CancelAgent), Guid.NewGuid().ToString()); - - await Assert.ThrowsAnyAsync(() => fixture.RunSendTestAsync(targetAgent, new BasicMessage { Content = "1" }).AsTask()); - } - - [Fact] - public async Task Test_SendMessage_ErrorAsync() - { - MessagingTestFixture fixture = new(); - - await fixture.RegisterFactoryMapInstances(new(nameof(ErrorAgent)), - (id, runtime) => new ValueTask(new ErrorAgent(id, runtime, string.Empty))); - - ActorId targetAgent = new(nameof(ErrorAgent), Guid.NewGuid().ToString()); - - await Assert.ThrowsAsync(() => fixture.RunSendTestAsync(targetAgent, new BasicMessage { Content = "1" }).AsTask()); - } - - [Fact] - public async Task Test_SendMessage_FromSendMessageHandlerAsync() - { - Guid[] targetGuids = [Guid.NewGuid(), Guid.NewGuid()]; - - MessagingTestFixture fixture = new(); - - Dictionary sendAgents = fixture.GetAgentInstances(); - Dictionary receiverAgents = fixture.GetAgentInstances(); - - await fixture.RegisterFactoryMapInstances(new(nameof(SendOnAgent)), - (id, runtime) => new ValueTask(new SendOnAgent(id, runtime, string.Empty, targetGuids))); - - await fixture.RegisterFactoryMapInstances(new(nameof(ReceiverAgent)), - (id, runtime) => new ValueTask(new ReceiverAgent(id, runtime, string.Empty))); - - ActorId targetAgent = new(nameof(SendOnAgent), Guid.NewGuid().ToString()); - BasicMessage input = new() { Content = "Hello" }; - Task testTask = fixture.RunSendTestAsync(targetAgent, input).AsTask(); - - // We do not actually expect to wait the timeout here, but it is still better than waiting the 10 min - // timeout that the tests default to. A failure will fail regardless of what timeout value we set. - TimeSpan timeout = Debugger.IsAttached ? TimeSpan.FromSeconds(120) : TimeSpan.FromSeconds(10); - Task timeoutTask = Task.Delay(timeout); - - Task completedTask = await Task.WhenAny([testTask, timeoutTask]); - Assert.Same(testTask, completedTask); - - // Check that each of the target agents received the message - foreach (Guid targetKey in targetGuids) - { - ActorId targetId = new(nameof(ReceiverAgent), targetKey.ToString()); - Assert.Single(receiverAgents[targetId].Messages); - Assert.Contains(receiverAgents[targetId].Messages, m => m.Content == $"@{targetKey}: {input.Content}"); - } - } -} diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.Runtime.Abstractions.UnitTests/TestAgents.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.Runtime.Abstractions.UnitTests/TestAgents.cs deleted file mode 100644 index fabb13704c..0000000000 --- a/dotnet/tests/Microsoft.Extensions.AI.Agents.Runtime.Abstractions.UnitTests/TestAgents.cs +++ /dev/null @@ -1,48 +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.InProcess.Tests; - -public abstract class TestAgent : RuntimeActor -{ - internal List ReceivedMessages = []; - - protected TestAgent(ActorId id, IAgentRuntime runtime, string description) - : base(id, runtime, description) - { - } -} - -/// -/// A test agent that captures the messages it receives and -/// is able to save and load its state. -/// -public sealed class MockAgent : TestAgent -{ - public MockAgent(ActorId id, IAgentRuntime runtime, string description) : base(id, runtime, description) - { - this.RegisterMessageHandler(this.Handle); - } - - public void Handle(string item, MessageContext messageContext) - { - this.ReceivedMessages.Add(item); - } - - public override async ValueTask SaveStateAsync(CancellationToken cancellationToken = default) - { - JsonElement json = JsonSerializer.SerializeToElement(this.ReceivedMessages); - return json; - } - - public override ValueTask LoadStateAsync(JsonElement state, CancellationToken cancellationToken = default) - { - this.ReceivedMessages = state.Deserialize>() ?? throw new InvalidOperationException("Failed to deserialize state"); - return default; - } -} diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.Runtime.Abstractions.UnitTests/TestSubscription.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.Runtime.Abstractions.UnitTests/TestSubscription.cs deleted file mode 100644 index 4445d114bd..0000000000 --- a/dotnet/tests/Microsoft.Extensions.AI.Agents.Runtime.Abstractions.UnitTests/TestSubscription.cs +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; - -namespace Microsoft.Extensions.AI.Agents.Runtime.InProcess.Tests; - -public class TestSubscription(string topicType, ActorType agentType, string? id = null) : ISubscriptionDefinition -{ - public string Id { get; } = id ?? Guid.NewGuid().ToString(); - - public string TopicType { get; } = topicType; - - public ActorId MapToActor(TopicId topic) - { - if (!this.Matches(topic)) - { - throw new InvalidOperationException("TopicId does not match the subscription."); - } - - return new ActorId(agentType, topic.Source); - } - - public bool Equals(ISubscriptionDefinition? other) => this.Id == other?.Id; - - public override bool Equals(object? obj) => obj is TestSubscription other && other.Equals(this); - - public override int GetHashCode() => this.Id.GetHashCode(); - - public bool Matches(TopicId topic) - { - return topic.Type == this.TopicType; - } -} diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.Runtime.Abstractions.UnitTests/TopicIdTests.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.Runtime.Abstractions.UnitTests/TopicIdTests.cs deleted file mode 100644 index d94dfcc57a..0000000000 --- a/dotnet/tests/Microsoft.Extensions.AI.Agents.Runtime.Abstractions.UnitTests/TopicIdTests.cs +++ /dev/null @@ -1,129 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; - -namespace Microsoft.Extensions.AI.Agents.Runtime.Abstractions.Tests; - -public class TopicIdTests -{ - [Fact] - public void ConstrWithTypeOnlyTest() - { - // Arrange & Act - TopicId topicId = new("testtype"); - - // Assert - Assert.Equal("testtype", topicId.Type); - } - - [Fact] - public void ConstructWithTypeAndSourceTest() - { - // Arrange & Act - TopicId topicId = new("testtype", "customsource"); - - // Assert - Assert.Equal("testtype", topicId.Type); - Assert.Equal("customsource", topicId.Source); - } - - [Theory] - [InlineData("testtype/https://github.com/cloudevents", "testtype", "https://github.com/cloudevents")] - [InlineData("testtype/mailto:cncf-wg-serverless@lists.cncf.io", "testtype", "mailto:cncf-wg-serverless@lists.cncf.io")] - [InlineData("testtype/urn:uuid:6e8bc430-9c3a-11d9-9669-0800200c9a66", "testtype", "urn:uuid:6e8bc430-9c3a-11d9-9669-0800200c9a66")] - [InlineData("testtype//cloudevents/spec/pull/123", "testtype", "/cloudevents/spec/pull/123")] - [InlineData("testtype//sensors/tn-1234567/alerts", "testtype", "/sensors/tn-1234567/alerts")] - [InlineData("testtype/1-555-123-4567", "testtype", "1-555-123-4567")] - public void ParseTest(string input, string expectedType, string expectedSource) - { - TopicId topicId = TopicId.Parse(input); - - Assert.Equal(expectedType, topicId.Type); - Assert.Equal(expectedSource, topicId.Source); - } - - [Theory] - [InlineData("invalid-format")] - [InlineData("")] - public void InvalidFormatParseThrowsTest(string invalidInput) - { - // Act & Assert - Assert.Throws(() => TopicId.Parse(invalidInput)); - } - - [Fact] - public void ToStringTest() - { - // Arrange - TopicId topicId = new("testtype", "customsource"); - - // Act - string result = topicId.ToString(); - - // Assert - Assert.Equal("testtype/customsource", result); - } - - [Fact] - public void EqualityTest() - { - // Arrange - TopicId topicId1 = new("testtype", "customsource"); - TopicId topicId2 = new("testtype", "customsource"); - - // Act & Assert - Assert.True(topicId1.Equals(topicId2)); - Assert.True(topicId1.Equals((object)topicId2)); - } - - [Fact] - public void InequalityTest() - { - // Arrange - TopicId topicId1 = new("testtype1", "source1"); - TopicId topicId2 = new("testtype2", "source2"); - TopicId topicId3 = new("testtype1", "source2"); - TopicId topicId4 = new("testtype2", "source1"); - - // Act & Assert - Assert.False(topicId1.Equals(topicId2)); - Assert.False(topicId1.Equals(topicId3)); - Assert.False(topicId1.Equals(topicId4)); - } - - [Fact] - public void NullEqualityTest() - { - // Arrange - TopicId topicId = new("testtype", "customsource"); - - // Act & Assert - Assert.False(topicId.Equals(null)); - } - - [Fact] - public void DifferentTypeEqualityTest() - { - // Arrange - TopicId topicId = new("testtype", "customsource"); - const string DifferentType = "not-a-topic-id"; - - // Act & Assert - Assert.False(topicId.Equals(DifferentType)); - } - - [Fact] - public void GetHashCodeTest() - { - // Arrange - TopicId topicId1 = new("testtype", "customsource"); - TopicId topicId2 = new("testtype", "customsource"); - - // Act - int hash1 = topicId1.GetHashCode(); - int hash2 = topicId2.GetHashCode(); - - // Assert - Assert.Equal(hash1, hash2); - } -}