.NET: feat: Implement Magentic Orchestration for .NET (#5595)

* feat: Implement Magentic Orchestration for .NET

* fixup: Update for review comments

* fix: Fix FenceJsonRegexPattern

* fix: Format

* fix: Updates for PR feedback

* fix: Add missing serialized types to source gen for trimming

* fix: Address PR Comments
This commit is contained in:
Jacob Alber
2026-05-07 14:36:15 -04:00
committed by GitHub
Unverified
parent 2a9b68d1bd
commit ce70ca1a9f
21 changed files with 2038 additions and 25 deletions
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
using Microsoft.Agents.AI.Workflows.Specialized.Magentic;
namespace Microsoft.Agents.AI.Workflows;
@@ -10,6 +11,7 @@ namespace Microsoft.Agents.AI.Workflows;
[JsonDerivedType(typeof(ExecutorInvokedEvent))]
[JsonDerivedType(typeof(ExecutorCompletedEvent))]
[JsonDerivedType(typeof(ExecutorFailedEvent))]
[JsonDerivedType(typeof(MagenticOrchestratorEvent))]
public class ExecutorEvent(string executorId, object? data) : WorkflowEvent(data)
{
/// <summary>
@@ -0,0 +1,44 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Request for human review of a proposed plan.
/// </summary>
/// <param name="Plan">The proposed plan.</param>
/// <param name="CurrentProgress">The current progress ledger, if available. During the initial plan review,
/// this will be <see langword="null"/>. In subsequent reviews after replanning (due to stalls), this will
/// contain the latest progress ledger that determined that no progress has been made or the workflow was in
/// a loop.</param>
/// <param name="IsStalled">Whether the workflow is currently stalled.</param>
public record MagenticPlanReviewRequest(ChatMessage Plan, MagenticProgressLedger? CurrentProgress, bool IsStalled)
{
/// <summary>
/// Create an approving <see cref="MagenticPlanReviewResponse"/>.
/// </summary>
/// <returns></returns>
public MagenticPlanReviewResponse Approve() => new([]);
/// <summary>
/// Create a <see cref="MagenticPlanReviewResponse"/> with revisions.
/// </summary>
/// <returns></returns>
public MagenticPlanReviewResponse Revise(string message) => new([new(ChatRole.User, message)]);
/// <summary>
/// Create a <see cref="MagenticPlanReviewResponse"/> with revisions.
/// </summary>
/// <returns></returns>
public MagenticPlanReviewResponse Revise(ChatMessage message) => new([message]);
/// <summary>
/// Create a <see cref="MagenticPlanReviewResponse"/> with revisions.
/// </summary>
/// <returns></returns>
public MagenticPlanReviewResponse Revise(IEnumerable<ChatMessage> messages)
=> new(messages is List<ChatMessage> messageList ? messageList : messages.ToList());
}
@@ -0,0 +1,18 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Review feedback for a proposed plan, including any revisions if the plan is not approved as-is. An
/// empty list of review messages indicates approval of the proposed plan without any revisions.
/// </summary>
/// <param name="Review">
/// Review feedback for a generated plan. Empty if the plan is approved as-is and changes are requested.
/// </param>
public record MagenticPlanReviewResponse(List<ChatMessage> Review)
{
internal bool IsApproved => this.Review.Count == 0;
}
@@ -0,0 +1,269 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Maintains a ledger of progress made by the Magentic workflow.
/// </summary>
public class MagenticProgressLedger
{
internal static readonly BooleanProgressLedgerSlot IsRequestSatisfiedSlot = new("is_request_satisfied",
"Is the request fully satisfied? (True if complete, or False if the original request has yet to be SUCCESSFULLY and FULLY addressed)");
internal static readonly BooleanProgressLedgerSlot IsInLoopSlot = new("is_in_loop",
"Are we in a loop where we are repeating the same requests and or getting the same responses as before? " +
"Loops can span multiple turns, and can include repeated actions like scrolling up or down more than a handful of times.");
internal static readonly BooleanProgressLedgerSlot IsProgressBeingMadeSlot = new("is_progress_being_made",
"Are we making forward progress? (True if just starting, or recent messages are adding value. False if recent " +
"messages show evidence of being stuck in a loop or if there is evidence of significant barriers to success " +
"such as the inability to read from a required file)");
internal readonly StringProgressLedgerSlot NextSpeakerSlot;
internal static readonly StringProgressLedgerSlot InstructionOrQuestionSlot = new("instruction_or_question",
"What instruction or question would you give this team member? (Phrase as if speaking directly to them, and " +
"include any specific information they may need)");
internal MagenticProgressLedger(string teamNames, IEnumerable<ProgressLedgerSlot> additionalQuestions, JsonElement? state = null)
{
this.NextSpeakerSlot = new("next_speaker", $"Who should speak next? (select from: {teamNames})");
this.AdditionalQuestions = additionalQuestions as ProgressLedgerSlot[] ?? additionalQuestions.ToArray();
if (state != null)
{
this.TryUpdateState(state.Value);
}
}
internal ProgressLedgerSlot[] AdditionalQuestions { get; }
internal bool TryUpdateState(JsonElement element)
{
// In principle all of these should be inlineable, but the CodeAnalysis fails to properly chain through the and-chain to realize that
// all must be true for `requiredQuestionsAnswered` to be true, meaning all of the out parameters would be initialized properly.
bool isInLoop = false;
bool isProgressBeingMade = false;
string? nextSpeaker = string.Empty;
string? instructionOrQuestion = string.Empty;
bool requiredQuestionsAnswered =
IsRequestSatisfiedSlot.TryGetValueFrom(element, out bool isRequestSatisfied) &&
IsInLoopSlot.TryGetValueFrom(element, out isInLoop) &&
IsProgressBeingMadeSlot.TryGetValueFrom(element, out isProgressBeingMade) &&
this.NextSpeakerSlot.TryGetValueFrom(element, out nextSpeaker) &&
InstructionOrQuestionSlot.TryGetValueFrom(element, out instructionOrQuestion);
if (requiredQuestionsAnswered)
{
this.State = element;
this.IsRequestSatisfied = isRequestSatisfied;
this.IsInLoop = isInLoop;
this.IsProgressBeingMade = isProgressBeingMade;
this.NextSpeaker = nextSpeaker!;
this.InstructionOrQuestion = instructionOrQuestion!;
}
// TODO: To what extent do we want to enforce that the additional questions are also answered?
return requiredQuestionsAnswered;
}
[JsonInclude]
internal JsonElement? State;
/// <summary>
/// Specifies whether plan execution has started.
/// </summary>
[JsonIgnore]
public bool IsStarted => this.State != null;
/// <summary>
/// Specifies whether the task has been fully satisfied.
/// </summary>
[JsonIgnore]
public bool IsRequestSatisfied { get; private set; }
/// <summary>
/// Specifies whether the team is in a loop.
/// </summary>
[JsonIgnore]
public bool IsInLoop { get; private set; }
/// <summary>
/// Specifies whether the team is making progress on the task.
/// </summary>
[JsonIgnore]
public bool IsProgressBeingMade { get; private set; }
/// <summary>
/// Gets the next team member to take a turn.
/// </summary>
[JsonIgnore]
public string NextSpeaker { get; private set; } = string.Empty;
/// <summary>
/// Gets the instruction or question to send to the next team member.
/// </summary>
[JsonIgnore]
public string InstructionOrQuestion { get; private set; } = string.Empty;
[JsonIgnore]
internal IEnumerable<ProgressLedgerSlot> Slots =>
[
IsRequestSatisfiedSlot,
IsInLoopSlot,
IsProgressBeingMadeSlot,
this.NextSpeakerSlot,
InstructionOrQuestionSlot,
.. this.AdditionalQuestions
];
internal bool TryGetCurrentSlotValue<T>(ProgressLedgerSlot<T> slot, [NotNullWhen(true)] out T? value)
{
if (!this.State.HasValue)
{
value = default;
return false;
}
return slot.TryGetValueFrom(this.State.Value, out value);
}
private (string QuestionBlock, string AnswerSchema)? _questionFormatCache;
internal (string QuestionBlock, string AnswerSchema) FormatQuestions()
{
if (!this._questionFormatCache.HasValue)
{
StringBuilder questionBuilder = new(), schemaBuilder = new();
schemaBuilder.AppendLine("{");
foreach (ProgressLedgerSlot slot in this.Slots)
{
questionBuilder.AppendLine(slot.FormattedQuestion);
schemaBuilder.AppendLine($"\"{slot.Key}\": {{")
.AppendLine($" \"{ProgressLedgerSlot.ValueKey}\": {slot.SchemaType}{slot.SuffixString},")
.AppendLine($" \"{ProgressLedgerSlot.ReasonKey}\": string")
.AppendLine("}");
}
schemaBuilder.AppendLine("}");
this._questionFormatCache = (questionBuilder.ToString(), schemaBuilder.ToString());
}
return this._questionFormatCache.Value;
}
}
internal abstract record ProgressLedgerSlot(string Key, string Question, string? SchemaTypeSuffix = null)
{
public const string ValueKey = "answer";
public const string ReasonKey = "reason";
internal string SuffixString => this.SchemaTypeSuffix == null ? string.Empty : $"({this.SchemaTypeSuffix})";
protected internal abstract string SchemaType { get; }
public string FormattedQuestion
{
get
{
if (field == null)
{
IEnumerable<string> questionLines = this.Question.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)
.Select(line => line.TrimEnd());
field = $" - {string.Join("\n ", questionLines)}";
}
return field;
}
}
}
internal abstract record ProgressLedgerSlot<T>(string Key, string Question, string? SchemaTypeSuffix = null, JsonSerializerOptions? SerializerOptions = null)
: ProgressLedgerSlot(Key, Question, SchemaTypeSuffix)
{
protected internal virtual JsonTypeInfo<T> GetJsonTypeInfo() =>
((this.SerializerOptions ?? WorkflowsJsonUtilities.DefaultOptions).TryGetTypeInfo(typeof(T), out JsonTypeInfo? typeInfo)
? typeInfo as JsonTypeInfo<T> : null)
?? throw new InvalidOperationException($"Cannot get TypeInfo for {typeof(T)} from {(this.SerializerOptions == null ? "provided" : "default")} SerializationOptions.");
public bool TryGetValueFrom(JsonElement answers, [NotNullWhen(true)] out T? value)
{
if (answers.TryGetProperty(this.Key, out JsonElement slotElement) &&
slotElement.ValueKind != JsonValueKind.Null &&
slotElement.TryGetProperty(ValueKey, out JsonElement answerValue))
{
try
{
T? result = answerValue.Deserialize(this.GetJsonTypeInfo());
if (result != null)
{
value = result;
return true;
}
}
catch
{
}
}
value = default;
return false;
}
public bool TryGetReasonFrom(JsonElement answers, [NotNullWhen(true)] out string? value)
{
if (answers.TryGetProperty(this.Key, out JsonElement slotElement) &&
slotElement.ValueKind != JsonValueKind.Null &&
slotElement.TryGetProperty(ReasonKey, out JsonElement reasonValue))
{
try
{
string? result = reasonValue.Deserialize(WorkflowsJsonUtilities.JsonContext.Default.String);
if (result != null)
{
value = result;
return true;
}
}
catch
{
}
}
value = default;
return false;
}
}
internal sealed record BooleanProgressLedgerSlot(string Key, string Question, string? SchemaTypeSuffix = null) : ProgressLedgerSlot<bool>(Key, Question, SchemaTypeSuffix)
{
// Since we know the type statically, we can directly return the JsonTypeInfo for string from our JsonContext,
// which is more efficient than looking it up via the options.
protected internal override JsonTypeInfo<bool> GetJsonTypeInfo() => WorkflowsJsonUtilities.JsonContext.Default.Boolean;
protected internal override string SchemaType => "boolean";
}
internal sealed record StringProgressLedgerSlot(string Key, string Question, string? SchemaTypeSuffix = null) : ProgressLedgerSlot<string>(Key, Question, SchemaTypeSuffix)
{
// Since we know the type statically, we can directly return the JsonTypeInfo for string from our JsonContext,
// which is more efficient than looking it up via the options.
protected internal override JsonTypeInfo<string> GetJsonTypeInfo() => WorkflowsJsonUtilities.JsonContext.Default.String;
protected internal override string SchemaType => "string";
}
@@ -0,0 +1,158 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Specialized.Magentic;
using ExecutorFactoryFunc = System.Func<Microsoft.Agents.AI.Workflows.ExecutorConfig<Microsoft.Agents.AI.Workflows.ExecutorOptions>,
string,
System.Threading.Tasks.ValueTask<Microsoft.Agents.AI.Workflows.Specialized.Magentic.MagenticOrchestrator>>;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Fluent builder for creating Magentic One multi-agent orchestration workflows.
///
/// Magentic One workflows use an LLM-powered manager to coordinate multiple agents through dynamic task planning, progress tracking,
/// and adaptive replanning.The manager creates plans, selects agents, monitors progress, and determines when to replan or complete.
///
/// The builder provides a fluent API for configuring participants, the manager, optional plan review, checkpointing, and event
/// callbacks.
///
/// Human-in-the-loop Support: Magentic provides specialized HITL mechanisms via:
/// - `RequirePlanSignoff` - Review and approve/revise plans before execution
/// - Tool approval via `function_approval_request`: Approve individual tool calls on participating agents. Note that tool calls are
/// not supported on the ManagerAgent.
/// </summary>
/// <param name="managerAgent"></param>
public class MagenticWorkflowBuilder(AIAgent managerAgent)
{
private readonly List<AIAgent> _team = new();
private string? _name;
private string? _description;
private int _maxStalls = TaskLimits.DefaultMaxStallCount;
private int? _maxRounds;
private int? _maxResets;
private bool _requirePlanSignoff = true;
/// <inheritdoc cref="GroupChatWorkflowBuilder.AddParticipants(IEnumerable{AIAgent})"/>
public MagenticWorkflowBuilder AddParticipants(params IEnumerable<AIAgent> agents)
{
this._team.AddRange(agents);
return this;
}
/// <inheritdoc cref="WorkflowBuilder.WithName(string)"/>
public MagenticWorkflowBuilder WithName(string name)
{
this._name = name;
return this;
}
/// <inheritdoc cref="WorkflowBuilder.WithDescription(string)"/>
public MagenticWorkflowBuilder WithDescription(string description)
{
this._description = description;
return this;
}
/// <summary>
/// Set the maximum number of coordination rounds. <see langword="null"/> means unlimited.
/// </summary>
/// <returns></returns>
public MagenticWorkflowBuilder WithMaxRounds(int? maxRounds = null)
{
this._maxRounds = maxRounds;
return this;
}
/// <summary>
/// Set the maximum number ofnumber of resets allowed. <see langword="null"/> means unlimited.
/// </summary>
/// <returns></returns>
public MagenticWorkflowBuilder WithMaxResets(int? maxResets = null)
{
this._maxResets = maxResets;
return this;
}
/// <summary>
/// Set the maximum number of consecutive rounds without progress before replan (default 3).
/// </summary>
/// <returns></returns>
public MagenticWorkflowBuilder WithMaxStalls(int maxStalls = TaskLimits.DefaultMaxStallCount)
{
this._maxStalls = maxStalls;
return this;
}
/// <summary>
/// If <see langword="true"/>, requires human approval of the initial plan or any updates before proceeding. True by default.
/// </summary>
/// <param name="requirePlanSignoff"></param>
/// <returns></returns>
public MagenticWorkflowBuilder RequirePlanSignoff(bool requirePlanSignoff = true)
{
this._requirePlanSignoff = requirePlanSignoff;
return this;
}
private WorkflowBuilder ReduceToWorkflowBuilder()
{
// Create a copy of the team so that improper modifications by using the builder after .Build() do not affect the
// workflow in unexpected ways.
List<AIAgent> team = [.. this._team];
ExecutorBinding orchestrator = CreateOrchestratorBinding(managerAgent, team, this.Limits, this._requirePlanSignoff);
WorkflowBuilder result = new(orchestrator);
AIAgentHostOptions options = new()
{
ReassignOtherAgentsAsUsers = true,
ForwardIncomingMessages = false
};
List<ExecutorBinding> teamBindings = [];
foreach (AIAgent agent in team)
{
ExecutorBinding binding = agent.BindAsExecutor(options);
teamBindings.Add(binding);
result.AddEdge(binding, orchestrator);
}
result.AddFanOutEdge(orchestrator, teamBindings)
.WithOutputFrom(orchestrator);
if (!string.IsNullOrWhiteSpace(this._name))
{
result.WithName(this._name);
}
if (!string.IsNullOrWhiteSpace(this._description))
{
result.WithDescription(this._description);
}
return result;
}
/// <inheritdoc cref="WorkflowBuilder.Build"/>
public Workflow Build() => this.ReduceToWorkflowBuilder().Build();
private TaskLimits Limits => new(
MaxRoundCount: this._maxRounds,
MaxResetCount: this._maxResets,
MaxStallCount: this._maxStalls);
private static ExecutorBinding CreateOrchestratorBinding(AIAgent managerAgent, List<AIAgent> team, TaskLimits limits, bool requirePlanSignoff)
{
ExecutorFactoryFunc factory = CreateOrchestratorAsync;
return factory.BindExecutor(nameof(MagenticOrchestrator));
ValueTask<MagenticOrchestrator> CreateOrchestratorAsync(ExecutorConfig<ExecutorOptions> options, string sessionId)
{
return new(new MagenticOrchestrator(managerAgent, team, limits, requirePlanSignoff));
}
}
}
@@ -0,0 +1,9 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Notifies an AIAgent-hosting executor that it should reset its conversation state, and start a new session, if appropriate.
/// Note that for Agent Orchestrations, only Magentic makes use of this functionality.
/// </summary>
public sealed record ResetChatSignal();
@@ -24,7 +24,7 @@ internal static class TurnExtensions
=> handoffState.TurnToken.ShouldEmitStreamingEvents(agentSetting);
}
internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
internal class AIAgentHostExecutor : ChatProtocolExecutor
{
private readonly AIAgent _agent;
private readonly AIAgentHostOptions _options;
@@ -40,7 +40,9 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
StringMessageChatRole = ChatRole.User
};
public AIAgentHostExecutor(AIAgent agent, AIAgentHostOptions options) : base(id: agent.GetDescriptiveId(),
public static string IdFor(AIAgent agent) => agent.GetDescriptiveId();
public AIAgentHostExecutor(AIAgent agent, AIAgentHostOptions options) : base(id: IdFor(agent),
s_defaultChatProtocolOptions,
declareCrossRunShareable: false) // Explicitly false, because we maintain turn state on the instance
{
@@ -67,7 +69,14 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
return this.ConfigureUserInputHandling(base.ConfigureProtocol(protocolBuilder));
return this.ConfigureUserInputHandling(base.ConfigureProtocol(protocolBuilder))
.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler<ResetChatSignal>(this.ResetChat));
}
internal void ResetChat(ResetChatSignal signal, IWorkflowContext context)
{
this._session = null;
this._currentTurnEmitEvents = null;
}
private ValueTask HandleUserInputResponseAsync(
@@ -0,0 +1,175 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Specialized.Magentic;
internal static partial class ChatMessageExtensions
{
private static void ProcessAIContents(StringBuilder resultBuilder, IEnumerable<AIContent> contents, StreamingToolCallResultPairMatcher? pairMatcher = null)
{
pairMatcher ??= new();
foreach (AIContent content in contents)
{
switch (content)
{
case TextContent textContent:
resultBuilder.AppendLine(textContent.Text);
break;
//case DataContent dataContent:
// // We really do not know how to deal with anything other than image data with descriptions, which is not
// // a well-defined concept in MEAI (as contrasted with AutoGen's ImageContent type)
// break;
case ErrorContent errorContent:
resultBuilder.AppendLine($"[ERROR{(errorContent.ErrorCode != null ? $"(Code={errorContent.ErrorCode})" : string.Empty)}]");
resultBuilder.AppendLine(errorContent.Message);
if (errorContent.Details != null)
{
resultBuilder.Append("Details:").AppendLine(errorContent.Details);
}
break;
case FunctionCallContent functionCallContent:
pairMatcher.CollectFunctionCall(functionCallContent);
break;
case FunctionResultContent functionResultContent:
pairMatcher.TryResolveFunctionCall(functionResultContent, out string? functionName);
string result = functionResultContent.Result?.ToString() ?? string.Empty;
resultBuilder.AppendLine($"[Tool Call '{functionName ?? functionResultContent.CallId}' Result]")
.AppendLine(result);
break;
case McpServerToolCallContent mstContent:
pairMatcher.CollectMcpServerToolCall(mstContent);
break;
case McpServerToolResultContent mstResultContent:
if (mstResultContent.Outputs?.Any() is true)
{
pairMatcher.TryResolveMcpServerToolCall(mstResultContent, out string? mcpServerToolName);
resultBuilder.AppendLine($"[Start MCP Server Tool Call '{mcpServerToolName ?? mstResultContent.CallId}' Results]");
ProcessAIContents(resultBuilder, mstResultContent.Outputs!);
resultBuilder.AppendLine($"[End MCP Server Tool Call '{mcpServerToolName ?? mstResultContent.CallId}']");
}
break;
case TextReasoningContent reasoningContent:
if (!string.IsNullOrWhiteSpace(reasoningContent.Text))
{
resultBuilder.Append("[Reasoning] ")
.AppendLine(reasoningContent.Text);
}
break;
case UriContent uriContent:
resultBuilder.AppendLine(uriContent.Uri.ToString());
break;
}
}
}
public static string GetText(this List<ChatMessage> messages)
{
if (messages.Count == 0)
{
return string.Empty;
}
StringBuilder builder = new();
StreamingToolCallResultPairMatcher pairMatcher = new();
foreach (ChatMessage message in messages)
{
ProcessAIContents(builder, message.Contents, pairMatcher);
}
return builder.ToString();
}
private const string FencedJsonRegexPattern = @"```(?<lang>[a-z]+)?\s*(?<json>\{[\s\S]*?\})\s*```";
#if NET
[GeneratedRegex(FencedJsonRegexPattern, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture)]
public static partial Regex FencedJsonRegex();
#else
public static Regex FencedJsonRegex() => s_fencedJsonRegex;
private static readonly Regex s_fencedJsonRegex =
new(FencedJsonRegexPattern, RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture);
#endif
internal static JsonElement ExtractJson(string messageText)
{
Match match = FencedJsonRegex().Match(messageText);
if (match.Success)
{
return JsonElement.Parse(match.Groups["json"].Value);
}
int start = messageText.IndexOf('{'), scanHead = start;
int? end = null;
if (scanHead < 0)
{
throw new InvalidOperationException("No JSON object found.");
}
int depth = 0;
bool inQuotes = false, inEscape = false;
for (; scanHead < messageText.Length && end is null; scanHead++)
{
if (inEscape)
{
inEscape = false;
continue;
}
switch (messageText[scanHead])
{
case '{' when !inQuotes:
depth++;
break;
case '}' when !inQuotes:
depth--;
if (depth == 0)
{
end = scanHead;
}
break;
case '\"':
// We already handled inEscape, so we can always flip inQuotes here
inQuotes = !inQuotes;
break;
case '\\':
Debug.Assert(!inEscape);
inEscape = true;
break;
}
}
if (end is null)
{
throw new InvalidOperationException("Unbalanced JSON braces.");
}
return JsonElement.Parse(messageText.Substring(start, end.Value - start + 1));
}
public static JsonElement ExtractJson(this ChatMessage message) => ExtractJson(message.Text);
}
@@ -0,0 +1,72 @@
// 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;
namespace Microsoft.Agents.AI.Workflows.Specialized.Magentic;
internal sealed class ExecutorAgentHarness(AIAgent agent, AIAgentUnservicedRequestsCollector collector)
{
internal const string AgentSessionKey = nameof(AgentSession);
private AgentSession? _session;
private async ValueTask<AgentSession> EnsureSessionAsync(IWorkflowContext context, CancellationToken cancellationToken) =>
this._session ??= await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
public async ValueTask<AgentResponse> InvokeAgentAsync(IEnumerable<ChatMessage> messages, IWorkflowContext context, bool emitUpdateEvents, CancellationToken cancellationToken = default)
{
AgentResponse response;
if (emitUpdateEvents)
{
// Run the agent in streaming mode only when agent run update events are to be emitted.
IAsyncEnumerable<AgentResponseUpdate> agentStream = agent.RunStreamingAsync(
messages,
await this.EnsureSessionAsync(context, cancellationToken).ConfigureAwait(false),
cancellationToken: cancellationToken);
List<AgentResponseUpdate> updates = [];
await foreach (AgentResponseUpdate update in agentStream.ConfigureAwait(false))
{
await context.YieldOutputAsync(update, cancellationToken).ConfigureAwait(false);
collector.ProcessAgentResponseUpdate(update);
updates.Add(update);
}
response = updates.ToAgentResponse();
}
else
{
// Otherwise, run the agent in non-streaming mode.
response = await agent.RunAsync(messages,
await this.EnsureSessionAsync(context, cancellationToken).ConfigureAwait(false),
cancellationToken: cancellationToken)
.ConfigureAwait(false);
collector.ProcessAgentResponse(response);
}
return response;
}
public async ValueTask<JsonElement?> SerializeSessionAsync(CancellationToken cancellationToken)
=> this._session == null
? null
: await agent.SerializeSessionAsync(this._session, cancellationToken: cancellationToken).ConfigureAwait(false);
public async ValueTask DeserializeSessionAsync(JsonElement? serializedSession, CancellationToken cancellationToken)
{
this._session = serializedSession == null
? null
: await agent.DeserializeSessionAsync(serializedSession.Value, cancellationToken: cancellationToken)
.ConfigureAwait(false);
}
public void ResetSession()
{
this._session = null;
}
}
@@ -0,0 +1,8 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.Workflows.Specialized.Magentic;
internal static class MagenticConstants
{
public const string MagenticTaskContextKey = nameof(MagenticTaskContextKey);
}
@@ -0,0 +1,122 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Runtime.ExceptionServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Specialized.Magentic;
internal class MagenticManager(AIAgent managerAgent)
{
private static async ValueTask<ChatMessage> CheckResponseAsync(Task<AgentResponse> responseTask, IWorkflowContext context, CancellationToken cancellationToken)
{
AgentResponse response = await responseTask.ConfigureAwait(false);
if (response.Messages.Count == 0)
{
throw new InvalidOperationException("Planner Agent did not return any messages.");
}
if (response.Messages.Count > 1)
{
await context.AddEventAsync(new WorkflowWarningEvent("Planner Agent returned multiple messages; using the last one."), cancellationToken)
.ConfigureAwait(false);
}
return response.Messages[response.Messages.Count - 1];
}
private ValueTask<ChatMessage> InvokeAgentAsync(IEnumerable<ChatMessage> messages, IWorkflowContext context, CancellationToken cancellationToken, AgentSession? session = null)
=> CheckResponseAsync(managerAgent.RunAsync(messages, session, cancellationToken: cancellationToken), context, cancellationToken);
public async ValueTask<TaskLedger> UpdatePlanAsync(MagenticTaskContext taskContext, IWorkflowContext context, CancellationToken cancellationToken)
{
// If we already have a TaskLedger, we need to update the facts based on the existing factset; otherwise, we use the initial facts construction
bool isReplan = taskContext.TaskLedger != null;
AgentSession localSession = await managerAgent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
ChatMessage factsRequest = new(ChatRole.User, isReplan ? taskContext.ToTaskLedgerFactsUpdatePrompt() : taskContext.ToTaskLedgerFactsPrompt());
ChatMessage updatedFacts = await this.InvokeAgentAsync(
messages: [.. taskContext.ChatHistory, factsRequest],
context,
cancellationToken,
localSession)
.ConfigureAwait(false);
ChatMessage planRequest = new(ChatRole.User, isReplan ? taskContext.ToTaskLedgerPlanUpdatePrompt() : taskContext.ToTaskLedgerPlanPrompt());
ChatMessage updatedPlan = await this.InvokeAgentAsync(
// We rely on the AgentSession to maintain the context of the conversation, so we don't include the
// history, facts request, or updated facts in the messages list.
messages: [planRequest],
context,
cancellationToken,
localSession)
.ConfigureAwait(false);
taskContext.ChatHistory.AddRange([factsRequest, updatedFacts, planRequest, updatedPlan]);
return new(updatedFacts, updatedPlan);
}
public async ValueTask UpdateProgressLedgerAsync(MagenticTaskContext taskContext, IWorkflowContext context, CancellationToken cancellationToken)
{
ChatMessage progressRequest = new(ChatRole.User, taskContext.ToProgressLedgerPrompt());
ExceptionDispatchInfo? lastException = null;
int maxRetryCount = taskContext.TaskLimits.MaxProgressLedgerRetryCount;
for (int attempts = 0; attempts < maxRetryCount; attempts++)
{
ChatMessage progressUpdateMessage = await this.InvokeAgentAsync(
messages: [.. taskContext.ChatHistory, progressRequest],
context,
cancellationToken)
.ConfigureAwait(false);
try
{
lastException = null;
JsonElement stateUpdateJson = progressUpdateMessage.ExtractJson();
if (!taskContext.ProgressLedger.TryUpdateState(stateUpdateJson))
{
throw new InvalidOperationException("Could not answer progress ledger questions with provided JSON.");
}
break;
}
catch (Exception e)
{
lastException = ExceptionDispatchInfo.Capture(e);
string warnString = $"Progress ledger JSON parse failed (attempt {attempts}/{maxRetryCount}): {e}";
await context.AddEventAsync(new WorkflowWarningEvent(warnString), cancellationToken).ConfigureAwait(false);
if (attempts < maxRetryCount)
{
await Task.Delay(250 * attempts, cancellationToken).ConfigureAwait(false);
}
}
}
lastException?.Throw();
}
public async ValueTask<ChatMessage> PrepareFinalAnswerAsync(MagenticTaskContext taskContext, IWorkflowContext context, CancellationToken cancellationToken)
{
ChatMessage finalAnswerRequest = new(ChatRole.User, taskContext.ToFinalAnswerPrompt());
ChatMessage finalAnswer = await this.InvokeAgentAsync([.. taskContext.ChatHistory, finalAnswerRequest], context, cancellationToken)
.ConfigureAwait(false);
return new(ChatRole.Assistant, finalAnswer.Text)
{
AuthorName = finalAnswer.AuthorName ?? nameof(MagenticManager),
MessageId = finalAnswer.MessageId ?? Guid.NewGuid().ToString("N"),
CreatedAt = finalAnswer.CreatedAt ?? DateTimeOffset.UtcNow,
RawRepresentation = finalAnswer.RawRepresentation,
};
}
}
@@ -0,0 +1,326 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Specialized.Magentic;
/// <summary>
/// Base type for Magentic Orchestration Events
/// </summary>
/// <param name="data"></param>
[JsonDerivedType(typeof(MagenticPlanCreatedEvent))]
[JsonDerivedType(typeof(MagenticReplannedEvent))]
[JsonDerivedType(typeof(MagenticProgressLedgerUpdatedEvent))]
public abstract class MagenticOrchestratorEvent(object? data) : WorkflowEvent(data)
{
}
/// <summary>
/// Represents the creation of the initial plan
/// </summary>
/// <param name="fullTaskLeger"></param>
public sealed class MagenticPlanCreatedEvent(ChatMessage fullTaskLeger) : MagenticOrchestratorEvent(fullTaskLeger)
{
/// <summary>
/// A <see cref="ChatMessage"/> containing the initial plan.
/// </summary>
public ChatMessage FullTaskLedger { get; } = fullTaskLeger;
}
/// <summary>
/// Represents the creation of a new plan in response to a stall.
/// </summary>
/// <param name="fullTaskLeger"></param>
public sealed class MagenticReplannedEvent(ChatMessage fullTaskLeger) : MagenticOrchestratorEvent(fullTaskLeger)
{
/// <summary>
/// A <see cref="ChatMessage"/> containing the new plan.
/// </summary>
public ChatMessage FullTaskLedger { get; } = fullTaskLeger;
}
/// <summary>
/// Represents an update to the <see cref="MagenticProgressLedger"/> when running a coordination round.
/// </summary>
/// <param name="progressLedger"></param>
public sealed class MagenticProgressLedgerUpdatedEvent(MagenticProgressLedger progressLedger) : MagenticOrchestratorEvent(progressLedger)
{
/// <summary>
/// The new state of the <see cref="MagenticProgressLedger"/>
/// </summary>
public MagenticProgressLedger ProgressLedger { get; } = progressLedger;
}
/// <summary>
/// Magentic orchestrator that defines the workflow structure.
///
/// This orchestrator manages the overall Magentic workflow in the following structure:
///
/// 1. Upon receiving the task(a list of messages), it creates the plan using the manager then runs the inner loop.
/// 2. The inner loop is distributed and implementation is decentralized. In the orchestrator, it is responsible for:
/// - Creating the progress ledger using the manager.
/// - Checking for task completion.
/// - Detecting stalling or looping and triggering replanning if needed.
/// - Sending requests to participants based on the progress ledger's next speaker.
/// - Issue requests for human intervention if enabled and needed.
/// 3. The inner loop waits for responses from the selected participant, then continues the loop.
/// 4. The orchestrator breaks out of the inner loop when the replanning or final answer conditions are met.
/// 5. The outer loop handles replanning and reenters the inner loop.
/// </summary>
/// <param name="managerAgent"></param>
/// <param name="team"></param>
/// <param name="limits"></param>
/// <param name="requirePlanSignoff"></param>
internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, TaskLimits limits, bool requirePlanSignoff)
: ChatProtocolExecutor(nameof(MagenticOrchestrator), s_options, declareCrossRunShareable: false)
{
private readonly MagenticManager _manager = new(managerAgent);
private static readonly ChatProtocolExecutorOptions s_options = new()
{
StringMessageChatRole = ChatRole.User,
AutoSendTurnToken = false
};
private MagenticTaskContext? _taskContext;
private PortBinding? _planReviewPort;
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
return base.ConfigureProtocol(protocolBuilder).ConfigureRoutes(ConfigureRoutes);
void ConfigureRoutes(RouteBuilder routeBuilder) => routeBuilder.AddPortHandler<MagenticPlanReviewRequest, MagenticPlanReviewResponse>(
"RequestPlanReview",
this.ProcessPlanReviewAsync,
out this._planReviewPort);
}
private ValueTask SubmitPlanReviewRequestAsync(MagenticTaskContext taskContext, IWorkflowContext workflowContext)
{
MagenticProgressLedger? progressLedger = taskContext.ProgressLedger;
if (progressLedger?.IsStarted is not true)
{
progressLedger = null;
}
MagenticPlanReviewRequest request = new(taskContext.TaskLedger!.CurrentPlan, progressLedger, taskContext.IsStalled);
return this._planReviewPort!.PostRequestAsync(request);
}
private async ValueTask ProcessPlanReviewAsync(MagenticPlanReviewResponse response, IWorkflowContext context, CancellationToken cancellationToken)
{
/*
Handle the human response to the plan review request.
Logic:
There are code paths which will trigger a plan review request to the human:
- Initial plan creation if `require_plan_signoff` is True.
- Potentially during the inner loop if stalling is detected (resetting and replanning).
The human can either approve the plan or request revisions with comments.
- If approved, proceed to run the outer loop, which simply adds the task ledger
to the conversation and enters the inner loop.
- If revision requested, append the review comments to the chat history,
trigger replanning via the manager, emit a REPLANNED event, then run the outer loop.
*/
if (this._taskContext == null || this._taskContext.TaskLedger == null)
{
throw new InvalidOperationException("Magentic Orchestration was not initialized correctly.");
}
if (this._taskContext.IsTerminated)
{
throw new InvalidOperationException("Magentic Orchestration has already been terminated and cannot process new messages. Please start a new session.");
}
if (response.IsApproved)
{
await this.DelegateToTeamAsync(this._taskContext, context, cancellationToken).ConfigureAwait(false);
}
else
{
this._taskContext.ChatHistory.AddRange(response.Review);
await this.UpdatePlanAndDelegateAsync(this._taskContext, context, cancellationToken).ConfigureAwait(false);
}
}
private async ValueTask UpdatePlanAndDelegateAsync(MagenticTaskContext taskContext, IWorkflowContext context, CancellationToken cancellationToken)
{
bool isReplan = taskContext.TaskLedger != null;
taskContext.TaskLedger = await this._manager.UpdatePlanAsync(taskContext, context, cancellationToken)
.ConfigureAwait(false);
this._fullTaskLedgerMessage = new(ChatRole.User, taskContext.ToTaskLedgerFullPrompt());
taskContext.ChatHistory.Add(this._fullTaskLedgerMessage);
await context.AddEventAsync(isReplan
? new MagenticReplannedEvent(this._fullTaskLedgerMessage)
: new MagenticPlanCreatedEvent(this._fullTaskLedgerMessage), cancellationToken).ConfigureAwait(false);
if (requirePlanSignoff)
{
await this.SubmitPlanReviewRequestAsync(taskContext, context).ConfigureAwait(false);
}
else
{
await this.DelegateToTeamAsync(taskContext, context, cancellationToken).ConfigureAwait(false);
}
}
protected override async ValueTask TakeTurnAsync(List<ChatMessage> messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default)
{
// First Turn: Initialize the task context and send the initial messages to the planner agent
this._taskContext ??= new(messages, team, limits, emitEvents, []);
await this.UpdatePlanAndDelegateAsync(this._taskContext, context, cancellationToken).ConfigureAwait(false);
}
private ChatMessage? _fullTaskLedgerMessage;
private ValueTask DelegateToTeamAsync(MagenticTaskContext taskContext, IWorkflowContext context, CancellationToken cancellationToken)
{
return this.RunCoordinationRoundAsync(taskContext, context, cancellationToken);
}
private async ValueTask RunCoordinationRoundAsync(MagenticTaskContext taskContext, IWorkflowContext context, CancellationToken cancellationToken)
{
(bool hitRoundLimit, bool hitResetLimit) = taskContext.CheckLimits();
if (hitRoundLimit || hitResetLimit)
{
string limitType = hitRoundLimit ? "round" : "reset";
List<ChatMessage> messages = [new(ChatRole.Assistant, $"Task execution stopped due to hitting the maximum {limitType} count limit.")];
await context.YieldOutputAsync(messages, cancellationToken).ConfigureAwait(false);
taskContext.IsTerminated = true;
return;
}
taskContext.TaskCounters.RoundCount++;
// Update the Progress Ledger
try
{
await this._manager.UpdateProgressLedgerAsync(taskContext, context, cancellationToken).ConfigureAwait(false);
await context.AddEventAsync(new MagenticProgressLedgerUpdatedEvent(taskContext.ProgressLedger), cancellationToken)
.ConfigureAwait(false);
}
// Retry on exception to max retry count, unless it is OperationCancelledException - in that case exit the loop right away
catch (Exception ex) when (ex is not OperationCanceledException)
{
await context.AddEventAsync(new WorkflowWarningEvent($"Magentic Orchestrator: Progress ledger creation failed, triggering reset: {ex}"), cancellationToken)
.ConfigureAwait(false);
await this.ResetAndReplanAsync(taskContext, context, cancellationToken).ConfigureAwait(false);
return;
}
// Check and handle finish condition
if (taskContext.ProgressLedger.IsRequestSatisfied)
{
await this.PrepareFinalAnswerAsync(taskContext, context, cancellationToken).ConfigureAwait(false);
return;
}
// Check and handle stalls
if (taskContext.ProgressLedger.IsInLoop || !taskContext.ProgressLedger.IsProgressBeingMade)
{
taskContext.TaskCounters.StallCount++;
}
else
{
taskContext.TaskCounters.StallCount = Math.Max(0, taskContext.TaskCounters.StallCount - 1);
}
if (taskContext.IsStalled)
{
await this.ResetAndReplanAsync(taskContext, context, cancellationToken).ConfigureAwait(false);
return;
}
// Prepare to delegate to the next speaker
string nextSpeaker = taskContext.ProgressLedger.NextSpeaker;
if (string.IsNullOrEmpty(nextSpeaker))
{
await context.AddEventAsync(new WorkflowWarningEvent("Next speaker answer empty; selecting first participant as fallback"), cancellationToken)
.ConfigureAwait(false);
nextSpeaker = team.First().Name!;
}
AIAgent? nextAgent = team.FirstOrDefault(agent => agent.Name == nextSpeaker);
if (nextAgent == null)
{
await context.AddEventAsync(new WorkflowWarningEvent($"Invalid next speaker: {nextSpeaker}"), cancellationToken)
.ConfigureAwait(false);
await this.PrepareFinalAnswerAsync(taskContext, context, cancellationToken).ConfigureAwait(false);
return;
}
if (!string.IsNullOrWhiteSpace(taskContext.ProgressLedger.InstructionOrQuestion))
{
ChatMessage instruction = new(ChatRole.Assistant, taskContext.ProgressLedger.InstructionOrQuestion);
taskContext.ChatHistory.Add(instruction);
await context.SendMessageAsync(instruction, cancellationToken).ConfigureAwait(false);
}
string nextExecutorId = AIAgentHostExecutor.IdFor(nextAgent);
await context.SendMessageAsync(new TurnToken(taskContext.EmitUpdateEvents), nextExecutorId, cancellationToken).ConfigureAwait(false);
}
private async ValueTask ResetAndReplanAsync(MagenticTaskContext taskContext, IWorkflowContext context, CancellationToken cancellationToken)
{
taskContext.Reset();
await context.SendMessageAsync(new ResetChatSignal(), cancellationToken: cancellationToken).ConfigureAwait(false);
await this.UpdatePlanAndDelegateAsync(taskContext, context, cancellationToken).ConfigureAwait(false);
}
private async ValueTask PrepareFinalAnswerAsync(MagenticTaskContext taskContext, IWorkflowContext context, CancellationToken cancellationToken)
{
List<ChatMessage> messages = [await this._manager.PrepareFinalAnswerAsync(taskContext, context, cancellationToken).ConfigureAwait(false)];
await context.YieldOutputAsync(messages, cancellationToken).ConfigureAwait(false);
taskContext.IsTerminated = true;
}
private const string CurrentTurnEmitUpdateEventsKey = nameof(CurrentTurnEmitUpdateEventsKey);
protected internal override async ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
{
Task contextStateTask = this._taskContext == null
? Task.CompletedTask
: context.QueueStateUpdateAsync(MagenticConstants.MagenticTaskContextKey,
this._taskContext.ExportState(),
cancellationToken: cancellationToken)
.AsTask();
await Task.WhenAll(base.OnCheckpointingAsync(context, cancellationToken).AsTask(),
contextStateTask).ConfigureAwait(false);
}
protected internal override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
{
await Task.WhenAll(base.OnCheckpointRestoredAsync(context, cancellationToken).AsTask(), LoadContextStateAsync())
.ConfigureAwait(false);
async Task LoadContextStateAsync()
{
MagenticTaskState? state = await context.ReadStateAsync<MagenticTaskState>(MagenticConstants.MagenticTaskContextKey, cancellationToken: cancellationToken)
.ConfigureAwait(false);
if (state != null)
{
this._taskContext = new MagenticTaskContext(state, team, limits, []);
}
}
}
}
@@ -0,0 +1,95 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Specialized.Magentic;
internal record TaskLimits(int MaxStallCount = TaskLimits.DefaultMaxStallCount,
int? MaxRoundCount = null,
int? MaxResetCount = null,
int MaxProgressLedgerRetryCount = TaskLimits.DefaultMaxProgressLedgerRetryCount)
{
public const int DefaultMaxStallCount = 3;
public const int DefaultMaxProgressLedgerRetryCount = 3;
}
internal record TaskLedger(ChatMessage CurrentFacts, ChatMessage CurrentPlan);
internal class TaskCounters
{
public int RoundCount { get; set; }
public int StallCount { get; set; }
public int ResetCount { get; set; }
}
internal record MagenticTaskState(List<ChatMessage> TaskDefinition, List<ChatMessage> ChatHistory, TaskLedger? TaskLedger, JsonElement? ProgressLedgerState, TaskCounters Counters, bool Terminated, bool? EmitUpdateEvents)
{
}
internal class MagenticTaskContext(List<ChatMessage> taskDefinition, List<AIAgent> team, TaskLimits limits, bool? emitUpdateEvents, IEnumerable<ProgressLedgerSlot> additionalProgressQuestions)
{
internal MagenticTaskContext(MagenticTaskState state, List<AIAgent> team, TaskLimits limits, IEnumerable<ProgressLedgerSlot> additionalProgressQuestions)
: this(state.TaskDefinition, team, limits, state.EmitUpdateEvents, additionalProgressQuestions)
{
this.TaskLedger = state.TaskLedger;
this.TaskCounters = state.Counters;
this.ChatHistory = state.ChatHistory;
this.IsTerminated = state.Terminated;
if (state.ProgressLedgerState.HasValue && !this.ProgressLedger.TryUpdateState(state.ProgressLedgerState.Value))
{
throw new InvalidOperationException("Could not load progress ledger state value");
}
}
public string Task { get; } = taskDefinition.GetText();
public string TeamDescription { get; } = GetTeamDescription(team);
public List<ChatMessage> ChatHistory { get; internal set; } = new();
public TaskLedger? TaskLedger { get; internal set; }
public TaskLimits TaskLimits => limits;
public bool IsTerminated { get; internal set; }
public bool IsStalled => this.TaskCounters.StallCount >= this.TaskLimits.MaxStallCount;
public (bool HitRoundLimit, bool HitResetLimit) CheckLimits()
{
return (this.TaskLimits.MaxRoundCount.HasValue && this.TaskLimits.MaxRoundCount.Value <= this.TaskCounters.RoundCount,
this.TaskLimits.MaxResetCount.HasValue && this.TaskLimits.MaxResetCount.Value <= this.TaskCounters.ResetCount);
}
public TaskCounters TaskCounters { get; internal set; } = new();
public MagenticProgressLedger ProgressLedger { get; } = new(GetTeamNames(team), additionalProgressQuestions);
public bool? EmitUpdateEvents => emitUpdateEvents;
public static string GetTeamDescription(IEnumerable<AIAgent> team)
{
return string.Join("\n", team.Select(agent => $"- {agent.Name}: {agent.Description}"));
}
public static string GetTeamNames(IEnumerable<AIAgent> team)
{
return string.Join(", ", team.Select(agent => agent.Name));
}
public MagenticTaskState ExportState()
{
return new(taskDefinition, this.ChatHistory, this.TaskLedger, this.ProgressLedger.State, this.TaskCounters, this.IsTerminated, this.EmitUpdateEvents);
}
internal void Reset()
{
this.ChatHistory.Clear();
this.TaskCounters.ResetCount++;
this.TaskCounters.StallCount = 0;
}
}
@@ -0,0 +1,151 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Specialized.Magentic;
internal static class PromptTemplateExtensions
{
public static string ToTaskLedgerFactsPrompt(this MagenticTaskContext taskContext)
{
return $"""
Below I will present you a request.
Before we begin addressing the request, please answer the following pre-survey to the best of your ability.
Keep in mind that you are Ken Jennings-level with trivia, and Mensa-level with puzzles, so there should be
a deep well to draw from.
Here is the request:
{taskContext.Task}
Here is the pre-survey:
1. Please list any specific facts or figures that are GIVEN in the request itself.It is possible that
there are none.
2. Please list any facts that may need to be looked up, and WHERE SPECIFICALLY they might be found.
In some cases, authoritative sources are mentioned in the request itself.
3. Please list any facts that may need to be derived(e.g., via logical deduction, simulation, or computation)
4. Please list any facts that are recalled from memory, hunches, well-reasoned guesses, etc.
When answering this survey, keep in mind that "facts" will typically be specific names, dates, statistics, etc.
Your answer should use headings:
1. GIVEN OR VERIFIED FACTS
2. FACTS TO LOOK UP
3. FACTS TO DERIVE
4. EDUCATED GUESSES
DO NOT include any other headings or sections in your response.DO NOT list next steps or plans until asked to do so.
""";
}
public static string ToTaskLedgerFactsUpdatePrompt(this MagenticTaskContext taskContext)
{
return $"""
As a reminder, we are working to solve the following task:
{taskContext.Task}
It is clear we are not making as much progress as we would like, but we may have learned something new.
Please rewrite the following fact sheet, updating it to include anything new we have learned that may be helpful.
Example edits can include (but are not limited to) adding new guesses, moving educated guesses to verified facts
if appropriate, etc. Updates may be made to any section of the fact sheet, and more than one section of the fact
sheet can be edited. This is an especially good time to update educated guesses, so please at least add or update
one educated guess or hunch, and explain your reasoning.
Here is the old fact sheet:
{taskContext.TaskLedger?.CurrentFacts ?? new(ChatRole.Assistant, string.Empty)}
""";
}
public static string ToTaskLedgerPlanPrompt(this MagenticTaskContext taskContext)
{
return $"""
Fantastic. To address this request we have assembled the following team:
{taskContext.TeamDescription}
Based on the team composition, and known and unknown facts, please devise a short bullet-point plan for addressing the
original request. Remember, there is no requirement to involve all team members. A team member's particular expertise
may not be needed for this task.
""";
}
public static string ToTaskLedgerPlanUpdatePrompt(this MagenticTaskContext taskContext)
{
return $"""
Please briefly explain what went wrong on this last run
(the root cause of the failure), and then come up with a new plan that takes steps and includes hints to overcome prior
challenges and especially avoids repeating the same mistakes. As before, the new plan should be concise, expressed in
bullet-point form, and consider the following team composition:
{taskContext.TeamDescription}
""";
}
public static string ToTaskLedgerFullPrompt(this MagenticTaskContext taskContext)
{
return $"""
We are working to address the following user request:
{taskContext.Task}
To answer this request we have assembled the following team:
{taskContext.TeamDescription}
Here is an initial fact sheet to consider:
{taskContext.TaskLedger!.CurrentFacts ?? new(ChatRole.Assistant, string.Empty)}
Here is the plan to follow as best as possible:
{taskContext.TaskLedger!.CurrentPlan}
""";
}
public static string ToProgressLedgerPrompt(this MagenticTaskContext taskContext)
{
(string questions, string schema) = taskContext.ProgressLedger.FormatQuestions();
return $"""
Recall we are working on the following request:
{taskContext.Task}
And we have assembled the following team:
{taskContext.TeamDescription}
To make progress on the request, please answer the following questions, including necessary reasoning:
{questions}
Please output an answer in pure JSON format according to the following schema. The JSON object must be parsable as-is.
DO NOT OUTPUT ANYTHING OTHER THAN JSON, AND DO NOT DEVIATE FROM THIS SCHEMA:
{schema}
""";
}
public static string ToFinalAnswerPrompt(this MagenticTaskContext taskContext)
{
return $"""
We are working on the following task:
{taskContext.Task}
We have completed the task.
The above messages contain the conversation that took place to complete the task.
Based on the information gathered, provide the final answer to the original request.
The answer should be phrased as if you were speaking to the user.
""";
}
}
@@ -0,0 +1,77 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Specialized.Magentic;
internal sealed class StreamingToolCallResultPairMatcher
{
private enum CallType
{
Function,
McpServerTool
}
private record CallSummaryKey(CallType Type, string CallId);
private struct ToolCallSummary(CallType callType, string callId, string name)
{
public CallType CallType => callType;
public string? CallId => callId;
public string Name => name;
}
private readonly Dictionary<CallSummaryKey, ToolCallSummary> _callSummaries = new();
private void Collect(CallType callType, string callId, string name, string callContentTypeName, string resultContentTypeName)
{
CallSummaryKey key = new(callType, callId);
if (this._callSummaries.ContainsKey(key))
{
throw new InvalidOperationException($"Duplicate {callContentTypeName} with CallId '{callId}' without corresponding {resultContentTypeName}.");
}
this._callSummaries[key] = new ToolCallSummary(callType, callId, name);
}
public void CollectFunctionCall(FunctionCallContent callContent)
{
const string FunctionCallContentTypeName = nameof(FunctionCallContent);
const string FunctionResultContentTypeName = nameof(FunctionResultContent);
this.Collect(CallType.Function, callContent.CallId, callContent.Name, FunctionCallContentTypeName, FunctionResultContentTypeName);
}
public void CollectMcpServerToolCall(McpServerToolCallContent callContent)
{
const string McpServerToolCallContentTypeName = nameof(McpServerToolCallContent);
const string McpServerToolResultContentTypeName = nameof(McpServerToolResultContent);
this.Collect(CallType.McpServerTool, callContent.CallId, callContent.Name, McpServerToolCallContentTypeName, McpServerToolResultContentTypeName);
}
private bool TryResolve(CallType callType, string callId, [NotNullWhen(true)] out string? name)
{
CallSummaryKey key = new(callType, callId);
bool hasMatchingCall = this._callSummaries.TryGetValue(key, out ToolCallSummary callSummary);
if (hasMatchingCall)
{
this._callSummaries.Remove(key);
}
name = hasMatchingCall ? callSummary.Name : null;
return hasMatchingCall;
}
public bool TryResolveFunctionCall(FunctionResultContent resultContent, [NotNullWhen(true)] out string? name)
=> this.TryResolve(CallType.Function, resultContent.CallId, out name);
public bool TryResolveMcpServerToolCall(McpServerToolResultContent resultContent, [NotNullWhen(true)] out string? name)
=> this.TryResolve(CallType.McpServerTool, resultContent.CallId, out name);
}
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
using Microsoft.Agents.AI.Workflows.Specialized.Magentic;
namespace Microsoft.Agents.AI.Workflows;
@@ -14,6 +15,8 @@ namespace Microsoft.Agents.AI.Workflows;
[JsonDerivedType(typeof(WorkflowWarningEvent))]
[JsonDerivedType(typeof(WorkflowOutputEvent))]
[JsonDerivedType(typeof(RequestInfoEvent))]
[JsonDerivedType(typeof(MagenticOrchestratorEvent))]
public class WorkflowEvent(object? data = null)
{
/// <summary>
@@ -7,6 +7,7 @@ using System.Text.Json.Serialization;
using Microsoft.Agents.AI.Workflows.Checkpointing;
using Microsoft.Agents.AI.Workflows.Execution;
using Microsoft.Agents.AI.Workflows.Specialized;
using Microsoft.Agents.AI.Workflows.Specialized.Magentic;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows;
@@ -97,6 +98,10 @@ internal static partial class WorkflowsJsonUtilities
[JsonSerializable(typeof(AIAgentHostState))]
[JsonSerializable(typeof(HandoffSharedState))]
[JsonSerializable(typeof(HandoffAgentHostState))]
[JsonSerializable(typeof(MagenticPlanReviewRequest))]
[JsonSerializable(typeof(MagenticPlanReviewResponse))]
[JsonSerializable(typeof(MagenticTaskState))]
[JsonSerializable(typeof(ResetChatSignal))]
// Event Types
//[JsonSerializable(typeof(WorkflowEvent))]
@@ -0,0 +1,153 @@
// Copyright (c) Microsoft. All rights reserved.
//using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Agents.AI.Workflows.Specialized.Magentic;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
public class MagenticManagerTests
{
private static void CheckMessage(ChatMessage message, string expectedText, bool runPropertySmokeTest = false, bool skipCreatedAt = true)
{
message.Text.Should().Be(expectedText);
if (runPropertySmokeTest)
{
message.AuthorName.Should().Be(nameof(MagenticOrchestrator));
if (!skipCreatedAt)
{
message.CreatedAt.Should().NotBeNull().And.NotBeBefore(DateTimeOffset.UtcNow.AddDays(-1));
}
message.Role.Should().Be(ChatRole.Assistant);
message.MessageId.Should().NotBeNull();
}
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task Test_MagenticManager_UpdatePlanAsync(bool hasExistingPlan)
{
TestReplayAgent testAgent = new(name: nameof(MagenticOrchestrator),
messages:
[
[new(ChatRole.Assistant, "Facts")],
[new(ChatRole.Assistant, "Plan")],
]);
TestEchoAgent participant = new(name: "Echo");
MagenticManager manager = new(testAgent);
MagenticTaskContext taskContext = new([new(ChatRole.User, "Task")], [participant], new TaskLimits(), null, []);
if (hasExistingPlan)
{
taskContext.TaskLedger = new(new(ChatRole.Assistant, "OldFacts"), new(ChatRole.Assistant, "OldPlan"));
}
TestRunContext runContext = new();
IWorkflowContext workflowContext = runContext.BindWorkflowContext(nameof(MagenticOrchestrator));
TaskLedger newPlan = await manager.UpdatePlanAsync(taskContext, workflowContext, CancellationToken.None);
CheckMessage(newPlan.CurrentFacts, "Facts");
CheckMessage(newPlan.CurrentPlan, "Plan");
taskContext.ChatHistory.Should().HaveCount(4);
if (hasExistingPlan)
{
ChatMessage factsRequest = taskContext.ChatHistory[0];
factsRequest.Text.Should().Contain("OldFacts");
}
ChatMessage facts = taskContext.ChatHistory[1];
facts.Should().Be(newPlan.CurrentFacts);
ChatMessage plan = taskContext.ChatHistory[3];
plan.Should().Be(newPlan.CurrentPlan);
}
[Theory]
[InlineData(0)]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
public async Task Test_MagenticManager_UpdateProgressLedgerAsync(int failures)
{
List<List<ChatMessage>> turns =
TestProgressLedgerState.MissingRequired.Take(failures)
.Select<TestProgressLedgerState, List<ChatMessage>>(
state => [new ChatMessage(ChatRole.Assistant, state.ToJsonString())])
.ToList();
turns.Should().HaveCount(failures);
turns.Add([new ChatMessage(ChatRole.Assistant, TestProgressLedgerState.Default.ToJsonString())]);
TestReplayAgent testAgent = new(name: nameof(MagenticOrchestrator),
messages: turns);
TestEchoAgent participant = new(name: "Echo");
MagenticManager manager = new(testAgent);
MagenticTaskContext taskContext = new([new(ChatRole.User, "Task")], [participant], new TaskLimits(), null, []);
taskContext.TaskLedger = new(new(ChatRole.Assistant, "OldFacts"), new(ChatRole.Assistant, "OldPlan"));
TestRunContext runContext = new();
IWorkflowContext workflowContext = runContext.BindWorkflowContext(nameof(MagenticOrchestrator));
// Precondition check: ProgressLedger should be not "started"
taskContext.ProgressLedger.IsStarted.Should().BeFalse();
Func<Task> action = () => manager.UpdateProgressLedgerAsync(taskContext, workflowContext, CancellationToken.None).AsTask();
if (failures >= taskContext.TaskLimits.MaxProgressLedgerRetryCount)
{
// We expect to see an exception if the number of failures exceeds the maximum retry count
await action.Should().ThrowAsync();
taskContext.ProgressLedger.IsStarted.Should().BeFalse();
}
else
{
await action.Should().NotThrowAsync();
taskContext.ProgressLedger.IsStarted.Should().BeTrue();
TestProgressLedgerState.Default.Validate(taskContext.ProgressLedger);
}
int expectedWarnings = Math.Min(failures, 3);
runContext.Events.Should().HaveCount(expectedWarnings).And.AllBeOfType<WorkflowWarningEvent>();
}
[Fact]
public async Task Test_MagenticManager_PrepareFinalAnswerAsync()
{
TestReplayAgent testAgent = new(name: nameof(MagenticOrchestrator),
messages:
[
[
new(ChatRole.Assistant, "FinalAnswer")
],
]);
TestEchoAgent participant = new(name: "Echo");
MagenticManager manager = new(testAgent);
MagenticTaskContext taskContext = new([new(ChatRole.User, "Task")], [participant], new TaskLimits(), null, []);
TestRunContext runContext = new();
IWorkflowContext workflowContext = runContext.BindWorkflowContext(nameof(MagenticOrchestrator));
ChatMessage answer = await manager.PrepareFinalAnswerAsync(taskContext, workflowContext, CancellationToken.None);
CheckMessage(answer, "FinalAnswer", true, false);
}
}
@@ -0,0 +1,214 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Text.Json;
using FluentAssertions;
using Microsoft.Agents.AI.Workflows.Specialized.Magentic;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
public class MagenticProgressLedgerTests
{
public record KVPair(string key);
public record AnswerReasonPair(bool answer, string reason);
[Theory]
[InlineData(false)]
[InlineData(true)]
public void Test_ExtractJson_SucceedsWhenInBlockQuote(bool isTagged)
{
// Arrange
string json = isTagged
? "```json\n{\"key\": \"value\"}\n```"
: "```{\"key\": \"value\"}```";
string embedded = $"Some text before the JSON block.\n{json}\nSome text after the JSON block.";
ChatMessage message = new(ChatRole.Assistant, embedded);
// Act
JsonElement element = message.ExtractJson();
// Assert
KVPair? result = element.Deserialize<KVPair>();
result.Should().NotBeNull();
result.key.Should().Be("value");
}
[Fact]
public void Test_ExtractJson_SucceedsWhenScanning()
{
// Arrange
ChatMessage message = new(ChatRole.Assistant,
"""
Some text before the JSON embed.
{"key": "value"}
Some text after the JSON embed.
""");
// Act
JsonElement element = message.ExtractJson();
// Assert
KVPair? result = element.Deserialize<KVPair>();
result.Should().NotBeNull();
result.key.Should().Be("value");
}
[Fact]
public void Test_ExtractJson_FailsWhenUnbalanced()
{
// Arrange
ChatMessage message = new(ChatRole.Assistant,
"""
Some text before the JSON embed.
{"key": { "key2": "value" }
Some text after the JSON embed.
""");
// Act
Func<JsonElement> action = () => message.ExtractJson();
// Assert
action.Should().Throw();
}
[Fact]
public void Test_ExtractJson_FailsWhenNoJson()
{
// Arrange
ChatMessage message = new(ChatRole.Assistant,
"""
Some text, without JSON
""");
// Act
Func<JsonElement> action = () => message.ExtractJson();
// Assert
action.Should().Throw();
}
[Fact]
public void Test_ExtractJson_SuceedsWithQuotesBrackets()
{
// Arrange
ChatMessage message = new(ChatRole.Assistant,
"""
{"reason":"the output contained }", "answer": false}
""");
// Act
JsonElement element = message.ExtractJson();
// Assert
AnswerReasonPair? result = element.Deserialize<AnswerReasonPair>();
result.Should().NotBeNull();
result.reason.Should().Be("the output contained }");
result.answer.Should().BeFalse();
}
public static readonly string TestTeamNames = string.Join(", ", ["CodingAgent", "CodeExecutor", "WebSurferAgent", "FileSurferAgent"]);
[Fact]
public void Test_ProgressLedgerState_IsEmptyWhenStarted()
{
// Arrange/Act
MagenticProgressLedger ledger = new(TestTeamNames, []);
// Assert
ledger.State.Should().BeNull();
ledger.IsStarted.Should().BeFalse();
ledger.TryGetCurrentSlotValue(TestProgressLedgerState.CustomSlot1, out _).Should().BeFalse();
ledger.TryGetCurrentSlotValue(TestProgressLedgerState.CustomSlot2, out _).Should().BeFalse();
}
[Theory]
[InlineData(0, "RequiredOnly")]
[InlineData(1, "IncludeCustom")]
public void Test_ProgressLedgerState_IsNotEmptyWhenRestored(int caseIndex, string _)
{
// Arrange
TestProgressLedgerState state = TestProgressLedgerState.Working[caseIndex];
JsonElement element = state.ToJson();
// Act
MagenticProgressLedger ledger = new(TestTeamNames, [], element);
// Assert
ledger.State.Should().Be(element);
state.Validate(ledger);
}
[Theory]
[InlineData(0, "RequiredOnly")]
[InlineData(1, "IncludeCustom")]
public void Test_ProgressLedgerState_SwitchesToStartedWhenStateUpdates(int caseIndex, string _)
{
// Arrange
MagenticProgressLedger ledger = new(TestTeamNames, []);
TestProgressLedgerState targetState = TestProgressLedgerState.Working[caseIndex];
JsonElement element = targetState.ToJson();
ledger.State.Should().BeNull();
// Act
ledger.TryUpdateState(element).Should().BeTrue();
// Assert
ledger.State.Should().Be(element);
targetState.Validate(ledger);
}
[Theory]
[InlineData(0, "is_request_satisfied")]
[InlineData(1, "is_in_loop")]
[InlineData(2, "is_progress_being_made")]
[InlineData(3, "instruction_or_question")]
[InlineData(4, "next_speaker")]
public void Test_ProgressLedgerState_FailsToUpdateWhenRequiredAnswersMissing(int caseIndex, string _)
{
// Arrange
MagenticProgressLedger ledger = new(TestTeamNames, []);
TestProgressLedgerState targetState = TestProgressLedgerState.MissingRequired[caseIndex];
JsonElement element = targetState.ToJson();
ledger.State.Should().BeNull();
// Act
ledger.TryUpdateState(element).Should().BeFalse();
ledger.State.Should().BeNull();
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public void Test_ProgressLedgerState_GeneratesCorrectSchema(bool includeCustom)
{
// Arrange
MagenticProgressLedger ledger = new(TestTeamNames, includeCustom
? [TestProgressLedgerState.CustomSlot1, TestProgressLedgerState.CustomSlot2]
: []);
// Act
(string questionBlock, string answerSchema) = ledger.FormatQuestions();
foreach (ProgressLedgerSlot slot in ledger.Slots)
{
// Best-efforts validation: I do not want to make it super-brittle and check for 1:1: with the template
// since that is effectively checking that string formatting works right to some extent.
questionBlock.Should().Contain(slot.Question);
answerSchema.Should().Contain(slot.Key);
answerSchema.Should().Contain(slot.SchemaType);
if (!string.IsNullOrWhiteSpace(slot.SchemaTypeSuffix))
{
answerSchema.Should().Contain(slot.SchemaTypeSuffix);
}
}
}
}
@@ -0,0 +1,88 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using System.Text.Json.Serialization;
using FluentAssertions;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
public sealed record Slot<T>(T? answer, string? reason);
public record TestProgressLedgerState(Slot<bool?>? is_request_satisfied = null,
Slot<bool?>? is_in_loop = null,
Slot<bool?>? is_progress_being_made = null,
Slot<string>? instruction_or_question = null,
Slot<string>? next_speaker = null,
Slot<bool?>? custom1 = null,
Slot<string>? custom2 = null)
{
public TestProgressLedgerState() : this(new Slot<bool?>(false, "is_request_satisfied_reason"),
new Slot<bool?>(false, "is_in_loop_reason"),
new Slot<bool?>(false, "is_progress_being_made_reason"),
new Slot<string>("Answer", "instruction_or_question_reason"),
new Slot<string>("Lorem Ipsum", "next_speaker_reason"),
new Slot<bool?>(false, "custom1_reason"),
new Slot<string>("Custom2", "custom2_reason"))
{ }
public string ToJsonString() => this.ToJson().ToString();
private static readonly JsonSerializerOptions s_options = new()
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
public JsonElement ToJson() => JsonSerializer.SerializeToElement(this, s_options);
internal static BooleanProgressLedgerSlot CustomSlot1 = new("custom1", "Custom Slot 1");
internal static StringProgressLedgerSlot CustomSlot2 = new("custom2", "Custom Slot 2");
public static bool TryGetCustom1(MagenticProgressLedger state, out bool result)
=> state.TryGetCurrentSlotValue(CustomSlot1, out result);
public static bool TryGetCustom2(MagenticProgressLedger state, out string? result)
=> state.TryGetCurrentSlotValue(CustomSlot2, out result);
public void Validate(MagenticProgressLedger state)
{
state.IsRequestSatisfied.Should().Be(this.is_request_satisfied!.answer!.Value);
state.IsInLoop.Should().Be(this.is_in_loop!.answer!.Value);
state.IsProgressBeingMade.Should().Be(this.is_progress_being_made!.answer!.Value);
state.InstructionOrQuestion.Should().Be(this.instruction_or_question!.answer);
state.NextSpeaker.Should().Be(this.next_speaker!.answer);
if (this.custom1 != null)
{
TryGetCustom1(state, out bool custom1Value).Should().BeTrue();
custom1Value.Should().Be(this.custom1.answer!.Value);
}
else
{
TryGetCustom1(state, out _).Should().BeFalse();
}
if (this.custom2 != null)
{
TryGetCustom2(state, out string? custom2Value).Should().BeTrue();
custom2Value.Should().Be(this.custom2.answer);
}
else
{
TryGetCustom2(state, out _).Should().BeFalse();
}
}
public static readonly TestProgressLedgerState Default = new();
public static readonly TestProgressLedgerState RequiredOnly = Default with { custom1 = null, custom2 = null };
public static readonly TestProgressLedgerState[] Working = [RequiredOnly, Default];
public static readonly TestProgressLedgerState[] MissingRequired =
[
Default with { is_request_satisfied = null },
Default with { is_in_loop = null},
Default with { is_progress_being_made = null},
Default with { instruction_or_question = null},
Default with { next_speaker = null},
];
}
@@ -11,8 +11,14 @@ using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
public class TestReplayAgent(List<ChatMessage>? messages = null, string? id = null, string? name = null) : AIAgent
public class TestReplayAgent(List<List<ChatMessage>> messages, string? id = null, string? name = null) : AIAgent
{
public TestReplayAgent(List<ChatMessage> messages, string? id = null, string? name = null) : this([messages ?? []], id, name)
{ }
public TestReplayAgent(string? id = null, string? name = null) : this([[]], id, name)
{ }
protected override string? IdCore => id;
public override string? Name => name;
@@ -57,46 +63,55 @@ public class TestReplayAgent(List<ChatMessage>? messages = null, string? id = nu
public static TestReplayAgent FromStrings(params string[] messages) =>
new(ToChatMessages(messages));
public List<ChatMessage> Messages { get; } = Validate(messages) ?? [];
public List<List<ChatMessage>> Messages { get; } = Validate(messages) ?? [];
protected override Task<AgentResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
=> this.RunStreamingAsync(messages, session, options, cancellationToken).ToAgentResponseAsync(cancellationToken);
public int Turn { get; set; }
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
string responseId = Guid.NewGuid().ToString("N");
foreach (ChatMessage message in this.Messages)
if (this.Turn < this.Messages.Count)
{
foreach (AIContent content in message.Contents)
foreach (ChatMessage message in this.Messages[this.Turn++])
{
yield return new AgentResponseUpdate()
foreach (AIContent content in message.Contents)
{
AgentId = this.Id,
AuthorName = this.Name,
MessageId = message.MessageId,
ResponseId = responseId,
Contents = [content],
Role = message.Role,
};
yield return new AgentResponseUpdate()
{
AgentId = this.Id,
AuthorName = this.Name,
MessageId = message.MessageId,
ResponseId = responseId,
Contents = [content],
Role = message.Role,
};
}
}
}
}
private static List<ChatMessage>? Validate(List<ChatMessage>? candidateMessages)
private static List<List<ChatMessage>>? Validate(List<List<ChatMessage>>? candidateMessages)
{
string? currentMessageId = null;
string? lastMessageId = null;
if (candidateMessages is not null)
if (candidateMessages != null)
{
foreach (ChatMessage message in candidateMessages)
foreach (List<ChatMessage> candidateMessagesTurn in candidateMessages)
{
if (currentMessageId is null)
foreach (ChatMessage message in candidateMessagesTurn)
{
currentMessageId = message.MessageId;
}
else if (currentMessageId == message.MessageId)
{
throw new ArgumentException("Duplicate consecutive message ids");
if (lastMessageId is null || lastMessageId != message.MessageId)
{
lastMessageId = message.MessageId;
}
else if (lastMessageId == message.MessageId)
{
throw new ArgumentException("Duplicate consecutive message ids");
}
}
}
}