mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.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:
committed by
GitHub
Unverified
parent
2a9b68d1bd
commit
ce70ca1a9f
@@ -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(
|
||||
|
||||
+175
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
+326
@@ -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.
|
||||
""";
|
||||
}
|
||||
}
|
||||
+77
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user