// Copyright (c) Microsoft. All rights reserved. using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.AI.Workflows.Declarative.Entities; using Microsoft.Agents.AI.Workflows.Declarative.Events; using Microsoft.Agents.AI.Workflows.Declarative.Extensions; using Microsoft.Agents.AI.Workflows.Declarative.Interpreter; using Microsoft.Agents.AI.Workflows.Declarative.Kit; using Microsoft.Agents.AI.Workflows.Declarative.PowerFx; using Microsoft.Agents.ObjectModel; using Microsoft.Extensions.AI; using Microsoft.PowerFx.Types; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.Workflows.Declarative.ObjectModel; [SendsMessage(typeof(ExternalInputRequest))] [SendsMessage(typeof(ExternalInputResponse))] internal sealed class QuestionExecutor(Question model, ResponseAgentProvider agentProvider, WorkflowFormulaState state) : DeclarativeActionExecutor(model, state) { public static class Steps { public static string Prepare(string id) => $"{id}_{nameof(Prepare)}"; public static string Input(string id) => $"{id}_{nameof(Input)}"; public static string Capture(string id) => $"{id}_{nameof(Capture)}"; } private readonly DurableProperty _promptCount = new(nameof(_promptCount)); private readonly DurableProperty _hasExecuted = new(nameof(_hasExecuted)); protected override bool IsDiscreteAction => false; protected override bool EmitResultEvent => false; // Input has been captured when Result is null public static bool IsComplete(object? message) { ActionExecutorResult executorMessage = ActionExecutorResult.ThrowIfNot(message); return executorMessage.Result is null; } protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken = default) { InitializablePropertyPath variable = Throw.IfNull(this.Model.Variable); bool isValueUndefined = context.ReadState(variable.Path) is BlankValue; // Snapshot prior-execution state before we mutate it below so the SkipQuestionMode // evaluation reflects whether this is the first time the action has run. bool hasExecutedPreviously = await this._hasExecuted.ReadAsync(context).ConfigureAwait(false); bool proceed = this.Evaluator.GetValue(this.Model.AlwaysPrompt).Value; if (!proceed) { SkipQuestionMode mode = this.Evaluator.GetValue(this.Model.SkipQuestionMode).Value; proceed = mode switch { SkipQuestionMode.SkipOnFirstExecutionIfVariableHasValue => isValueUndefined || hasExecutedPreviously, SkipQuestionMode.AlwaysSkipIfVariableHasValue => isValueUndefined, SkipQuestionMode.AlwaysAsk => true, _ => true, }; } // Record that the action has executed in the same executor scope as the read above. // (CaptureResponseAsync runs in a different executor's state scope, so writing it there // would not be visible to subsequent ExecuteAsync invocations triggered by GotoAction.) await this._hasExecuted.WriteAsync(context, true).ConfigureAwait(false); if (proceed) { // Initial prompt: count is 0 because no responses have been received yet for this turn. // _promptCount itself is tracked in CaptureResponseAsync's scope (see comment on _promptCount). await this.PromptAsync(context, actualCount: 0, cancellationToken).ConfigureAwait(false); } else { await context.SendResultMessageAsync(this.Id, cancellationToken).ConfigureAwait(false); } return default; } public async ValueTask PrepareResponseAsync(IWorkflowContext context, ActionExecutorResult message, CancellationToken cancellationToken) { ExternalInputRequest inputRequest = new(this.FormatPrompt(this.Model.Prompt)); await context.SendMessageAsync(inputRequest, cancellationToken).ConfigureAwait(false); } public async ValueTask CaptureResponseAsync(IWorkflowContext context, ExternalInputResponse response, CancellationToken cancellationToken) { // _promptCount is tracked in this (Capture) executor's scope so reads and writes are coherent. // Each Capture invocation represents an attempt to satisfy the question; increment up front // and pass the value to PromptAsync explicitly so the retry/default decision is scope-independent. int promptCount = await this._promptCount.ReadAsync(context).ConfigureAwait(false) + 1; await this._promptCount.WriteAsync(context, promptCount).ConfigureAwait(false); FormulaValue? extractedValue = null; if (!response.HasMessages) { string unrecognizedResponse = this.Model.UnrecognizedPrompt is not null ? this.FormatPrompt(this.Model.UnrecognizedPrompt) : "Invalid response"; await context.AddEventAsync(new MessageActivityEvent(unrecognizedResponse.Trim()), cancellationToken).ConfigureAwait(false); } else { EntityExtractionResult entityResult = EntityExtractor.Parse(this.Model.Entity, string.Concat(response.Messages.Select(message => message.Text))); if (entityResult.IsValid) { extractedValue = entityResult.Value; } else { string invalidResponse = this.Model.InvalidPrompt is not null ? this.FormatPrompt(this.Model.InvalidPrompt) : "Invalid response"; await context.AddEventAsync(new MessageActivityEvent(invalidResponse.Trim()), cancellationToken).ConfigureAwait(false); } } if (extractedValue is null) { await this.PromptAsync(context, promptCount, cancellationToken).ConfigureAwait(false); } else { // Reset for any subsequent Question turn (e.g. via GotoAction re-entry) so the next attempt starts fresh. await this._promptCount.WriteAsync(context, 0).ConfigureAwait(false); bool autoSend = true; if (this.Model.ExtensionData?.Properties.TryGetValue("autoSend", out DataValue? autoSendValue) ?? false) { autoSend = autoSendValue.ToObject() is bool value && value; } if (autoSend) { string? workflowConversationId = context.GetWorkflowConversation(); if (workflowConversationId is not null) { // Input message expected to be defined when values have been extracted, but guard defensively. ChatMessage? input = response.Messages.LastOrDefault(); if (input is not null) { await agentProvider.CreateMessageAsync(workflowConversationId, input, cancellationToken).ConfigureAwait(false); await context.SetLastMessageAsync(input).ConfigureAwait(false); } } } await this.AssignAsync(Throw.IfNull(this.Model.Variable).Path, extractedValue, context).ConfigureAwait(false); await context.SendResultMessageAsync(this.Id, cancellationToken).ConfigureAwait(false); } } public async ValueTask CompleteAsync(IWorkflowContext context, ActionExecutorResult message, CancellationToken cancellationToken) { await context.RaiseCompletionEventAsync(this.Model, cancellationToken).ConfigureAwait(false); } private async ValueTask PromptAsync(IWorkflowContext context, int actualCount, CancellationToken cancellationToken) { long repeatCount = this.Evaluator.GetValue(this.Model.RepeatCount).Value; if (actualCount >= repeatCount) { DataValue defaultValue = DataValue.Blank(); if (this.Model.DefaultValue is not null) { ValueExpression defaultValueExpression = Throw.IfNull(this.Model.DefaultValue); defaultValue = this.Evaluator.GetValue(defaultValueExpression).Value; } await this.AssignAsync(Throw.IfNull(this.Model.Variable).Path, defaultValue.ToFormula(), context).ConfigureAwait(false); string defaultValueResponse = this.FormatPrompt(this.Model.DefaultValueResponse); await context.AddEventAsync(new MessageActivityEvent(defaultValueResponse.Trim()), cancellationToken).ConfigureAwait(false); // Reset for any subsequent Question turn (e.g. via GotoAction re-entry) so the next attempt starts fresh. await this._promptCount.WriteAsync(context, 0).ConfigureAwait(false); await context.SendResultMessageAsync(this.Id, cancellationToken).ConfigureAwait(false); } else { await context.SendResultMessageAsync(this.Id, result: true, cancellationToken).ConfigureAwait(false); } } private string FormatPrompt(ActivityTemplateBase? promptTemplate) { if (promptTemplate is not MessageActivityTemplate messageActivity) { return string.Empty; } return this.Engine.Format(messageActivity.Text).Trim(); } }