.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
This commit is contained in:
Peter Ibekwe
2026-02-18 15:15:36 -08:00
committed by GitHub
Unverified
parent 7cee839982
commit 988ef6a50e
14 changed files with 1175 additions and 5 deletions
+4 -4
View File
@@ -111,10 +111,10 @@
<!-- Identity -->
<PackageVersion Include="Microsoft.Identity.Client.Extensions.Msal" Version="4.78.0" />
<!-- Workflows -->
<PackageVersion Include="Microsoft.Agents.ObjectModel" Version="2026.2.2.1" />
<PackageVersion Include="Microsoft.Agents.ObjectModel.Json" Version="2026.2.2.1" />
<PackageVersion Include="Microsoft.Agents.ObjectModel.PowerFx" Version="2026.2.2.1" />
<PackageVersion Include="Microsoft.PowerFx.Interpreter" Version="1.5.0-build.20251008-1002" />
<PackageVersion Include="Microsoft.Agents.ObjectModel" Version="2026.2.3.1" />
<PackageVersion Include="Microsoft.Agents.ObjectModel.Json" Version="2026.2.3.1" />
<PackageVersion Include="Microsoft.Agents.ObjectModel.PowerFx" Version="2026.2.3.1" />
<PackageVersion Include="Microsoft.PowerFx.Interpreter" Version="1.8.1" />
<!-- Durable Task -->
<PackageVersion Include="Microsoft.DurableTask.Client" Version="1.18.0" />
<PackageVersion Include="Microsoft.DurableTask.Client.AzureManaged" Version="1.18.0" />
+1
View File
@@ -215,6 +215,7 @@
<Project Path="samples/GettingStarted/Workflows/Declarative/Marketing/Marketing.csproj" />
<Project Path="samples/GettingStarted/Workflows/Declarative/StudentTeacher/StudentTeacher.csproj" />
<Project Path="samples/GettingStarted/Workflows/Declarative/ToolApproval/ToolApproval.csproj" />
<Project Path="samples/GettingStarted/Workflows/Declarative/InvokeFunctionTool/InvokeFunctionTool.csproj" />
</Folder>
<Folder Name="/Samples/GettingStarted/Workflows/Declarative/Examples/">
<File Path="../workflow-samples/CustomerSupport.yaml" />
@@ -0,0 +1,38 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<PropertyGroup>
<InjectIsExternalInitOnLegacy>true</InjectIsExternalInitOnLegacy>
<InjectSharedFoundryAgents>true</InjectSharedFoundryAgents>
<InjectSharedWorkflowsExecution>true</InjectSharedWorkflowsExecution>
<InjectSharedWorkflowsSettings>true</InjectSharedWorkflowsSettings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Logging" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows.Declarative\Microsoft.Agents.AI.Workflows.Declarative.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows.Declarative.AzureAI\Microsoft.Agents.AI.Workflows.Declarative.AzureAI.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="InvokeFunctionTool.yaml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
@@ -0,0 +1,55 @@
#
# This workflow demonstrates using InvokeFunctionTool to call functions directly
# from the workflow without going through an AI agent first.
#
# InvokeFunctionTool allows workflows to:
# - Pre-fetch data before calling an AI agent
# - Execute operations directly without AI involvement
# - Store function results in workflow variables for later use
#
# Example input:
# What are the specials in the menu?
#
kind: Workflow
trigger:
kind: OnConversationStart
id: workflow_invoke_function_tool_demo
actions:
# Invoke GetSpecials function to get today's specials directly from the workflow
- kind: InvokeFunctionTool
id: invoke_get_specials
conversationId: =System.ConversationId
requireApproval: true
functionName: GetSpecials
output:
autoSend: true
result: Local.Specials
messages: Local.FunctionMessage
# Display a message showing we retrieved the specials
- kind: SendMessage
id: show_specials_intro
message: "Today's specials have been retrieved. Here they are: {Local.Specials}"
# Now use an agent to format and present the specials to the user
- kind: InvokeAzureAgent
id: invoke_menu_agent
conversationId: =System.ConversationId
agent:
name: FunctionMenuAgent
input:
messages: =UserMessage("Please describe today's specials in an appealing way.")
output:
messages: Local.AgentResponse
# Allow the user to ask follow-up questions in a loop
- kind: InvokeAzureAgent
id: invoke_followup
conversationId: =System.ConversationId
agent:
name: FunctionMenuAgent
input:
externalLoop:
when: =Upper(System.LastMessage.Text) <> "EXIT"
@@ -0,0 +1,85 @@
// Copyright (c) Microsoft. All rights reserved.
using System.ComponentModel;
namespace Demo.Workflows.Declarative.InvokeFunctionTool;
#pragma warning disable CA1822 // Mark members as static
/// <summary>
/// Plugin providing menu-related functions that can be invoked directly by the workflow
/// using the InvokeFunctionTool action.
/// </summary>
public sealed class MenuPlugin
{
[Description("Provides a list items on the menu.")]
public MenuItem[] GetMenu()
{
return s_menuItems;
}
[Description("Provides a list of specials from the menu.")]
public MenuItem[] GetSpecials()
{
return [.. s_menuItems.Where(i => i.IsSpecial)];
}
[Description("Provides the price of the requested menu item.")]
public float? GetItemPrice(
[Description("The name of the menu item.")]
string name)
{
return s_menuItems.FirstOrDefault(i => i.Name.Equals(name, StringComparison.OrdinalIgnoreCase))?.Price;
}
private static readonly MenuItem[] s_menuItems =
[
new()
{
Category = "Soup",
Name = "Clam Chowder",
Price = 4.95f,
IsSpecial = true,
},
new()
{
Category = "Soup",
Name = "Tomato Soup",
Price = 4.95f,
IsSpecial = false,
},
new()
{
Category = "Salad",
Name = "Cobb Salad",
Price = 9.99f,
},
new()
{
Category = "Salad",
Name = "House Salad",
Price = 4.95f,
},
new()
{
Category = "Drink",
Name = "Chai Tea",
Price = 2.95f,
IsSpecial = true,
},
new()
{
Category = "Drink",
Name = "Soda",
Price = 1.95f,
},
];
public sealed class MenuItem
{
public string Category { get; init; } = string.Empty;
public string Name { get; init; } = string.Empty;
public float Price { get; init; }
public bool IsSpecial { get; init; }
}
}
@@ -0,0 +1,88 @@
// Copyright (c) Microsoft. All rights reserved.
using Azure.AI.Projects;
using Azure.AI.Projects.OpenAI;
using Azure.Identity;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Configuration;
using OpenAI.Responses;
using Shared.Foundry;
using Shared.Workflows;
namespace Demo.Workflows.Declarative.InvokeFunctionTool;
/// <summary>
/// Demonstrate a workflow that uses InvokeFunctionTool to call functions directly
/// from the workflow without going through an AI agent first.
/// </summary>
/// <remarks>
/// The InvokeFunctionTool action allows workflows to invoke function tools directly,
/// enabling pre-fetching of data or executing operations before calling an AI agent.
/// See the README.md file in the parent folder (../README.md) for detailed
/// information about the configuration required to run this sample.
/// </remarks>
internal sealed class Program
{
public static async Task Main(string[] args)
{
// Initialize configuration
IConfiguration configuration = Application.InitializeConfig();
Uri foundryEndpoint = new(configuration.GetValue(Application.Settings.FoundryEndpoint));
// Create the menu plugin with functions that can be invoked directly by the workflow
MenuPlugin menuPlugin = new();
AIFunction[] functions =
[
AIFunctionFactory.Create(menuPlugin.GetMenu),
AIFunctionFactory.Create(menuPlugin.GetSpecials),
AIFunctionFactory.Create(menuPlugin.GetItemPrice),
];
// Ensure sample agent exists in Foundry
await CreateAgentAsync(foundryEndpoint, configuration);
// Get input from command line or console
string workflowInput = Application.GetInput(args);
// Create the workflow factory.
WorkflowFactory workflowFactory = new("InvokeFunctionTool.yaml", foundryEndpoint);
// Execute the workflow
WorkflowRunner runner = new(functions) { UseJsonCheckpoints = true };
await runner.ExecuteAsync(workflowFactory.CreateWorkflow, workflowInput);
}
private static async Task CreateAgentAsync(Uri foundryEndpoint, IConfiguration configuration)
{
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
AIProjectClient aiProjectClient = new(foundryEndpoint, new DefaultAzureCredential());
await aiProjectClient.CreateAgentAsync(
agentName: "FunctionMenuAgent",
agentDefinition: DefineMenuAgent(configuration, []), // Create Agent with no function tool in the definition.
agentDescription: "Provides information about the restaurant menu");
}
private static PromptAgentDefinition DefineMenuAgent(IConfiguration configuration, AIFunction[] functions)
{
PromptAgentDefinition agentDefinition =
new(configuration.GetValue(Application.Settings.FoundryModelMini))
{
Instructions =
"""
Answer the users questions about the menu.
Use the information provided in the conversation history to answer questions.
If the information is already available in the conversation, use it directly.
For questions or input that do not require searching the documentation, inform the
user that you can only answer questions about what's on the menu.
"""
};
foreach (AIFunction function in functions)
{
agentDefinition.Tools.Add(function.AsOpenAIResponseTool());
}
return agentDefinition;
}
}
@@ -390,6 +390,27 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor
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);
@@ -365,6 +365,8 @@ internal sealed class WorkflowTemplateVisitor : DialogActionVisitor
#region Not supported
protected override void Visit(InvokeFunctionTool item) => this.NotSupported(item);
protected override void Visit(AnswerQuestionWithAI item) => this.NotSupported(item);
protected override void Visit(DeleteActivity item) => this.NotSupported(item);
@@ -0,0 +1,312 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
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.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
/// <summary>
/// Executor for the <see cref="InvokeFunctionTool"/> action.
/// This executor yields to the caller for function execution and resumes when results are provided.
/// </summary>
internal sealed class InvokeFunctionToolExecutor(
InvokeFunctionTool model,
ResponseAgentProvider agentProvider,
WorkflowFormulaState state) :
DeclarativeActionExecutor<InvokeFunctionTool>(model, state)
{
/// <summary>
/// Step identifiers for the function tool invocation workflow.
/// </summary>
public static class Steps
{
/// <summary>
/// Step for waiting for external input (function result).
/// </summary>
public static string ExternalInput(string id) => $"{id}_{nameof(ExternalInput)}";
/// <summary>
/// Step for resuming after receiving function result.
/// </summary>
public static string Resume(string id) => $"{id}_{nameof(Resume)}";
}
/// <inheritdoc/>
protected override bool EmitResultEvent => false;
/// <inheritdoc/>
protected override bool IsDiscreteAction => false;
/// <inheritdoc/>
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
{
string functionName = this.GetFunctionName();
bool requireApproval = this.GetRequireApproval();
Dictionary<string, object?>? arguments = this.GetArguments();
// Create the function call content to send to the caller
FunctionCallContent functionCall = new(
callId: this.Id,
name: functionName,
arguments: arguments);
// Build the response with the function call request
ChatMessage requestMessage = new(ChatRole.Tool, [functionCall]);
// If approval is required, add user input request content
if (requireApproval)
{
requestMessage.Contents.Add(new FunctionApprovalRequestContent(this.Id, functionCall));
}
AgentResponse agentResponse = new([requestMessage]);
// Yield to the caller - workflow halts here until external input is received
ExternalInputRequest inputRequest = new(agentResponse);
await context.SendMessageAsync(inputRequest, cancellationToken).ConfigureAwait(false);
return default;
}
/// <summary>
/// Captures the function result and stores in output variables.
/// </summary>
/// <param name="context">The workflow context.</param>
/// <param name="response">The external input response containing the function result.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
public async ValueTask CaptureResponseAsync(
IWorkflowContext context,
ExternalInputResponse response,
CancellationToken cancellationToken)
{
bool autoSend = this.GetAutoSendValue();
string? conversationId = this.GetConversationId();
// Extract function results from the response
IEnumerable<FunctionResultContent> functionResults = response.Messages
.SelectMany(m => m.Contents)
.OfType<FunctionResultContent>();
FunctionResultContent? matchingResult = functionResults
.FirstOrDefault(r => r.CallId == this.Id);
if (matchingResult is not null)
{
// Store the result in output variable
await this.AssignResultAsync(context, matchingResult).ConfigureAwait(false);
// Auto-send the result if configured
if (autoSend)
{
AgentResponse resultResponse = new([new ChatMessage(ChatRole.Tool, [matchingResult])]);
await context.AddEventAsync(new AgentResponseEvent(this.Id, resultResponse), cancellationToken).ConfigureAwait(false);
}
}
// Store messages if output path is configured
if (this.Model.Output?.Messages is not null)
{
await this.AssignAsync(this.Model.Output.Messages?.Path, response.Messages.ToFormula(), context).ConfigureAwait(false);
}
// Add messages to conversation if conversationId is provided
// Note: We transform messages containing FunctionResultContent or FunctionCallContent
// to assistant text messages because workflow-generated CallIds don't correspond to
// actual AI-generated tool calls and would be rejected by the API.
if (conversationId is not null)
{
foreach (ChatMessage message in TransformConversationMessages(response.Messages))
{
await agentProvider.CreateMessageAsync(conversationId, message, cancellationToken).ConfigureAwait(false);
}
}
// Completes the action after processing the function result.
await context.RaiseCompletionEventAsync(this.Model, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Transforms messages containing function-related content to assistant text messages.
/// Messages with FunctionResultContent are converted to assistant messages with the result as text.
/// Messages with only FunctionCallContent are excluded as they have no informational value.
/// </summary>
private static IEnumerable<ChatMessage> TransformConversationMessages(IEnumerable<ChatMessage> messages)
{
foreach (ChatMessage message in messages)
{
// Check if message contains function content
bool hasFunctionResult = message.Contents.OfType<FunctionResultContent>().Any();
bool hasFunctionCall = message.Contents.OfType<FunctionCallContent>().Any();
if (hasFunctionResult)
{
// Convert function results to assistant text message
List<AIContent> updatedContents = [];
foreach (AIContent content in message.Contents)
{
if (content is FunctionResultContent functionResult)
{
string? resultText = functionResult.Result?.ToString();
if (!string.IsNullOrEmpty(resultText))
{
updatedContents.Add(new TextContent($"[Function {functionResult.CallId} result]: {resultText}"));
}
}
else if (content is not FunctionCallContent)
{
// Keep non-function content as-is
updatedContents.Add(content);
}
}
if (updatedContents.Count > 0)
{
yield return new ChatMessage(ChatRole.Assistant, updatedContents);
}
}
else if (!hasFunctionCall)
{
// Pass through messages without function content
yield return message;
}
}
}
private async ValueTask AssignResultAsync(IWorkflowContext context, FunctionResultContent result)
{
if (this.Model.Output?.Result is null)
{
return;
}
object? resultValue = result.Result;
// Attempt to parse as JSON if it's a string
if (resultValue is string jsonString)
{
try
{
using JsonDocument jsonDocument = JsonDocument.Parse(jsonString);
// Handle different JSON value kinds
object? parsedValue = jsonDocument.RootElement.ValueKind switch
{
JsonValueKind.Object => jsonDocument.ParseRecord(VariableType.RecordType),
JsonValueKind.Array => jsonDocument.ParseList(CreateListTypeFromJson(jsonDocument.RootElement)),
JsonValueKind.String => jsonDocument.RootElement.GetString(),
JsonValueKind.Number => jsonDocument.RootElement.TryGetInt64(out long l) ? l : jsonDocument.RootElement.GetDouble(),
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.Null => null,
_ => jsonString,
};
await this.AssignAsync(this.Model.Output.Result?.Path, parsedValue.ToFormula(), context).ConfigureAwait(false);
return;
}
catch (JsonException)
{
// Not a valid JSON
}
}
await this.AssignAsync(this.Model.Output.Result?.Path, resultValue.ToFormula(), context).ConfigureAwait(false);
}
/// <summary>
/// Creates a VariableType.List with schema inferred from the first object element in the array.
/// </summary>
private static VariableType CreateListTypeFromJson(JsonElement arrayElement)
{
// Find the first object element to infer schema
foreach (JsonElement element in arrayElement.EnumerateArray())
{
if (element.ValueKind == JsonValueKind.Object)
{
// Build schema from the object's properties
List<(string Key, VariableType Type)> fields = [];
foreach (JsonProperty property in element.EnumerateObject())
{
VariableType fieldType = property.Value.ValueKind switch
{
JsonValueKind.String => typeof(string),
JsonValueKind.Number => typeof(decimal),
JsonValueKind.True or JsonValueKind.False => typeof(bool),
JsonValueKind.Object => VariableType.RecordType,
JsonValueKind.Array => VariableType.ListType,
_ => typeof(string),
};
fields.Add((property.Name, fieldType));
}
return VariableType.List(fields);
}
}
// Fallback for arrays of primitives or empty arrays
return VariableType.ListType;
}
private string GetFunctionName() =>
this.Evaluator.GetValue(
Throw.IfNull(
this.Model.FunctionName,
$"{nameof(this.Model)}.{nameof(this.Model.FunctionName)}")).Value;
private string? GetConversationId()
{
if (this.Model.ConversationId is null)
{
return null;
}
string conversationIdValue = this.Evaluator.GetValue(this.Model.ConversationId).Value;
return conversationIdValue.Length == 0 ? null : conversationIdValue;
}
private bool GetRequireApproval()
{
if (this.Model.RequireApproval is null)
{
return false;
}
return this.Evaluator.GetValue(this.Model.RequireApproval).Value;
}
private bool GetAutoSendValue()
{
if (this.Model.Output?.AutoSend is null)
{
return true;
}
return this.Evaluator.GetValue(this.Model.Output.AutoSend).Value;
}
private Dictionary<string, object?>? GetArguments()
{
if (this.Model.Arguments is null)
{
return null;
}
Dictionary<string, object?> result = [];
foreach (KeyValuePair<string, ValueExpression> argument in this.Model.Arguments)
{
result[argument.Key] = this.Evaluator.GetValue(argument.Value).Value.ToObject();
}
return result;
}
}
@@ -57,6 +57,7 @@ internal sealed class WorkflowHarness(Workflow workflow, string runId)
Assert.NotNull(this._lastCheckpoint);
Checkpointed<StreamingRun> run = await InProcessExecution.ResumeStreamAsync(workflow, this._lastCheckpoint, this.GetCheckpointManager());
IReadOnlyList<WorkflowEvent> workflowEvents = await MonitorAndDisposeWorkflowRunAsync(run, response).ToArrayAsync();
this._lastCheckpoint = workflowEvents.OfType<SuperStepCompletedEvent>().LastOrDefault()?.CompletionInfo?.Checkpoint;
return new WorkflowEvents(workflowEvents);
}
@@ -120,7 +121,11 @@ internal sealed class WorkflowHarness(Workflow workflow, string runId)
break;
case RequestInfoEvent requestInfo:
Console.WriteLine($"REQUEST #{requestInfo.Request.RequestId}");
hasRequest = true;
// Only count as a new request if it's not the one we're responding to
if (response is null || requestInfo.Request.RequestId != response.RequestId)
{
hasRequest = true;
}
break;
case ConversationUpdateEvent conversationEvent:
@@ -0,0 +1,155 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.Events;
using Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Agents;
using Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Framework;
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
using Microsoft.Extensions.AI;
using Xunit.Abstractions;
namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests;
/// <summary>
/// Integration tests for InvokeFunctionTool action.
/// This test pattern can be extended for other InvokeTool types.
/// </summary>
public sealed class InvokeFunctionToolWorkflowTest(ITestOutputHelper output) : IntegrationTest(output)
{
[Theory]
[InlineData("InvokeFunctionTool.yaml", new string[] { "GetSpecials", "GetItemPrice" }, "2.95")]
[InlineData("InvokeFunctionToolWithApproval.yaml", new string[] { "GetItemPrice" }, "4.9")]
public Task ValidateInvokeFunctionToolAsync(string workflowFileName, string[] expectedFunctionCalls, string? expectedResultContains) =>
this.RunInvokeToolTestAsync(workflowFileName, expectedFunctionCalls, expectedResultContains);
/// <summary>
/// Runs an InvokeTool workflow test with the specified configuration.
/// This method is designed to be generic and reusable for different InvokeTool types.
/// </summary>
/// <param name="workflowFileName">The workflow YAML file name.</param>
/// <param name="expectedFunctionCalls">Expected function names to be called in order.</param>
/// <param name="expectedResultContains">Expected text to be present in the final result.</param>
private async Task RunInvokeToolTestAsync(
string workflowFileName,
string[] expectedFunctionCalls,
string? expectedResultContains = null)
{
// Arrange
string workflowPath = GetWorkflowPath(workflowFileName);
IEnumerable<AIFunction> functionTools = new MenuPlugin().GetTools();
Dictionary<string, AIFunction> functionMap = functionTools.ToDictionary(tool => tool.Name, tool => tool);
DeclarativeWorkflowOptions workflowOptions = await this.CreateOptionsAsync(externalConversation: false);
Workflow workflow = DeclarativeWorkflowBuilder.Build<string>(workflowPath, workflowOptions);
WorkflowHarness harness = new(workflow, runId: Path.GetFileNameWithoutExtension(workflowPath));
List<string> invokedFunctions = [];
// Act - Run workflow and handle function invocations
WorkflowEvents workflowEvents = await harness.RunWorkflowAsync("start").ConfigureAwait(false);
while (workflowEvents.InputEvents.Count > 0)
{
RequestInfoEvent inputEvent = workflowEvents.InputEvents[^1];
ExternalInputRequest? toolRequest = inputEvent.Request.Data.As<ExternalInputRequest>();
Assert.NotNull(toolRequest);
IList<AIContent> functionResults = await this.ProcessFunctionCallsAsync(
toolRequest,
functionMap,
invokedFunctions).ConfigureAwait(false);
ChatMessage resultMessage = new(ChatRole.Tool, functionResults);
WorkflowEvents resumeEvents = await harness.ResumeAsync(
inputEvent.Request.CreateResponse(new ExternalInputResponse(resultMessage))).ConfigureAwait(false);
workflowEvents = new WorkflowEvents([.. workflowEvents.Events, .. resumeEvents.Events]);
// Continue processing until there are no more pending input events from the resumed workflow
if (resumeEvents.InputEvents.Count == 0)
{
// No more input events from the last resume - workflow completed
break;
}
}
// Assert - Verify function calls were made in expected order
Assert.Equal(expectedFunctionCalls.Length, invokedFunctions.Count);
for (int i = 0; i < expectedFunctionCalls.Length; i++)
{
Assert.Equal(expectedFunctionCalls[i], invokedFunctions[i]);
}
// Assert - Verify executor and action events
Assert.NotEmpty(workflowEvents.ExecutorInvokeEvents);
Assert.NotEmpty(workflowEvents.ExecutorCompleteEvents);
Assert.NotEmpty(workflowEvents.ActionInvokeEvents);
// Assert - Verify expected result if specified
if (expectedResultContains is not null)
{
MessageActivityEvent? messageEvent = workflowEvents.Events
.OfType<MessageActivityEvent>()
.LastOrDefault();
Assert.NotNull(messageEvent);
Assert.Contains(expectedResultContains, messageEvent.Message, StringComparison.OrdinalIgnoreCase);
}
}
/// <summary>
/// Processes function calls from an external input request.
/// Handles both regular function calls and approval requests.
/// </summary>
private async Task<IList<AIContent>> ProcessFunctionCallsAsync(
ExternalInputRequest toolRequest,
Dictionary<string, AIFunction> functionMap,
List<string> invokedFunctions)
{
List<AIContent> results = [];
foreach (ChatMessage message in toolRequest.AgentResponse.Messages)
{
// Handle approval requests if present
foreach (FunctionApprovalRequestContent approvalRequest in message.Contents.OfType<FunctionApprovalRequestContent>())
{
this.Output.WriteLine($"APPROVAL REQUEST: {approvalRequest.FunctionCall.Name}");
// Auto-approve for testing
results.Add(approvalRequest.CreateResponse(approved: true));
}
// Handle function calls
foreach (FunctionCallContent functionCall in message.Contents.OfType<FunctionCallContent>())
{
this.Output.WriteLine($"FUNCTION CALL: {functionCall.Name}");
if (!functionMap.TryGetValue(functionCall.Name, out AIFunction? functionTool))
{
Assert.Fail($"Function not found: {functionCall.Name}");
continue;
}
invokedFunctions.Add(functionCall.Name);
// Execute the function
AIFunctionArguments? functionArguments = functionCall.Arguments is null
? null
: new(functionCall.Arguments.NormalizePortableValues());
object? result = await functionTool.InvokeAsync(functionArguments).ConfigureAwait(false);
results.Add(new FunctionResultContent(functionCall.CallId, JsonSerializer.Serialize(result)));
this.Output.WriteLine($"FUNCTION RESULT: {JsonSerializer.Serialize(result)}");
}
}
return results;
}
private static string GetWorkflowPath(string workflowFileName) =>
Path.Combine(Environment.CurrentDirectory, "Workflows", workflowFileName);
}
@@ -0,0 +1,52 @@
#
# This workflow tests invoking function tools directly from a workflow.
# Uses the MenuPlugin functions: GetMenu, GetSpecials, GetItemPrice
#
kind: Workflow
trigger:
kind: OnConversationStart
id: workflow_invoke_function_tool_test
actions:
# Set the item name we want to look up
- kind: SetVariable
id: set_item_name
variable: Local.ItemName
value: Chai Tea
# Invoke GetSpecials function to get today's specials
- kind: InvokeFunctionTool
id: invoke_get_specials
functionName: GetSpecials
conversationId: =System.ConversationId
output:
autoSend: false
result: Local.Specials
# Invoke GetItemPrice function to get the price of a specific item
- kind: InvokeFunctionTool
id: invoke_get_item_price
functionName: GetItemPrice
conversationId: =System.ConversationId
arguments:
name: =Local.ItemName
output:
autoSend: true
result: Local.ItemPrice
# Ask an agent the price from the results in the conversation
- kind: InvokeAzureAgent
id: invoke_menu
conversationId: =System.ConversationId
agent:
name: TestAgent
input:
messages: =UserMessage("What's the price of Chai Tea?")
output:
messages: Local.AgentResponse
# Send the result as an activity
- kind: SendMessage
id: show_price_result
message: "{Local.AgentResponse}"
@@ -0,0 +1,33 @@
#
# This workflow tests invoking function tools with approval requirement.
# Uses the MenuPlugin function: GetItemPrice with requireApproval: true
#
kind: Workflow
trigger:
kind: OnConversationStart
id: workflow_invoke_function_tool_approval_test
actions:
# Set the item name we want to look up
- kind: SetVariable
id: set_item_name
variable: Local.ItemName
value: Clam Chowder
# Invoke GetItemPrice function with approval requirement
- kind: InvokeFunctionTool
id: invoke_get_item_price
functionName: GetItemPrice
conversationId: =System.ConversationId
requireApproval: true
arguments:
name: =Local.ItemName
output:
autoSend: false
result: Local.ItemPrice
# Send the result as an activity
- kind: SendMessage
id: show_price_result
message: "The price of {Local.ItemName} is ${Text(Local.ItemPrice)}"
@@ -0,0 +1,323 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.Events;
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
using Microsoft.Agents.ObjectModel;
using Microsoft.Extensions.AI;
using Xunit.Abstractions;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
/// <summary>
/// Tests for <see cref="InvokeFunctionToolExecutor"/>.
/// </summary>
public sealed class InvokeFunctionToolExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output)
{
#region Step Naming Convention Tests
[Fact]
public void InvokeFunctionToolThrowsWhenModelInvalid() =>
// Arrange, Act & Assert
Assert.Throws<DeclarativeModelException>(() => new InvokeFunctionToolExecutor(new InvokeFunctionTool(), new MockAgentProvider().Object, this.State));
[Fact]
public void InvokeFunctionToolNamingConvention()
{
// Arrange
string testId = this.CreateActionId().Value;
// Act
string externalInputStep = InvokeFunctionToolExecutor.Steps.ExternalInput(testId);
string resumeStep = InvokeFunctionToolExecutor.Steps.Resume(testId);
// Assert
Assert.Equal($"{testId}_{nameof(InvokeFunctionToolExecutor.Steps.ExternalInput)}", externalInputStep);
Assert.Equal($"{testId}_{nameof(InvokeFunctionToolExecutor.Steps.Resume)}", resumeStep);
}
#endregion
#region ExecuteAsync Tests
[Fact]
public async Task InvokeFunctionToolExecuteWithoutApprovalAsync()
{
// Arrange
this.State.InitializeSystem();
InvokeFunctionTool model = this.CreateModel(
displayName: nameof(InvokeFunctionToolExecuteWithoutApprovalAsync),
functionName: "simple_function",
requireApproval: false);
// Act and Assert
await this.ExecuteTestAsync(model);
}
[Fact]
public async Task InvokeFunctionToolExecuteWithArgumentsAsync()
{
// Arrange
this.State.InitializeSystem();
InvokeFunctionTool model = this.CreateModel(
displayName: nameof(InvokeFunctionToolExecuteWithArgumentsAsync),
functionName: "get_weather",
argumentKey: "location",
argumentValue: "Seattle");
// Act and Assert
await this.ExecuteTestAsync(model);
}
[Fact]
public async Task InvokeFunctionToolExecuteWithRequireApprovalAsync()
{
// Arrange
this.State.InitializeSystem();
InvokeFunctionTool model = this.CreateModel(
displayName: nameof(InvokeFunctionToolExecuteWithRequireApprovalAsync),
functionName: "approval_function",
requireApproval: true);
// Act and Assert
await this.ExecuteTestAsync(model);
}
[Fact]
public async Task InvokeFunctionToolExecuteWithEmptyConversationIdAsync()
{
// Arrange
this.State.InitializeSystem();
InvokeFunctionTool model = this.CreateModel(
displayName: nameof(InvokeFunctionToolExecuteWithEmptyConversationIdAsync),
functionName: "test_function",
conversationId: "");
// Act and Assert
await this.ExecuteTestAsync(model);
}
[Fact]
public async Task InvokeFunctionToolExecuteWithNullArgumentsAsync()
{
// Arrange
this.State.InitializeSystem();
InvokeFunctionTool model = this.CreateModel(
displayName: nameof(InvokeFunctionToolExecuteWithNullArgumentsAsync),
functionName: "no_args_function",
argumentKey: null);
// Act and Assert
await this.ExecuteTestAsync(model);
}
[Fact]
public async Task InvokeFunctionToolExecuteWithNullRequireApprovalAsync()
{
// Arrange
this.State.InitializeSystem();
InvokeFunctionTool model = this.CreateModel(
displayName: nameof(InvokeFunctionToolExecuteWithNullRequireApprovalAsync),
functionName: "test_function",
requireApproval: null);
// Act and Assert
await this.ExecuteTestAsync(model);
}
[Fact]
public async Task InvokeFunctionToolExecuteWithNullConversationIdAsync()
{
// Arrange
this.State.InitializeSystem();
InvokeFunctionTool model = this.CreateModel(
displayName: nameof(InvokeFunctionToolExecuteWithNullConversationIdAsync),
functionName: "test_function",
conversationId: null);
// Act and Assert
await this.ExecuteTestAsync(model);
}
#endregion
#region CaptureResponseAsync Tests
[Fact]
public async Task InvokeFunctionToolCaptureResponseWithNoOutputConfiguredAsync()
{
// Arrange
this.State.InitializeSystem();
InvokeFunctionTool model = this.CreateModel(
displayName: nameof(InvokeFunctionToolCaptureResponseWithNoOutputConfiguredAsync),
functionName: "test_function");
MockAgentProvider mockAgentProvider = new();
InvokeFunctionToolExecutor action = new(model, mockAgentProvider.Object, this.State);
FunctionResultContent functionResult = new(action.Id, "Result without output");
ExternalInputResponse response = new(new ChatMessage(ChatRole.Tool, [functionResult]));
// Act
WorkflowEvent[] events = await this.ExecuteCaptureResponseTestAsync(action, response);
// Assert
VerifyModel(model, action);
Assert.NotEmpty(events);
}
[Fact]
public async Task InvokeFunctionToolCaptureResponseWithEmptyMessagesAsync()
{
// Arrange
this.State.InitializeSystem();
InvokeFunctionTool model = this.CreateModel(
displayName: nameof(InvokeFunctionToolCaptureResponseWithEmptyMessagesAsync),
functionName: "test_function");
MockAgentProvider mockAgentProvider = new();
InvokeFunctionToolExecutor action = new(model, mockAgentProvider.Object, this.State);
// Empty response
ExternalInputResponse response = new([]);
// Act
WorkflowEvent[] events = await this.ExecuteCaptureResponseTestAsync(action, response);
// Assert
VerifyModel(model, action);
Assert.NotEmpty(events);
}
[Fact]
public async Task InvokeFunctionToolCaptureResponseWithConversationIdAsync()
{
// Arrange
this.State.InitializeSystem();
const string ConversationId = "TestConversationId";
InvokeFunctionTool model = this.CreateModel(
displayName: nameof(InvokeFunctionToolCaptureResponseWithConversationIdAsync),
functionName: "test_function",
conversationId: ConversationId);
MockAgentProvider mockAgentProvider = new();
InvokeFunctionToolExecutor action = new(model, mockAgentProvider.Object, this.State);
FunctionResultContent functionResult = new(action.Id, "Result for conversation");
ExternalInputResponse response = new(new ChatMessage(ChatRole.Tool, [functionResult]));
// Act
WorkflowEvent[] events = await this.ExecuteCaptureResponseTestAsync(action, response);
// Assert
VerifyModel(model, action);
Assert.NotEmpty(events);
}
[Fact]
public async Task InvokeFunctionToolCaptureResponseWithNonMatchingResultAsync()
{
// Arrange
this.State.InitializeSystem();
InvokeFunctionTool model = this.CreateModel(
displayName: nameof(InvokeFunctionToolCaptureResponseWithNonMatchingResultAsync),
functionName: "test_function");
MockAgentProvider mockAgentProvider = new();
InvokeFunctionToolExecutor action = new(model, mockAgentProvider.Object, this.State);
// Use a different call ID that doesn't match the action ID
FunctionResultContent functionResult = new("different_call_id", "Different result");
ExternalInputResponse response = new(new ChatMessage(ChatRole.Tool, [functionResult]));
// Act
WorkflowEvent[] events = await this.ExecuteCaptureResponseTestAsync(action, response);
// Assert
VerifyModel(model, action);
Assert.NotEmpty(events);
}
[Fact]
public async Task InvokeFunctionToolCaptureResponseWithMultipleFunctionResultsAsync()
{
// Arrange
this.State.InitializeSystem();
InvokeFunctionTool model = this.CreateModel(
displayName: nameof(InvokeFunctionToolCaptureResponseWithMultipleFunctionResultsAsync),
functionName: "test_function",
conversationId: "TestConversation");
MockAgentProvider mockAgentProvider = new();
InvokeFunctionToolExecutor action = new(model, mockAgentProvider.Object, this.State);
// Multiple function results - the matching one should be captured
FunctionResultContent nonMatchingResult = new("other_call_id", "Other result");
FunctionResultContent matchingResult = new(action.Id, "Matching result");
ExternalInputResponse response = new(new ChatMessage(ChatRole.Tool, [nonMatchingResult, matchingResult]));
// Act
WorkflowEvent[] events = await this.ExecuteCaptureResponseTestAsync(action, response);
// Assert
VerifyModel(model, action);
Assert.NotEmpty(events);
}
#endregion
#region Helper Methods
private async Task ExecuteTestAsync(InvokeFunctionTool model)
{
MockAgentProvider mockAgentProvider = new();
InvokeFunctionToolExecutor action = new(model, mockAgentProvider.Object, this.State);
// Act
WorkflowEvent[] events = await this.ExecuteAsync(action, isDiscrete: false);
// Assert
VerifyModel(model, action);
VerifyInvocationEvent(events);
// IsDiscreteAction should be false for InvokeFunction
VerifyIsDiscrete(action, isDiscrete: false);
}
private async Task<WorkflowEvent[]> ExecuteCaptureResponseTestAsync(
InvokeFunctionToolExecutor action,
ExternalInputResponse response)
{
return await this.ExecuteAsync(
action,
InvokeFunctionToolExecutor.Steps.ExternalInput(action.Id),
(context, _, cancellationToken) => action.CaptureResponseAsync(context, response, cancellationToken));
}
private InvokeFunctionTool CreateModel(
string displayName,
string functionName,
bool? requireApproval = false,
string? conversationId = null,
string? argumentKey = null,
string? argumentValue = null)
{
InvokeFunctionTool.Builder builder = new()
{
Id = this.CreateActionId(),
DisplayName = this.FormatDisplayName(displayName),
FunctionName = new StringExpression.Builder(StringExpression.Literal(functionName)),
RequireApproval = requireApproval != null ? new BoolExpression.Builder(BoolExpression.Literal(requireApproval.Value)) : null
};
if (conversationId is not null)
{
builder.ConversationId = new StringExpression.Builder(StringExpression.Literal(conversationId));
}
if (argumentKey is not null && argumentValue is not null)
{
builder.Arguments.Add(argumentKey, ValueExpression.Literal(new StringDataValue(argumentValue)));
}
return AssignParent<InvokeFunctionTool>(builder);
}
#endregion
}