diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 5c9f2e06d9..0de4409d53 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -111,10 +111,10 @@ - - - - + + + + diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index dfb8717c85..92968ac153 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -215,6 +215,7 @@ + diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/InvokeFunctionTool/InvokeFunctionTool.csproj b/dotnet/samples/GettingStarted/Workflows/Declarative/InvokeFunctionTool/InvokeFunctionTool.csproj new file mode 100644 index 0000000000..23e1c91e0a --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/InvokeFunctionTool/InvokeFunctionTool.csproj @@ -0,0 +1,38 @@ + + + + Exe + net10.0 + enable + enable + + + + true + true + true + true + + + + + + + + + + + + + + + + + + + + Always + + + + diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/InvokeFunctionTool/InvokeFunctionTool.yaml b/dotnet/samples/GettingStarted/Workflows/Declarative/InvokeFunctionTool/InvokeFunctionTool.yaml new file mode 100644 index 0000000000..8bc0ffe8be --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/InvokeFunctionTool/InvokeFunctionTool.yaml @@ -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" diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/InvokeFunctionTool/MenuPlugin.cs b/dotnet/samples/GettingStarted/Workflows/Declarative/InvokeFunctionTool/MenuPlugin.cs new file mode 100644 index 0000000000..a2c00f37cc --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/InvokeFunctionTool/MenuPlugin.cs @@ -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 + +/// +/// Plugin providing menu-related functions that can be invoked directly by the workflow +/// using the InvokeFunctionTool action. +/// +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; } + } +} diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/InvokeFunctionTool/Program.cs b/dotnet/samples/GettingStarted/Workflows/Declarative/InvokeFunctionTool/Program.cs new file mode 100644 index 0000000000..456bba0f88 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/InvokeFunctionTool/Program.cs @@ -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; + +/// +/// Demonstrate a workflow that uses InvokeFunctionTool to call functions directly +/// from the workflow without going through an AI agent first. +/// +/// +/// 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. +/// +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; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs index 3ecb91ea3a..7b84e24839 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs @@ -390,6 +390,27 @@ protected override void Visit(InvokeAzureAgent item) 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(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(resumeId, this._workflowState, action.CaptureResponseAsync), + action.ParentId); + } + protected override void Visit(InvokeAzureResponse item) { this.NotSupported(item); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowTemplateVisitor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowTemplateVisitor.cs index 56901762f4..568a38950c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowTemplateVisitor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowTemplateVisitor.cs @@ -365,6 +365,8 @@ protected override void Visit(SendActivity item) #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); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs new file mode 100644 index 0000000000..615d4d88ab --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs @@ -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; + +/// +/// Executor for the action. +/// This executor yields to the caller for function execution and resumes when results are provided. +/// +internal sealed class InvokeFunctionToolExecutor( + InvokeFunctionTool model, + ResponseAgentProvider agentProvider, + WorkflowFormulaState state) : + DeclarativeActionExecutor(model, state) +{ + /// + /// Step identifiers for the function tool invocation workflow. + /// + public static class Steps + { + /// + /// Step for waiting for external input (function result). + /// + public static string ExternalInput(string id) => $"{id}_{nameof(ExternalInput)}"; + + /// + /// Step for resuming after receiving function result. + /// + public static string Resume(string id) => $"{id}_{nameof(Resume)}"; + } + + /// + protected override bool EmitResultEvent => false; + + /// + protected override bool IsDiscreteAction => false; + + /// + protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken = default) + { + string functionName = this.GetFunctionName(); + bool requireApproval = this.GetRequireApproval(); + Dictionary? 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; + } + + /// + /// Captures the function result and stores in output variables. + /// + /// The workflow context. + /// The external input response containing the function result. + /// A cancellation token. + /// A representing the asynchronous operation. + 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 functionResults = response.Messages + .SelectMany(m => m.Contents) + .OfType(); + + 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); + } + + /// + /// 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. + /// + private static IEnumerable TransformConversationMessages(IEnumerable messages) + { + foreach (ChatMessage message in messages) + { + // Check if message contains function content + bool hasFunctionResult = message.Contents.OfType().Any(); + bool hasFunctionCall = message.Contents.OfType().Any(); + + if (hasFunctionResult) + { + // Convert function results to assistant text message + List 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); + } + + /// + /// Creates a VariableType.List with schema inferred from the first object element in the array. + /// + 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? GetArguments() + { + if (this.Model.Arguments is null) + { + return null; + } + + Dictionary result = []; + foreach (KeyValuePair argument in this.Model.Arguments) + { + result[argument.Key] = this.Evaluator.GetValue(argument.Value).Value.ToObject(); + } + + return result; + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/WorkflowHarness.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/WorkflowHarness.cs index 80d4c57da8..d0aa60123e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/WorkflowHarness.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/WorkflowHarness.cs @@ -57,6 +57,7 @@ public async Task ResumeAsync(ExternalResponse response) Assert.NotNull(this._lastCheckpoint); Checkpointed run = await InProcessExecution.ResumeStreamAsync(workflow, this._lastCheckpoint, this.GetCheckpointManager()); IReadOnlyList workflowEvents = await MonitorAndDisposeWorkflowRunAsync(run, response).ToArrayAsync(); + this._lastCheckpoint = workflowEvents.OfType().LastOrDefault()?.CompletionInfo?.Checkpoint; return new WorkflowEvents(workflowEvents); } @@ -120,7 +121,11 @@ private static async IAsyncEnumerable MonitorAndDisposeWorkflowRu 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: diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/InvokeFunctionToolWorkflowTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/InvokeFunctionToolWorkflowTest.cs new file mode 100644 index 0000000000..289fbe2faa --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/InvokeFunctionToolWorkflowTest.cs @@ -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; + +/// +/// Integration tests for InvokeFunctionTool action. +/// This test pattern can be extended for other InvokeTool types. +/// +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); + + /// + /// Runs an InvokeTool workflow test with the specified configuration. + /// This method is designed to be generic and reusable for different InvokeTool types. + /// + /// The workflow YAML file name. + /// Expected function names to be called in order. + /// Expected text to be present in the final result. + private async Task RunInvokeToolTestAsync( + string workflowFileName, + string[] expectedFunctionCalls, + string? expectedResultContains = null) + { + // Arrange + string workflowPath = GetWorkflowPath(workflowFileName); + IEnumerable functionTools = new MenuPlugin().GetTools(); + Dictionary functionMap = functionTools.ToDictionary(tool => tool.Name, tool => tool); + DeclarativeWorkflowOptions workflowOptions = await this.CreateOptionsAsync(externalConversation: false); + Workflow workflow = DeclarativeWorkflowBuilder.Build(workflowPath, workflowOptions); + + WorkflowHarness harness = new(workflow, runId: Path.GetFileNameWithoutExtension(workflowPath)); + List 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(); + Assert.NotNull(toolRequest); + + IList 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() + .LastOrDefault(); + + Assert.NotNull(messageEvent); + Assert.Contains(expectedResultContains, messageEvent.Message, StringComparison.OrdinalIgnoreCase); + } + } + + /// + /// Processes function calls from an external input request. + /// Handles both regular function calls and approval requests. + /// + private async Task> ProcessFunctionCallsAsync( + ExternalInputRequest toolRequest, + Dictionary functionMap, + List invokedFunctions) + { + List results = []; + + foreach (ChatMessage message in toolRequest.AgentResponse.Messages) + { + // Handle approval requests if present + foreach (FunctionApprovalRequestContent approvalRequest in message.Contents.OfType()) + { + 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()) + { + 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); +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/InvokeFunctionTool.yaml b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/InvokeFunctionTool.yaml new file mode 100644 index 0000000000..0f63065e29 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/InvokeFunctionTool.yaml @@ -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}" diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/InvokeFunctionToolWithApproval.yaml b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/InvokeFunctionToolWithApproval.yaml new file mode 100644 index 0000000000..faaf7cabdf --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/InvokeFunctionToolWithApproval.yaml @@ -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)}" diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeFunctionToolExecutorTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeFunctionToolExecutorTest.cs new file mode 100644 index 0000000000..4a07ba3002 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeFunctionToolExecutorTest.cs @@ -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; + +/// +/// Tests for . +/// +public sealed class InvokeFunctionToolExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output) +{ + #region Step Naming Convention Tests + + [Fact] + public void InvokeFunctionToolThrowsWhenModelInvalid() => + // Arrange, Act & Assert + Assert.Throws(() => 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 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(builder); + } + + #endregion +}