Files
agent-framework/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs
T
Peter Ibekwe 988ef6a50e .NET: Support InvokeFunctionTool for declarative workflows (#4014)
* Initial Implementation of InvokeFunctionTool

* Added unit test for InvokeFunctionTool executor.

* Implemented unit and integration tests for InvokeFunctionTool.

* Add sample for InvokeFunctionTool in declarative workflows.

* Remove unused sample and updated comments.

* Updating to official OM release with InvokeFunctionTool

* Fix formatting issues.

* Updated PowerFx version

* Update test fixture

* Cleanup - Removed unused method in InvokeFunctionToolExecutor

* Update test based on PR feedback.

* Update based on PR comments
2026-02-18 23:15:36 +00:00

626 lines
25 KiB
C#

// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics;
using System.Linq;
using Microsoft.Agents.AI.Workflows.Declarative.Events;
using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
using Microsoft.Agents.ObjectModel;
namespace Microsoft.Agents.AI.Workflows.Declarative.Interpreter;
internal sealed class WorkflowActionVisitor : DialogActionVisitor
{
private const string DefaultWorkflowId = "workflow";
internal static class Steps
{
public static string Root(AdaptiveDialog action) => $"{action.BeginDialog?.Id.Value ?? DefaultWorkflowId}_{nameof(Root)}";
public static string Root(string? actionId = null) => $"{actionId ?? DefaultWorkflowId}_{nameof(Root)}";
public static string Post(string actionId) => $"{actionId}_{nameof(Post)}";
public static string Restart(string actionId) => $"{actionId}_{nameof(Restart)}";
}
private readonly Executor _rootAction;
private readonly WorkflowModel<Func<object?, bool>> _workflowModel;
private readonly DeclarativeWorkflowOptions _workflowOptions;
private readonly WorkflowFormulaState _workflowState;
public WorkflowActionVisitor(
Executor rootAction,
WorkflowFormulaState state,
DeclarativeWorkflowOptions options)
{
this._rootAction = rootAction;
this._workflowModel = new WorkflowModel<Func<object?, bool>>((IModeledAction)rootAction);
this._workflowOptions = options;
this._workflowState = state;
}
public bool HasUnsupportedActions { get; private set; }
public Workflow Complete()
{
WorkflowModelBuilder builder = new(this._rootAction);
this._workflowModel.Build(builder);
// Apply telemetry if configured
if (this._workflowOptions.IsTelemetryEnabled)
{
builder.WorkflowBuilder.WithOpenTelemetry(
this._workflowOptions.ConfigureTelemetry,
this._workflowOptions.TelemetryActivitySource);
}
// Build final workflow
return builder.WorkflowBuilder.Build(validateOrphans: false);
}
protected override void Visit(ActionScope item)
{
this.Trace(item);
string parentId = GetParentId(item);
// Handle case where root element is its own parent
if (item.Id.Equals(parentId))
{
parentId = Steps.Root(parentId);
}
this.ContinueWith(new DelegateActionExecutor(item.Id.Value, this._workflowState), parentId, condition: null, CompletionHandler);
// Complete the action scope.
void CompletionHandler()
{
// No completion for root scope
if (this._workflowModel.GetDepth(item.Id.Value) > 1)
{
DelegateAction<ActionExecutorResult>? action = null;
ConditionGroupExecutor? conditionGroup = this._workflowModel.LocateParent<ConditionGroupExecutor>(parentId);
if (conditionGroup is not null)
{
action = conditionGroup.DoneAsync;
}
// Define post action for this scope
string completionId = this.ContinuationFor(item.Id.Value, action);
this._workflowModel.AddLinkFromPeer(item.Id.Value, completionId);
// Transition to post action of parent scope
this._workflowModel.AddLink(completionId, Steps.Post(parentId));
}
}
}
public override void VisitConditionItem(ConditionItem item)
{
this.Trace(item);
string parentId = GetParentId(item);
ConditionGroupExecutor? conditionGroup = this._workflowModel.LocateParent<ConditionGroupExecutor>(parentId);
if (conditionGroup is not null)
{
string stepId = ConditionGroupExecutor.Steps.Item(conditionGroup.Model, item);
this._workflowModel.AddNode(new DelegateActionExecutor(stepId, this._workflowState), parentId, CompletionHandler);
base.VisitConditionItem(item);
// Complete the condition item.
void CompletionHandler()
{
string completionId = this.ContinuationFor(stepId, conditionGroup.DoneAsync); // End items
this._workflowModel.AddLink(completionId, Steps.Post(conditionGroup.Id)); // Merge with parent scope
// Merge link when no action group is defined
if (!item.Actions.Any())
{
this._workflowModel.AddLink(stepId, completionId);
}
}
}
}
protected override void Visit(ConditionGroup item)
{
this.Trace(item);
ConditionGroupExecutor action = new(item, this._workflowState);
this.ContinueWith(action);
this.ContinuationFor(action.Id, action.ParentId);
string? lastConditionItemId = null;
foreach (ConditionItem conditionItem in item.Conditions)
{
// Create conditional link for conditional action
lastConditionItemId = ConditionGroupExecutor.Steps.Item(item, conditionItem);
this._workflowModel.AddLink(action.Id, lastConditionItemId, (result) => action.IsMatch(conditionItem, result));
conditionItem.Accept(this);
}
if (lastConditionItemId is not null)
{
// Create clean start for else action from prior conditions
this.RestartAfter(lastConditionItemId, action.Id);
}
if (item.ElseActions?.Actions.Length > 0)
{
// Create conditional link for else action
string stepId = ConditionGroupExecutor.Steps.Else(item);
this._workflowModel.AddLink(action.Id, stepId, action.IsElse);
}
else
{
string stepId = Steps.Post(action.Id);
this._workflowModel.AddLink(action.Id, stepId, action.IsElse);
}
}
protected override void Visit(GotoAction item)
{
this.Trace(item);
// Represent action with default executor
DefaultActionExecutor action = new(item, this._workflowState);
this.ContinueWith(action);
// Transition to target action
this._workflowModel.AddLink(action.Id, item.ActionId.Value);
// Define a clean-start to ensure "goto" is not a source for any edge
this.RestartAfter(action.Id, action.ParentId);
}
protected override void Visit(Foreach item)
{
this.Trace(item);
// Entry point for loop
ForeachExecutor action = new(item, this._workflowState);
string loopId = ForeachExecutor.Steps.Next(action.Id);
this.ContinueWith(action, condition: null, CompletionHandler);
// Transition to select the next item
this.ContinueWith(new DelegateActionExecutor(loopId, this._workflowState, action.TakeNextAsync), action.Id);
// Transition to post action if no more items
string continuationId = this.ContinuationFor(action.Id, action.ParentId);
this._workflowModel.AddLink(loopId, continuationId, (_) => !action.HasValue);
// Transition to start of inner actions if there is a current item
string startId = ForeachExecutor.Steps.Start(action.Id);
this._workflowModel.AddNode(new DelegateActionExecutor(startId, this._workflowState), action.Id);
this._workflowModel.AddLink(loopId, startId, (_) => action.HasValue);
void CompletionHandler()
{
// Transition to end of inner actions
string endActionsId = ForeachExecutor.Steps.End(action.Id);
this.ContinueWith(new DelegateActionExecutor(endActionsId, this._workflowState, action.CompleteAsync), action.Id);
// Transition to select the next item
this._workflowModel.AddLink(endActionsId, loopId);
}
}
protected override void Visit(BreakLoop item)
{
this.Trace(item);
// Locate the nearest "Foreach" loop that contains this action
ForeachExecutor? loopAction = this._workflowModel.LocateParent<ForeachExecutor>(item.GetParentId());
// Skip action if its not contained a loop
if (loopAction is not null)
{
// Represent action with default executor
DefaultActionExecutor action = new(item, this._workflowState);
this.ContinueWith(action);
// Transition to post action
this._workflowModel.AddLink(action.Id, Steps.Post(loopAction.Id));
// Define a clean-start to ensure "break" is not a source for any edge
this.RestartAfter(action.Id, action.ParentId);
}
}
protected override void Visit(ContinueLoop item)
{
this.Trace(item);
// Locate the nearest "Foreach" loop that contains this action
ForeachExecutor? loopAction = this._workflowModel.LocateParent<ForeachExecutor>(item.GetParentId());
// Skip action if its not contained a loop
if (loopAction is not null)
{
// Represent action with default executor
DefaultActionExecutor action = new(item, this._workflowState);
this.ContinueWith(action);
// Transition to select the next item
this._workflowModel.AddLink(action.Id, ForeachExecutor.Steps.Next(loopAction.Id));
// Define a clean-start to ensure "continue" is not a source for any edge
this.RestartAfter(action.Id, action.ParentId);
}
}
protected override void Visit(Question item)
{
this.Trace(item);
// Entry point for question
QuestionExecutor action = new(item, this._workflowOptions.AgentProvider, this._workflowState);
this.ContinueWith(action);
// Transition to post action if complete
string postId = Steps.Post(action.Id);
this._workflowModel.AddLink(action.Id, postId, QuestionExecutor.IsComplete);
// Perpare for input request if not complete
string prepareId = QuestionExecutor.Steps.Prepare(action.Id);
this.ContinueWith(new DelegateActionExecutor(prepareId, this._workflowState, action.PrepareResponseAsync, emitResult: false), action.ParentId, message => !QuestionExecutor.IsComplete(message));
// Define input action
string inputId = QuestionExecutor.Steps.Input(action.Id);
RequestPortAction inputPort = new(RequestPort.Create<ExternalInputRequest, ExternalInputResponse>(inputId));
this._workflowModel.AddNode(inputPort, action.ParentId);
this._workflowModel.AddLinkFromPeer(action.ParentId, inputId);
// Capture input response
string captureId = QuestionExecutor.Steps.Capture(action.Id);
this.ContinueWith(new DelegateActionExecutor<ExternalInputResponse>(captureId, this._workflowState, action.CaptureResponseAsync, emitResult: false), action.ParentId);
// Transition to post action if complete
this.ContinueWith(new DelegateActionExecutor(postId, this._workflowState, action.CompleteAsync), action.ParentId, QuestionExecutor.IsComplete);
// Transition to prepare action if not complete
this._workflowModel.AddLink(captureId, prepareId, message => !QuestionExecutor.IsComplete(message));
}
protected override void Visit(RequestExternalInput item)
{
this.Trace(item);
RequestExternalInputExecutor action = new(item, this._workflowOptions.AgentProvider, this._workflowState);
this.ContinueWith(action);
// Define input action
string inputId = RequestExternalInputExecutor.Steps.Input(action.Id);
RequestPortAction inputPort = new(RequestPort.Create<ExternalInputRequest, ExternalInputResponse>(inputId));
this._workflowModel.AddNode(inputPort, action.ParentId);
this._workflowModel.AddLinkFromPeer(action.ParentId, inputId);
// Capture input response
string captureId = RequestExternalInputExecutor.Steps.Capture(action.Id);
this.ContinueWith(new DelegateActionExecutor<ExternalInputResponse>(captureId, this._workflowState, action.CaptureResponseAsync), action.ParentId);
}
protected override void Visit(EndDialog item)
{
this.Trace(item);
// Represent action with default executor
DefaultActionExecutor action = new(item, this._workflowState);
this.ContinueWith(action);
// Define a clean-start to ensure "end" is not a source for any edge
this.RestartAfter(item.Id.Value, action.ParentId);
}
protected override void Visit(EndConversation item)
{
this.Trace(item);
// Represent action with default executor
DefaultActionExecutor action = new(item, this._workflowState);
this.ContinueWith(action);
// Define a clean-start to ensure "end" is not a source for any edge
this.RestartAfter(action.Id, action.ParentId);
}
protected override void Visit(CancelAllDialogs item)
{
this.Trace(item);
// Represent action with default executor
DefaultActionExecutor action = new(item, this._workflowState);
this.ContinueWith(action);
// Define a clean-start to ensure "end" is not a source for any edge
this.RestartAfter(item.Id.Value, action.ParentId);
}
protected override void Visit(CancelDialog item)
{
this.Trace(item);
// Represent action with default executor
DefaultActionExecutor action = new(item, this._workflowState);
this.ContinueWith(action);
// Define a clean-start to ensure "end" is not a source for any edge
this.RestartAfter(action.Id, action.ParentId);
}
protected override void Visit(CreateConversation item)
{
this.Trace(item);
this.ContinueWith(new CreateConversationExecutor(item, this._workflowOptions.AgentProvider, this._workflowState));
}
protected override void Visit(AddConversationMessage item)
{
this.Trace(item);
this.ContinueWith(new AddConversationMessageExecutor(item, this._workflowOptions.AgentProvider, this._workflowState));
}
protected override void Visit(CopyConversationMessages item)
{
this.Trace(item);
this.ContinueWith(new CopyConversationMessagesExecutor(item, this._workflowOptions.AgentProvider, this._workflowState));
}
protected override void Visit(InvokeAzureAgent item)
{
this.Trace(item);
// Entry point to invoke agent
InvokeAzureAgentExecutor action = new(item, this._workflowOptions.AgentProvider, this._workflowState);
this.ContinueWith(action);
// Transition to post action if complete
string postId = Steps.Post(action.Id);
this._workflowModel.AddLink(action.Id, postId, InvokeAzureAgentExecutor.RequiresNothing);
// Define request-port for function calling action
string externalInputPortId = InvokeAzureAgentExecutor.Steps.ExternalInput(action.Id);
RequestPortAction externalInputPort = new(RequestPort.Create<ExternalInputRequest, ExternalInputResponse>(externalInputPortId));
this._workflowModel.AddNode(externalInputPort, action.ParentId);
this._workflowModel.AddLink(action.Id, externalInputPortId, InvokeAzureAgentExecutor.RequiresInput);
// Request ports always transitions to resume
string resumeId = InvokeAzureAgentExecutor.Steps.Resume(action.Id);
this._workflowModel.AddNode(new DelegateActionExecutor<ExternalInputResponse>(resumeId, this._workflowState, action.ResumeAsync, emitResult: false), action.ParentId);
this._workflowModel.AddLink(externalInputPortId, resumeId);
// Transition to post action if complete
this._workflowModel.AddLink(resumeId, postId, InvokeAzureAgentExecutor.RequiresNothing);
// Transition to request port if more input is required
this._workflowModel.AddLink(resumeId, externalInputPortId, InvokeAzureAgentExecutor.RequiresInput);
// Define post action
this._workflowModel.AddNode(new DelegateActionExecutor(postId, this._workflowState, action.CompleteAsync), action.ParentId);
}
protected override void Visit(InvokeFunctionTool item)
{
this.Trace(item);
// Entry point to invoke function tool - always yields for external execution
InvokeFunctionToolExecutor action = new(item, this._workflowOptions.AgentProvider, this._workflowState);
this.ContinueWith(action);
// Define request-port for function tool invocation (always requires external input)
string externalInputPortId = InvokeFunctionToolExecutor.Steps.ExternalInput(action.Id);
RequestPortAction externalInputPort = new(RequestPort.Create<ExternalInputRequest, ExternalInputResponse>(externalInputPortId));
this._workflowModel.AddNode(externalInputPort, action.ParentId);
this._workflowModel.AddLinkFromPeer(action.ParentId, externalInputPortId);
// Capture response when external input is received
string resumeId = InvokeFunctionToolExecutor.Steps.Resume(action.Id);
this.ContinueWith(
new DelegateActionExecutor<ExternalInputResponse>(resumeId, this._workflowState, action.CaptureResponseAsync),
action.ParentId);
}
protected override void Visit(InvokeAzureResponse item)
{
this.NotSupported(item);
}
protected override void Visit(RetrieveConversationMessage item)
{
this.Trace(item);
this.ContinueWith(new RetrieveConversationMessageExecutor(item, this._workflowOptions.AgentProvider, this._workflowState));
}
protected override void Visit(RetrieveConversationMessages item)
{
this.Trace(item);
this.ContinueWith(new RetrieveConversationMessagesExecutor(item, this._workflowOptions.AgentProvider, this._workflowState));
}
protected override void Visit(SetVariable item)
{
this.Trace(item);
this.ContinueWith(new SetVariableExecutor(item, this._workflowState));
}
protected override void Visit(SetMultipleVariables item)
{
this.Trace(item);
this.ContinueWith(new SetMultipleVariablesExecutor(item, this._workflowState));
}
protected override void Visit(SetTextVariable item)
{
this.Trace(item);
this.ContinueWith(new SetTextVariableExecutor(item, this._workflowState));
}
protected override void Visit(ClearAllVariables item)
{
this.Trace(item);
this.ContinueWith(new ClearAllVariablesExecutor(item, this._workflowState));
}
protected override void Visit(ResetVariable item)
{
this.Trace(item);
this.ContinueWith(new ResetVariableExecutor(item, this._workflowState));
}
protected override void Visit(EditTable item)
{
this.Trace(item);
this.ContinueWith(new EditTableExecutor(item, this._workflowState));
}
protected override void Visit(EditTableV2 item)
{
this.Trace(item);
this.ContinueWith(new EditTableV2Executor(item, this._workflowState));
}
protected override void Visit(ParseValue item)
{
this.Trace(item);
this.ContinueWith(new ParseValueExecutor(item, this._workflowState));
}
protected override void Visit(SendActivity item)
{
this.Trace(item);
this.ContinueWith(new SendActivityExecutor(item, this._workflowState));
}
#region Not supported
protected override void Visit(AnswerQuestionWithAI item) => this.NotSupported(item);
protected override void Visit(DeleteActivity item) => this.NotSupported(item);
protected override void Visit(GetActivityMembers item) => this.NotSupported(item);
protected override void Visit(UpdateActivity item) => this.NotSupported(item);
protected override void Visit(ActivateExternalTrigger item) => this.NotSupported(item);
protected override void Visit(DisableTrigger item) => this.NotSupported(item);
protected override void Visit(WaitForConnectorTrigger item) => this.NotSupported(item);
protected override void Visit(InvokeConnectorAction item) => this.NotSupported(item);
protected override void Visit(InvokeCustomModelAction item) => this.NotSupported(item);
protected override void Visit(InvokeFlowAction item) => this.NotSupported(item);
protected override void Visit(InvokeAIBuilderModelAction item) => this.NotSupported(item);
protected override void Visit(InvokeSkillAction item) => this.NotSupported(item);
protected override void Visit(AdaptiveCardPrompt item) => this.NotSupported(item);
protected override void Visit(CSATQuestion item) => this.NotSupported(item);
protected override void Visit(OAuthInput item) => this.NotSupported(item);
protected override void Visit(BeginDialog item) => this.NotSupported(item);
protected override void Visit(UnknownDialogAction item) => this.NotSupported(item);
protected override void Visit(RepeatDialog item) => this.NotSupported(item);
protected override void Visit(ReplaceDialog item) => this.NotSupported(item);
protected override void Visit(EmitEvent item) => this.NotSupported(item);
protected override void Visit(GetConversationMembers item) => this.NotSupported(item);
protected override void Visit(HttpRequestAction item) => this.NotSupported(item);
protected override void Visit(RecognizeIntent item) => this.NotSupported(item);
protected override void Visit(TransferConversation item) => this.NotSupported(item);
protected override void Visit(TransferConversationV2 item) => this.NotSupported(item);
protected override void Visit(SignOutUser item) => this.NotSupported(item);
protected override void Visit(LogCustomTelemetryEvent item) => this.NotSupported(item);
protected override void Visit(DisconnectedNodeContainer item) => this.NotSupported(item);
protected override void Visit(CreateSearchQuery item) => this.NotSupported(item);
protected override void Visit(SearchKnowledgeSources item) => this.NotSupported(item);
protected override void Visit(SearchAndSummarizeWithCustomModel item) => this.NotSupported(item);
protected override void Visit(SearchAndSummarizeContent item) => this.NotSupported(item);
#endregion
private void ContinueWith(
DeclarativeActionExecutor executor,
Func<object?, bool>? condition = null,
Action? completionHandler = null)
{
executor.Logger = this._workflowOptions.LoggerFactory.CreateLogger(executor.Id);
this.ContinueWith(executor, executor.ParentId, condition, completionHandler);
}
private void ContinueWith(
IModeledAction action,
string parentId,
Func<object?, bool>? condition = null,
Action? completionHandler = null)
{
this._workflowModel.AddNode(action, parentId, completionHandler);
this._workflowModel.AddLinkFromPeer(parentId, action.Id, condition);
}
private string ContinuationFor(string parentId, DelegateAction<ActionExecutorResult>? stepAction = null) => this.ContinuationFor(parentId, parentId, stepAction);
private string ContinuationFor(string actionId, string parentId, DelegateAction<ActionExecutorResult>? stepAction = null)
{
actionId = Steps.Post(actionId);
this._workflowModel.AddNode(new DelegateActionExecutor(actionId, this._workflowState, stepAction), parentId);
return actionId;
}
private void RestartAfter(string actionId, string parentId) =>
this._workflowModel.AddNode(new DelegateActionExecutor(Steps.Restart(actionId), this._workflowState), parentId);
private static string GetParentId(BotElement item) =>
item.GetParentId() ??
throw new DeclarativeModelException($"Missing parent ID for action element: {item.GetId()} [{item.GetType().Name}].");
private void NotSupported(DialogAction item)
{
Debug.WriteLine($"> UNKNOWN: {new string('\t', this._workflowModel.GetDepth(item.GetParentId()))}{FormatItem(item)} => {FormatParent(item)}");
this.HasUnsupportedActions = true;
}
private void Trace(BotElement item) =>
Debug.WriteLine($"> VISIT: {new string('\t', this._workflowModel.GetDepth(item.GetParentId()))}{FormatItem(item)} => {FormatParent(item)}");
private void Trace(DialogAction item)
{
string? parentId = item.GetParentId();
if (item.Id.Equals(parentId ?? string.Empty))
{
parentId = Steps.Root(parentId);
}
Debug.WriteLine($"> VISIT: {new string('\t', this._workflowModel.GetDepth(parentId))}{FormatItem(item)} => {FormatParent(item)}");
}
private static string FormatItem(BotElement element) => $"{element.GetType().Name} ({element.GetId()})";
private static string FormatParent(BotElement element) =>
element.Parent is null ?
throw new DeclarativeModelException($"Undefined parent for {element.GetType().Name} that is member of {element.GetId()}.") :
$"{element.Parent.GetType().Name} ({element.GetParentId()})";
}