Files
agent-framework/dotnet/src/Microsoft.Agents.AI.Workflows/MagenticProgressLedger.cs
T
Jacob AlberGitHublokitothcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
27324a8013 .NET: Mark Magentic Orchestration Experimental (#5704)
* fix: Mark Magentic Orchestration Experimental

* Apply [Experimental] to all public Magentic types and suppress MAAIW001 in project

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/957a07c1-a805-40eb-989d-bd3425d4c0af

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>
2026-05-07 22:54:01 +00:00

271 lines
10 KiB
C#

// 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>
[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)]
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";
}