// Copyright (c) Microsoft. All rights reserved. using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.AI.Workflows.Declarative.Extensions; using Microsoft.Agents.AI.Workflows.Declarative.Kit; using Microsoft.Agents.AI.Workflows.Declarative.PowerFx; using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.Workflows.Declarative.Interpreter; /// /// The root executor for a declarative workflow. /// /// /// In addition to the strongly-typed route inherited from /// , this executor also accepts , /// , of , /// [], and so that the workflow /// satisfies . This makes the workflow /// usable both for direct Run.SendMessageAsync(input) invocations and for hosting /// via . /// /// /// Each non- input drives the declarative graph forward /// immediately. The host's arrives after the message batch and /// is treated as a no-op because the inbound message has already been processed. /// External responses (HITL function results) bypass the start executor entirely /// (they are routed via WorkflowSession.SendResponseAsync to request-info /// executors), so the start executor only ever sees a single inbound batch per turn. /// /// internal sealed class DeclarativeWorkflowExecutor( string workflowId, DeclarativeWorkflowOptions options, WorkflowFormulaState state, Func inputTransform) : Executor(workflowId), IResettableExecutor, IModeledAction where TInput : notnull { /// public ValueTask ResetAsync() { return default; } /// [SendsMessage(typeof(ActionExecutorResult))] public override ValueTask HandleAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken = default) { ChatMessage input = inputTransform.Invoke(message); return this.AdvanceAsync(input, context, cancellationToken); } /// protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) { // Inherit the TInput route + method/class attributes (e.g. SendsMessage on HandleAsync). ProtocolBuilder result = base.ConfigureProtocol(protocolBuilder); // Add the chat-protocol input shapes so the workflow satisfies IsChatProtocol // and can be hosted via AsAIAgent. Skip any shape that already matches TInput // (the inherited route handles that case via inputTransform). return result.ConfigureRoutes(this.ConfigureChatProtocolRoutes) .SendsMessage(); } private void ConfigureChatProtocolRoutes(RouteBuilder routeBuilder) { Type tInput = typeof(TInput); // Skip an exact-type match because RouteBuilder.AddHandler throws on duplicate // registrations for the same message type. Equality (not IsAssignableFrom) is // also what ChatProtocolExtensions.IsChatProtocol checks, so always registering // IEnumerable when TInput is broader (e.g. object) keeps the // workflow chat-protocol-compliant. if (tInput != typeof(string)) { routeBuilder.AddHandler(this.HandleStringAsync); } if (tInput != typeof(ChatMessage)) { routeBuilder.AddHandler(this.HandleChatMessageAsync); } if (tInput != typeof(IEnumerable)) { routeBuilder.AddHandler>(this.HandleChatMessagesAsync); } if (tInput != typeof(ChatMessage[])) { routeBuilder.AddHandler(this.HandleChatMessageArrayAsync); } if (tInput != typeof(TurnToken)) { routeBuilder.AddHandler(this.HandleTurnTokenAsync); } } private ValueTask HandleStringAsync(string message, IWorkflowContext context, CancellationToken cancellationToken) { return this.AdvanceAsync(new ChatMessage(ChatRole.User, message), context, cancellationToken); } private ValueTask HandleChatMessageAsync(ChatMessage message, IWorkflowContext context, CancellationToken cancellationToken) { return this.AdvanceAsync(message, context, cancellationToken); } private async ValueTask HandleChatMessagesAsync(IEnumerable messages, IWorkflowContext context, CancellationToken cancellationToken) { var list = messages as IList ?? new List(messages); if (list.Count == 0) { return; } for (int i = 0; i < list.Count; i++) { await this.AdvanceAsync(list[i], context, cancellationToken, finalizeTurn: i == list.Count - 1).ConfigureAwait(false); } } private async ValueTask HandleChatMessageArrayAsync(ChatMessage[] messages, IWorkflowContext context, CancellationToken cancellationToken) { if (messages.Length == 0) { return; } for (int i = 0; i < messages.Length; i++) { await this.AdvanceAsync(messages[i], context, cancellationToken, finalizeTurn: i == messages.Length - 1).ConfigureAwait(false); } } // The host sends a TurnToken after the message batch; the message has already // driven the graph forward, so we treat the token as a no-op here. private ValueTask HandleTurnTokenAsync(TurnToken token, IWorkflowContext context, CancellationToken cancellationToken) { return default; } private async ValueTask AdvanceAsync(ChatMessage input, IWorkflowContext context, CancellationToken cancellationToken, bool finalizeTurn = true) { // No state to restore if we're starting from the beginning. state.SetInitialized(); DeclarativeWorkflowContext declarativeContext = new(context, state); // Conversation id resolution prefers state already persisted by a prior turn, // so multi-turn invocations reuse the same backend conversation rather than // creating a fresh one each turn. string? conversationId = declarativeContext.GetWorkflowConversation(); if (string.IsNullOrWhiteSpace(conversationId)) { conversationId = options.ConversationId; } bool conversationCreated = false; if (string.IsNullOrWhiteSpace(conversationId)) { conversationId = await options.AgentProvider.CreateConversationAsync(cancellationToken).ConfigureAwait(false); conversationCreated = true; } if (conversationCreated || !string.Equals(declarativeContext.GetWorkflowConversation(), conversationId, StringComparison.Ordinal)) { await declarativeContext.QueueConversationUpdateAsync(conversationId!, isExternal: true, cancellationToken).ConfigureAwait(false); } ChatMessage inputMessage = await options.AgentProvider.CreateMessageAsync(conversationId!, input, cancellationToken).ConfigureAwait(false); // Use the original input for System.LastMessage to ensure Text is preserved (the // service may strip text on round-trip), but substitute server-side media references // (e.g., HostedFileContent) so subsequent actions don't re-upload large blobs. await declarativeContext.SetLastMessageAsync(input.MergeForLastMessage(inputMessage)).ConfigureAwait(false); if (finalizeTurn) { await context.SendResultMessageAsync(this.Id, cancellationToken).ConfigureAwait(false); } } }