From df99a0419ecc9ba7a776d1f9e77761fbc4277b3a Mon Sep 17 00:00:00 2001 From: Peter Ibekwe Date: Thu, 12 Feb 2026 17:22:08 -0800 Subject: [PATCH 01/12] Initial Implementation of InvokeFunctionTool --- dotnet/Directory.Packages.props | 6 +- dotnet/nuget.config | 6 + .../Interpreter/WorkflowActionVisitor.cs | 23 ++ .../Interpreter/WorkflowTemplateVisitor.cs | 4 + .../ObjectModel/InvokeFunctionToolExecutor.cs | 229 ++++++++++++++++++ .../Workflows/InvokeFunctionToolSample.yaml | 50 ++++ .../InvokeFunctionToolExecutorTest.cs | 58 +++++ 7 files changed, 373 insertions(+), 3 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/InvokeFunctionToolSample.yaml create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeFunctionToolExecutorTest.cs diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 98c7376aaf..843151249c 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -108,9 +108,9 @@ - - - + + + diff --git a/dotnet/nuget.config b/dotnet/nuget.config index 76d943ce16..dfd4634b12 100644 --- a/dotnet/nuget.config +++ b/dotnet/nuget.config @@ -3,10 +3,16 @@ + + + + + + \ No newline at end of file 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 1837142568..1868f3074f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs @@ -382,6 +382,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); @@ -530,6 +551,8 @@ protected override void Visit(SendActivity item) protected override void Visit(SearchAndSummarizeContent item) => this.NotSupported(item); + protected override void Visit(InvokeMcpTool item) => this.NotSupported(item); + #endregion private void ContinueWith( 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 065f04aaac..1c2b83c3ef 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,10 @@ protected override void Visit(SendActivity item) #region Not supported + protected override void Visit(InvokeFunctionTool item) => this.NotSupported(item); + + protected override void Visit(InvokeMcpTool 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..41cfb0306b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs @@ -0,0 +1,229 @@ +// 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, + WorkflowAgentProvider 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 + if (conversationId is not null) + { + foreach (ChatMessage message in 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); + } + + /// + /// Completes the action after processing the function result. + /// + /// The workflow context. + /// The action executor result. + /// A cancellation token. + /// A representing the asynchronous operation. + public async ValueTask CompleteAsync(IWorkflowContext context, ActionExecutorResult message, CancellationToken cancellationToken) + { + await context.RaiseCompletionEventAsync(this.Model, cancellationToken).ConfigureAwait(false); + } + + 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 + { + JsonDocument jsonDocument = JsonDocument.Parse(jsonString); + Dictionary objectProperties = jsonDocument.ParseRecord(VariableType.RecordType); + await this.AssignAsync(this.Model.Output.Result?.Path, objectProperties.ToFormula(), context).ConfigureAwait(false); + return; + } + catch + { + // Not valid JSON, assign as string + } + } + + await this.AssignAsync(this.Model.Output.Result?.Path, resultValue.ToFormula(), context).ConfigureAwait(false); + } + + 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/Workflows/InvokeFunctionToolSample.yaml b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/InvokeFunctionToolSample.yaml new file mode 100644 index 0000000000..7e7b2a44ca --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/InvokeFunctionToolSample.yaml @@ -0,0 +1,50 @@ +# +# This workflow demonstrates invoking a function tool directly from a workflow. +# The function tool yields to the caller for execution and resumes when results are provided. +# +# Example usage in YAML: +# +# kind: InvokeFunctionTool +# id: invoke_function_example +# conversationId: =System.ConversationId # optional - associates with conversation +# functionName: get_weather # required - name of the function to invoke +# requireApproval: true # optional - default = false +# arguments: # optional - function arguments +# location: =Local.location +# unit: F +# output: +# autoSend: false # optional - default = true +# messages: Local.weatherToolCallItems # optional - stores response messages +# result: Local.WeatherInfo # optional - stores parsed result +# +kind: Workflow +trigger: + + kind: OnConversationStart + id: workflow_function_tool_demo + actions: + + # Set initial variables + - kind: SetVariable + id: set_location + variable: Local.location + value: Seattle + + # Invoke a function tool that requires approval + - kind: InvokeFunctionTool + id: invoke_weather + functionName: get_weather + requireApproval: true + arguments: + location: =Local.location + unit: Fahrenheit + output: + autoSend: true + result: Local.WeatherResult + + # Use the result in a subsequent action + - kind: SendActivity + id: send_weather_result + activity: + kind: MessageActivity + text: "The weather in ${Local.location} is: ${Text(Local.WeatherResult.temperature)}°F" 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..1ad8bed2ce --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeFunctionToolExecutorTest.cs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel; +using Microsoft.Agents.AI.Workflows.Declarative.PowerFx; +using Microsoft.Agents.ObjectModel; +using Xunit.Abstractions; + +namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel; + +/// +/// Tests for . +/// +public sealed class InvokeFunctionToolExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output) +{ + [Fact] + public async Task ExecutorCreatedWithValidModelAsync() + { + // Arrange + // Initialize state to simulate workflow environment. + this.State.InitializeSystem(); + InvokeFunctionTool model = this.CreateModel( + displayName: nameof(ExecutorCreatedWithValidModelAsync), + functionName: "test_function", + requireApproval: true, + conversationId: "TestConversationId"); + MockAgentProvider mockAgentProvider = new(); + InvokeFunctionToolExecutor action = new(model, mockAgentProvider.Object, this.State); + + // Act + await this.ExecuteAsync(action); + + // Assert + VerifyModel(model, action); + } + + private InvokeFunctionTool CreateModel( + string displayName, + string functionName, + bool requireApproval = false, + string? conversationId = null) + { + InvokeFunctionTool.Builder builder = new() + { + Id = this.CreateActionId(), + DisplayName = this.FormatDisplayName(displayName), + FunctionName = new StringExpression.Builder(StringExpression.Literal(functionName)), + RequireApproval = new BoolExpression.Builder(BoolExpression.Literal(requireApproval)) + }; + + if (conversationId is not null) + { + builder.ConversationId = new StringExpression.Builder(StringExpression.Literal(conversationId)); + } + + return AssignParent(builder); + } +} From 3a05291e54fb0c41e2ad5b1c74cc799c077bbb53 Mon Sep 17 00:00:00 2001 From: Peter Ibekwe Date: Fri, 13 Feb 2026 15:42:40 -0800 Subject: [PATCH 02/12] Added unit test for InvokeFunctionTool executor. --- .../ObjectModel/InvokeFunctionToolExecutor.cs | 2 +- .../InvokeFunctionToolExecutorTest.cs | 313 +++++++++++++++++- 2 files changed, 305 insertions(+), 10 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs index 41cfb0306b..6e4499fb55 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs @@ -22,7 +22,7 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.ObjectModel; /// internal sealed class InvokeFunctionToolExecutor( InvokeFunctionTool model, - WorkflowAgentProvider agentProvider, + ResponseAgentProvider agentProvider, WorkflowFormulaState state) : DeclarativeActionExecutor(model, state) { 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 index 1ad8bed2ce..4eda8d7c39 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeFunctionToolExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeFunctionToolExecutorTest.cs @@ -1,9 +1,12 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Reflection; 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; @@ -13,39 +16,324 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel; /// 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 ExecutorCreatedWithValidModelAsync() + public async Task InvokeFunctionToolExecuteWithArgumentsAsync() { // Arrange - // Initialize state to simulate workflow environment. this.State.InitializeSystem(); InvokeFunctionTool model = this.CreateModel( - displayName: nameof(ExecutorCreatedWithValidModelAsync), + 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", - requireApproval: true, - conversationId: "TestConversationId"); + 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 - await this.ExecuteAsync(action); + 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 CompleteAsync Tests + + [Fact] + public async Task InvokeFunctionToolCompleteAsyncRaisesCompletionEventAsync() + { + // Arrange + this.State.InitializeSystem(); + InvokeFunctionTool model = this.CreateModel( + displayName: nameof(InvokeFunctionToolCompleteAsyncRaisesCompletionEventAsync), + functionName: "test_function"); + MockAgentProvider mockAgentProvider = new(); + InvokeFunctionToolExecutor action = new(model, mockAgentProvider.Object, this.State); + + // Act + WorkflowEvent[] events = await this.ExecuteAsync( + InvokeFunctionToolExecutor.Steps.Resume(action.Id), + action.CompleteAsync); + + // Assert + VerifyModel(model, action); + VerifyCompletionEvent(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 + Assert.Equal( + false, + action.GetType().BaseType? + .GetProperty("IsDiscreteAction", BindingFlags.NonPublic | BindingFlags.Instance)? + .GetValue(action)); + } + + 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) + 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 = new BoolExpression.Builder(BoolExpression.Literal(requireApproval)) + RequireApproval = requireApproval != null ? new BoolExpression.Builder(BoolExpression.Literal(requireApproval.Value)) : null }; if (conversationId is not null) @@ -53,6 +341,13 @@ private InvokeFunctionTool CreateModel( 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 } From 1879b4194ce910e419730f8da7abad98d7ac9d9a Mon Sep 17 00:00:00 2001 From: Peter Ibekwe Date: Mon, 16 Feb 2026 15:37:03 -0800 Subject: [PATCH 03/12] Implemented unit and integration tests for InvokeFunctionTool. --- .../ObjectModel/InvokeFunctionToolExecutor.cs | 52 +++++- .../Framework/WorkflowHarness.cs | 7 +- .../InvokeFunctionToolWorkflowTest.cs | 155 ++++++++++++++++++ .../Workflows/InvokeFunctionTool.yaml | 52 ++++++ .../InvokeFunctionToolWithApproval.yaml | 33 ++++ 5 files changed, 297 insertions(+), 2 deletions(-) create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/InvokeFunctionToolWorkflowTest.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/InvokeFunctionTool.yaml create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/InvokeFunctionToolWithApproval.yaml diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs index 6e4499fb55..3e20bb1fa1 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs @@ -122,9 +122,12 @@ public async ValueTask CaptureResponseAsync( } // 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 response.Messages) + foreach (ChatMessage message in TransformConversationMessages(response.Messages)) { await agentProvider.CreateMessageAsync(conversationId, message, cancellationToken).ConfigureAwait(false); } @@ -146,6 +149,53 @@ public async ValueTask CompleteAsync(IWorkflowContext context, ActionExecutorRes 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) 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..5eb92f7096 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..cd02ce9ca5 --- /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.95")] + 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)}" From c6cfa14638ad8de74fded03522949913faba669f Mon Sep 17 00:00:00 2001 From: Peter Ibekwe Date: Mon, 16 Feb 2026 16:39:07 -0800 Subject: [PATCH 04/12] Add sample for InvokeFunctionTool in declarative workflows. --- dotnet/agent-framework-dotnet.slnx | 1 + .../InvokeFunctionTool.csproj | 38 ++++++++ .../InvokeFunctionTool.yaml | 55 ++++++++++++ .../InvokeFunctionTool/MenuPlugin.cs | 85 ++++++++++++++++++ .../Declarative/InvokeFunctionTool/Program.cs | 90 +++++++++++++++++++ 5 files changed, 269 insertions(+) create mode 100644 dotnet/samples/GettingStarted/Workflows/Declarative/InvokeFunctionTool/InvokeFunctionTool.csproj create mode 100644 dotnet/samples/GettingStarted/Workflows/Declarative/InvokeFunctionTool/InvokeFunctionTool.yaml create mode 100644 dotnet/samples/GettingStarted/Workflows/Declarative/InvokeFunctionTool/MenuPlugin.cs create mode 100644 dotnet/samples/GettingStarted/Workflows/Declarative/InvokeFunctionTool/Program.cs diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index e592e80803..f3841c3cc2 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -213,6 +213,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..8d6765e931 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/InvokeFunctionTool/Program.cs @@ -0,0 +1,90 @@ +// 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. This workflow demonstrates InvokeFunctionTool + // which allows the workflow to call functions directly without going through an agent. + WorkflowFactory workflowFactory = new("InvokeFunctionTool.yaml", foundryEndpoint); + + // Execute the workflow: The WorkflowRunner handles external input requests + // including function call requests from InvokeFunctionTool actions. + 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; + } +} From a6b92a9fe75600fa0a1b3424a415686815e88879 Mon Sep 17 00:00:00 2001 From: Peter Ibekwe Date: Tue, 17 Feb 2026 12:02:22 -0800 Subject: [PATCH 05/12] Remove unused sample and updated comments. --- .../Declarative/InvokeFunctionTool/Program.cs | 6 +-- .../Workflows/InvokeFunctionToolSample.yaml | 50 ------------------- 2 files changed, 2 insertions(+), 54 deletions(-) delete mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/InvokeFunctionToolSample.yaml diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/InvokeFunctionTool/Program.cs b/dotnet/samples/GettingStarted/Workflows/Declarative/InvokeFunctionTool/Program.cs index 8d6765e931..456bba0f88 100644 --- a/dotnet/samples/GettingStarted/Workflows/Declarative/InvokeFunctionTool/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/InvokeFunctionTool/Program.cs @@ -44,12 +44,10 @@ public static async Task Main(string[] args) // Get input from command line or console string workflowInput = Application.GetInput(args); - // Create the workflow factory. This workflow demonstrates InvokeFunctionTool - // which allows the workflow to call functions directly without going through an agent. + // Create the workflow factory. WorkflowFactory workflowFactory = new("InvokeFunctionTool.yaml", foundryEndpoint); - // Execute the workflow: The WorkflowRunner handles external input requests - // including function call requests from InvokeFunctionTool actions. + // Execute the workflow WorkflowRunner runner = new(functions) { UseJsonCheckpoints = true }; await runner.ExecuteAsync(workflowFactory.CreateWorkflow, workflowInput); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/InvokeFunctionToolSample.yaml b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/InvokeFunctionToolSample.yaml deleted file mode 100644 index 7e7b2a44ca..0000000000 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/InvokeFunctionToolSample.yaml +++ /dev/null @@ -1,50 +0,0 @@ -# -# This workflow demonstrates invoking a function tool directly from a workflow. -# The function tool yields to the caller for execution and resumes when results are provided. -# -# Example usage in YAML: -# -# kind: InvokeFunctionTool -# id: invoke_function_example -# conversationId: =System.ConversationId # optional - associates with conversation -# functionName: get_weather # required - name of the function to invoke -# requireApproval: true # optional - default = false -# arguments: # optional - function arguments -# location: =Local.location -# unit: F -# output: -# autoSend: false # optional - default = true -# messages: Local.weatherToolCallItems # optional - stores response messages -# result: Local.WeatherInfo # optional - stores parsed result -# -kind: Workflow -trigger: - - kind: OnConversationStart - id: workflow_function_tool_demo - actions: - - # Set initial variables - - kind: SetVariable - id: set_location - variable: Local.location - value: Seattle - - # Invoke a function tool that requires approval - - kind: InvokeFunctionTool - id: invoke_weather - functionName: get_weather - requireApproval: true - arguments: - location: =Local.location - unit: Fahrenheit - output: - autoSend: true - result: Local.WeatherResult - - # Use the result in a subsequent action - - kind: SendActivity - id: send_weather_result - activity: - kind: MessageActivity - text: "The weather in ${Local.location} is: ${Text(Local.WeatherResult.temperature)}°F" From 670cd473c569be9e59ce229f3bdf4e0741e86bff Mon Sep 17 00:00:00 2001 From: Peter Ibekwe Date: Tue, 17 Feb 2026 13:27:38 -0800 Subject: [PATCH 06/12] Updating to official OM release with InvokeFunctionTool --- dotnet/Directory.Packages.props | 6 +++--- dotnet/nuget.config | 6 ------ .../Interpreter/WorkflowActionVisitor.cs | 2 -- .../Interpreter/WorkflowTemplateVisitor.cs | 2 -- 4 files changed, 3 insertions(+), 13 deletions(-) diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 0dc6891c77..187af11c7f 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -108,9 +108,9 @@ - - - + + + diff --git a/dotnet/nuget.config b/dotnet/nuget.config index dfd4634b12..76d943ce16 100644 --- a/dotnet/nuget.config +++ b/dotnet/nuget.config @@ -3,16 +3,10 @@ - - - - - - \ No newline at end of file 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 a4c53ce48f..7b84e24839 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs @@ -559,8 +559,6 @@ protected override void Visit(SendActivity item) protected override void Visit(SearchAndSummarizeContent item) => this.NotSupported(item); - protected override void Visit(InvokeMcpTool item) => this.NotSupported(item); - #endregion private void ContinueWith( 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 f5e98c5f05..568a38950c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowTemplateVisitor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowTemplateVisitor.cs @@ -367,8 +367,6 @@ protected override void Visit(SendActivity item) protected override void Visit(InvokeFunctionTool item) => this.NotSupported(item); - protected override void Visit(InvokeMcpTool item) => this.NotSupported(item); - protected override void Visit(AnswerQuestionWithAI item) => this.NotSupported(item); protected override void Visit(DeleteActivity item) => this.NotSupported(item); From fb306a8264f7707d9ff79a087d3c5771c3a8486b Mon Sep 17 00:00:00 2001 From: Peter Ibekwe Date: Tue, 17 Feb 2026 13:54:18 -0800 Subject: [PATCH 07/12] Fix formatting issues. --- .../Framework/WorkflowHarness.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 5eb92f7096..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,7 +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; + this._lastCheckpoint = workflowEvents.OfType().LastOrDefault()?.CompletionInfo?.Checkpoint; return new WorkflowEvents(workflowEvents); } From 70e6030e73a89677ca8d6e2479a26a38c4682764 Mon Sep 17 00:00:00 2001 From: Peter Ibekwe Date: Tue, 17 Feb 2026 14:18:45 -0800 Subject: [PATCH 08/12] Updated PowerFx version --- dotnet/Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 187af11c7f..90009325d7 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -111,7 +111,7 @@ - + From 2c332a0d24c1989b88e8110ed060cd71bc266a82 Mon Sep 17 00:00:00 2001 From: Peter Ibekwe Date: Tue, 17 Feb 2026 14:53:23 -0800 Subject: [PATCH 09/12] Update test fixture --- .../InvokeFunctionToolWorkflowTest.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/InvokeFunctionToolWorkflowTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/InvokeFunctionToolWorkflowTest.cs index cd02ce9ca5..289fbe2faa 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/InvokeFunctionToolWorkflowTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/InvokeFunctionToolWorkflowTest.cs @@ -23,7 +23,7 @@ public sealed class InvokeFunctionToolWorkflowTest(ITestOutputHelper output) : I { [Theory] [InlineData("InvokeFunctionTool.yaml", new string[] { "GetSpecials", "GetItemPrice" }, "2.95")] - [InlineData("InvokeFunctionToolWithApproval.yaml", new string[] { "GetItemPrice" }, "4.95")] + [InlineData("InvokeFunctionToolWithApproval.yaml", new string[] { "GetItemPrice" }, "4.9")] public Task ValidateInvokeFunctionToolAsync(string workflowFileName, string[] expectedFunctionCalls, string? expectedResultContains) => this.RunInvokeToolTestAsync(workflowFileName, expectedFunctionCalls, expectedResultContains); From de40b09b7cc4535c6e6c1dfd4ed931ac1ba03d48 Mon Sep 17 00:00:00 2001 From: Peter Ibekwe Date: Tue, 17 Feb 2026 15:00:20 -0800 Subject: [PATCH 10/12] Cleanup - Removed unused method in InvokeFunctionToolExecutor --- .../ObjectModel/InvokeFunctionToolExecutor.cs | 12 --------- .../InvokeFunctionToolExecutorTest.cs | 25 ------------------- 2 files changed, 37 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs index 3e20bb1fa1..099af80772 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs @@ -137,18 +137,6 @@ public async ValueTask CaptureResponseAsync( await context.RaiseCompletionEventAsync(this.Model, cancellationToken).ConfigureAwait(false); } - /// - /// Completes the action after processing the function result. - /// - /// The workflow context. - /// The action executor result. - /// A cancellation token. - /// A representing the asynchronous operation. - public async ValueTask CompleteAsync(IWorkflowContext context, ActionExecutorResult message, CancellationToken cancellationToken) - { - 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. 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 index 4eda8d7c39..917bd00472 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeFunctionToolExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeFunctionToolExecutorTest.cs @@ -263,31 +263,6 @@ public async Task InvokeFunctionToolCaptureResponseWithMultipleFunctionResultsAs #endregion - #region CompleteAsync Tests - - [Fact] - public async Task InvokeFunctionToolCompleteAsyncRaisesCompletionEventAsync() - { - // Arrange - this.State.InitializeSystem(); - InvokeFunctionTool model = this.CreateModel( - displayName: nameof(InvokeFunctionToolCompleteAsyncRaisesCompletionEventAsync), - functionName: "test_function"); - MockAgentProvider mockAgentProvider = new(); - InvokeFunctionToolExecutor action = new(model, mockAgentProvider.Object, this.State); - - // Act - WorkflowEvent[] events = await this.ExecuteAsync( - InvokeFunctionToolExecutor.Steps.Resume(action.Id), - action.CompleteAsync); - - // Assert - VerifyModel(model, action); - VerifyCompletionEvent(events); - } - - #endregion - #region Helper Methods private async Task ExecuteTestAsync(InvokeFunctionTool model) From 001e67f2a4b0a7f546abd359b2bf13edc05bc917 Mon Sep 17 00:00:00 2001 From: Peter Ibekwe Date: Tue, 17 Feb 2026 15:54:36 -0800 Subject: [PATCH 11/12] Update test based on PR feedback. --- .../ObjectModel/InvokeFunctionToolExecutorTest.cs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) 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 index 917bd00472..4a07ba3002 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeFunctionToolExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeFunctionToolExecutorTest.cs @@ -1,6 +1,5 @@ // Copyright (c) Microsoft. All rights reserved. -using System.Reflection; using System.Threading.Tasks; using Microsoft.Agents.AI.Workflows.Declarative.Events; using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel; @@ -278,11 +277,7 @@ private async Task ExecuteTestAsync(InvokeFunctionTool model) VerifyInvocationEvent(events); // IsDiscreteAction should be false for InvokeFunction - Assert.Equal( - false, - action.GetType().BaseType? - .GetProperty("IsDiscreteAction", BindingFlags.NonPublic | BindingFlags.Instance)? - .GetValue(action)); + VerifyIsDiscrete(action, isDiscrete: false); } private async Task ExecuteCaptureResponseTestAsync( From d8e4d6f1083a885b2407ce1993f57506e8c05751 Mon Sep 17 00:00:00 2001 From: Peter Ibekwe Date: Wed, 18 Feb 2026 13:29:13 -0800 Subject: [PATCH 12/12] Update based on PR comments --- .../ObjectModel/InvokeFunctionToolExecutor.cs | 55 +++++++++++++++++-- 1 file changed, 50 insertions(+), 5 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs index 099af80772..615d4d88ab 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs @@ -198,20 +198,65 @@ private async ValueTask AssignResultAsync(IWorkflowContext context, FunctionResu { try { - JsonDocument jsonDocument = JsonDocument.Parse(jsonString); - Dictionary objectProperties = jsonDocument.ParseRecord(VariableType.RecordType); - await this.AssignAsync(this.Model.Output.Result?.Path, objectProperties.ToFormula(), context).ConfigureAwait(false); + 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 + catch (JsonException) { - // Not valid JSON, assign as string + // 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(