// 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);
}
}
}