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