// 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; /// /// Maintains a ledger of progress made by the Magentic workflow. /// [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 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; /// /// Specifies whether plan execution has started. /// [JsonIgnore] public bool IsStarted => this.State != null; /// /// Specifies whether the task has been fully satisfied. /// [JsonIgnore] public bool IsRequestSatisfied { get; private set; } /// /// Specifies whether the team is in a loop. /// [JsonIgnore] public bool IsInLoop { get; private set; } /// /// Specifies whether the team is making progress on the task. /// [JsonIgnore] public bool IsProgressBeingMade { get; private set; } /// /// Gets the next team member to take a turn. /// [JsonIgnore] public string NextSpeaker { get; private set; } = string.Empty; /// /// Gets the instruction or question to send to the next team member. /// [JsonIgnore] public string InstructionOrQuestion { get; private set; } = string.Empty; [JsonIgnore] internal IEnumerable Slots => [ IsRequestSatisfiedSlot, IsInLoopSlot, IsProgressBeingMadeSlot, this.NextSpeakerSlot, InstructionOrQuestionSlot, .. this.AdditionalQuestions ]; internal bool TryGetCurrentSlotValue(ProgressLedgerSlot 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 questionLines = this.Question.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries) .Select(line => line.TrimEnd()); field = $" - {string.Join("\n ", questionLines)}"; } return field; } } } internal abstract record ProgressLedgerSlot(string Key, string Question, string? SchemaTypeSuffix = null, JsonSerializerOptions? SerializerOptions = null) : ProgressLedgerSlot(Key, Question, SchemaTypeSuffix) { protected internal virtual JsonTypeInfo GetJsonTypeInfo() => ((this.SerializerOptions ?? WorkflowsJsonUtilities.DefaultOptions).TryGetTypeInfo(typeof(T), out JsonTypeInfo? typeInfo) ? typeInfo as JsonTypeInfo : 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(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 GetJsonTypeInfo() => WorkflowsJsonUtilities.JsonContext.Default.Boolean; protected internal override string SchemaType => "boolean"; } internal sealed record StringProgressLedgerSlot(string Key, string Question, string? SchemaTypeSuffix = null) : ProgressLedgerSlot(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 GetJsonTypeInfo() => WorkflowsJsonUtilities.JsonContext.Default.String; protected internal override string SchemaType => "string"; }