.NET Workflows - Add support for tool approval (#1685)

* Draft

* Nullable init

* Complete

* Consistency

* Test fix

* Typo

* Comment

* Updated

* Fix identifier

* Test fix

* Comment typo

* Better naming

* Comment

* Tweak comment
This commit is contained in:
Chris
2025-10-27 10:40:45 -07:00
committed by GitHub
Unverified
parent f8b427c6ec
commit 92925a8bc7
24 changed files with 424 additions and 146 deletions
@@ -336,9 +336,11 @@ internal sealed class Program
request.Data.TypeId.TypeName switch
{
// Request for human input
_ when request.Data.TypeId.IsMatch<InputRequest>() => HandleInputRequest(request.DataAs<InputRequest>()!),
_ when request.Data.TypeId.IsMatch<AnswerRequest>() => HandleUserMessageRequest(request.DataAs<AnswerRequest>()!),
// Request for function tool invocation. (Only active when functions are defined and IncludeFunctions is true.)
_ when request.Data.TypeId.IsMatch<AgentToolRequest>() => await this.HandleToolRequestAsync(request.DataAs<AgentToolRequest>()!),
_ when request.Data.TypeId.IsMatch<AgentFunctionToolRequest>() => await this.HandleToolRequestAsync(request.DataAs<AgentFunctionToolRequest>()!),
// Request for user input, such as function or mcp tool approval
_ when request.Data.TypeId.IsMatch<UserInputRequest>() => HandleUserInputRequest(request.DataAs<UserInputRequest>()!),
// Unknown request type.
_ => throw new InvalidOperationException($"Unsupported external request type: {request.GetType().Name}."),
};
@@ -346,7 +348,7 @@ internal sealed class Program
/// <summary>
/// Handle request for human input.
/// </summary>
private static InputResponse HandleInputRequest(InputRequest request)
private static AnswerResponse HandleUserMessageRequest(AnswerRequest request)
{
string? userInput;
do
@@ -358,7 +360,7 @@ internal sealed class Program
}
while (string.IsNullOrWhiteSpace(userInput));
return new InputResponse(userInput);
return new AnswerResponse(userInput);
}
/// <summary>
@@ -368,13 +370,13 @@ internal sealed class Program
/// This handler is only active when <see cref="IncludeFunctions"/> is set to true and
/// one or more <see cref="AIFunction"/> instances are defined in the constructor.
/// </remarks>
private async ValueTask<AgentToolResponse> HandleToolRequestAsync(AgentToolRequest request)
private async ValueTask<AgentFunctionToolResponse> HandleToolRequestAsync(AgentFunctionToolRequest request)
{
Task<FunctionResultContent>[] functionTasks = request.FunctionCalls.Select(functionCall => InvokesToolAsync(functionCall)).ToArray();
await Task.WhenAll(functionTasks);
return AgentToolResponse.Create(request, functionTasks.Select(task => task.Result));
return AgentFunctionToolResponse.Create(request, functionTasks.Select(task => task.Result));
async Task<FunctionResultContent> InvokesToolAsync(FunctionCallContent functionCall)
{
@@ -385,6 +387,30 @@ internal sealed class Program
}
}
/// <summary>
/// Handle request for user input for mcp and function tool approval.
/// </summary>
private static UserInputResponse HandleUserInputRequest(UserInputRequest request)
{
return UserInputResponse.Create(request, ProcessRequests());
IEnumerable<UserInputResponseContent> ProcessRequests()
{
foreach (UserInputRequestContent approvalRequest in request.InputRequests)
{
// Here we are explicitly approving all requests.
// In a real-world scenario, you would replace this logic to either solicit user approval or implement a more complex approval process.
yield return
approvalRequest switch
{
McpServerToolApprovalRequestContent mcpApprovalRequest => mcpApprovalRequest.CreateResponse(approved: true),
FunctionApprovalRequestContent functionApprovalRequest => functionApprovalRequest.CreateResponse(approved: true),
_ => throw new NotSupportedException($"Unsupported request of type {approvalRequest.GetType().Name}"),
};
}
}
}
private static string? ParseWorkflowFile(string[] args)
{
string? workflowFile = args.FirstOrDefault();
@@ -7,9 +7,9 @@ using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Declarative.Events;
/// <summary>
/// Represents a request for user input.
/// Represents one or more function tool requests.
/// </summary>
public sealed class AgentToolRequest
public sealed class AgentFunctionToolRequest
{
/// <summary>
/// The name of the agent associated with the tool request.
@@ -17,14 +17,14 @@ public sealed class AgentToolRequest
public string AgentName { get; }
/// <summary>
/// A list of tool requests.
/// A list of function tool requests.
/// </summary>
public IList<FunctionCallContent> FunctionCalls { get; }
[JsonConstructor]
internal AgentToolRequest(string agentName, IList<FunctionCallContent>? functionCalls = null)
internal AgentFunctionToolRequest(string agentName, IList<FunctionCallContent> functionCalls)
{
this.AgentName = agentName;
this.FunctionCalls = functionCalls ?? [];
this.FunctionCalls = functionCalls;
}
}
@@ -8,9 +8,9 @@ using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Declarative.Events;
/// <summary>
/// Represents a user input response.
/// Represents one or more function tool responses.
/// </summary>
public sealed class AgentToolResponse
public sealed class AgentFunctionToolResponse
{
/// <summary>
/// The name of the agent associated with the tool response.
@@ -22,32 +22,31 @@ public sealed class AgentToolResponse
/// </summary>
public IList<FunctionResultContent> FunctionResults { get; }
/// <summary>
/// Initializes a new instance of the <see cref="InputResponse"/> class.
/// </summary>
[JsonConstructor]
internal AgentToolResponse(string agentName, IList<FunctionResultContent> functionResults)
internal AgentFunctionToolResponse(string agentName, IList<FunctionResultContent> functionResults)
{
this.AgentName = agentName;
this.FunctionResults = functionResults;
}
/// <summary>
/// Factory method to create an <see cref="AgentToolResponse"/> from an <see cref="AgentToolRequest"/>
/// Factory method to create an <see cref="AgentFunctionToolResponse"/> from an <see cref="AgentFunctionToolRequest"/>
/// Ensures that all function calls in the request have a corresponding result.
/// </summary>
/// <param name="toolRequest">The tool request.</param>
/// <param name="functionResults">On or more function results</param>
/// <returns>An <see cref="AgentToolResponse"/> that can be provided to the workflow.</returns>
/// <exception cref="DeclarativeActionException">Not all <see cref="AgentToolRequest.FunctionCalls"/> have a corresponding <see cref="FunctionResultContent"/>.</exception>
public static AgentToolResponse Create(AgentToolRequest toolRequest, params IEnumerable<FunctionResultContent> functionResults)
/// <param name="functionResults">One or more function results</param>
/// <returns>An <see cref="AgentFunctionToolResponse"/> that can be provided to the workflow.</returns>
/// <exception cref="DeclarativeActionException">Not all <see cref="AgentFunctionToolRequest.FunctionCalls"/> have a corresponding <see cref="FunctionResultContent"/>.</exception>
public static AgentFunctionToolResponse Create(AgentFunctionToolRequest toolRequest, params IEnumerable<FunctionResultContent> functionResults)
{
HashSet<string> callIds = [.. toolRequest.FunctionCalls.Select(call => call.CallId)];
HashSet<string> resultIds = [.. functionResults.Select(call => call.CallId)];
if (!callIds.SetEquals(resultIds))
{
throw new DeclarativeActionException($"Missing results for: {string.Join(",", callIds.Except(resultIds))}");
}
return new AgentToolResponse(toolRequest.AgentName, [.. functionResults]);
return new AgentFunctionToolResponse(toolRequest.AgentName, [.. functionResults]);
}
}
@@ -0,0 +1,27 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Workflows.Declarative.Events;
/// <summary>
/// Represents a request for user input in response to a `Question` action.
/// </summary>
public sealed class AnswerRequest
{
/// <summary>
/// An optional prompt for the user.
/// </summary>
/// <remarks>
/// This prompt is utilized for the "Question" action type in the Declarative Workflow,
/// but is redundant when the user is responding to an agent since the agent's message
/// is the implicit prompt.
/// </remarks>
public string? Prompt { get; }
[JsonConstructor]
internal AnswerRequest(string? prompt = null)
{
this.Prompt = prompt;
}
}
@@ -8,7 +8,7 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.Events;
/// <summary>
/// Represents a user input response.
/// </summary>
public sealed class InputResponse
public sealed class AnswerResponse
{
/// <summary>
/// The response value.
@@ -16,20 +16,20 @@ public sealed class InputResponse
public ChatMessage Value { get; }
/// <summary>
/// Initializes a new instance of the <see cref="InputResponse"/> class.
/// Initializes a new instance of the <see cref="AnswerResponse"/> class.
/// </summary>
/// <param name="value">The response value.</param>
[JsonConstructor]
public InputResponse(ChatMessage value)
public AnswerResponse(ChatMessage value)
{
this.Value = value;
}
/// <summary>
/// Initializes a new instance of the <see cref="InputResponse"/> class.
/// Initializes a new instance of the <see cref="AnswerResponse"/> class.
/// </summary>
/// <param name="value">The response value.</param>
public InputResponse(string value)
public AnswerResponse(string value)
{
this.Value = new ChatMessage(ChatRole.User, value);
}
@@ -1,22 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Workflows.Declarative.Events;
/// <summary>
/// Represents a request for user input.
/// </summary>
public sealed class InputRequest
{
/// <summary>
/// The prompt message to display to the user.
/// </summary>
public string Prompt { get; }
[JsonConstructor]
internal InputRequest(string prompt)
{
this.Prompt = prompt;
}
}
@@ -0,0 +1,30 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json.Serialization;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Declarative.Events;
/// <summary>
/// Represents one or more user-input requests.
/// </summary>
public sealed class UserInputRequest
{
/// <summary>
/// The name of the agent associated with the tool request.
/// </summary>
public string AgentName { get; }
/// <summary>
/// A list of user input requests.
/// </summary>
public IList<AIContent> InputRequests { get; }
[JsonConstructor]
internal UserInputRequest(string agentName, IList<AIContent> inputRequests)
{
this.AgentName = agentName;
this.InputRequests = inputRequests;
}
}
@@ -0,0 +1,52 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Serialization;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Declarative.Events;
/// <summary>
/// Represents one or more user-input responses.
/// </summary>
public sealed class UserInputResponse
{
/// <summary>
/// The name of the agent associated with the tool request.
/// </summary>
public string AgentName { get; }
/// <summary>
/// A list of approval responses.
/// </summary>
public IList<AIContent> InputResponses { get; }
[JsonConstructor]
internal UserInputResponse(string agentName, IList<AIContent> inputResponses)
{
this.AgentName = agentName;
this.InputResponses = inputResponses;
}
/// <summary>
/// Factory method to create an <see cref="UserInputResponse"/> from a <see cref="UserInputRequest"/>
/// Ensures that all requests have a corresponding result.
/// </summary>
/// <param name="inputRequest">The input request.</param>
/// <param name="inputResponses">One or more responses</param>
/// <returns>An <see cref="UserInputResponse"/> that can be provided to the workflow.</returns>
/// <exception cref="DeclarativeActionException">Not all <see cref="AgentFunctionToolRequest.FunctionCalls"/> have a corresponding <see cref="FunctionResultContent"/>.</exception>
public static UserInputResponse Create(UserInputRequest inputRequest, params IEnumerable<UserInputResponseContent> inputResponses)
{
HashSet<string> callIds = [.. inputRequest.InputRequests.OfType<UserInputRequestContent>().Select(call => call.Id)];
HashSet<string> resultIds = [.. inputResponses.Select(call => call.Id)];
if (!callIds.SetEquals(resultIds))
{
throw new DeclarativeActionException($"Missing responses for: {string.Join(",", callIds.Except(resultIds))}");
}
return new UserInputResponse(inputRequest.AgentName, [.. inputResponses]);
}
}
@@ -249,13 +249,13 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor
// Define input action
string inputId = QuestionExecutor.Steps.Input(action.Id);
RequestPortAction inputPort = new(RequestPort.Create<InputRequest, InputResponse>(inputId));
RequestPortAction inputPort = new(RequestPort.Create<AnswerRequest, AnswerResponse>(inputId));
this._workflowModel.AddNode(inputPort, action.ParentId);
this._workflowModel.AddLinkFromPeer(action.ParentId, inputId);
// Capture input response
string captureId = QuestionExecutor.Steps.Capture(action.Id);
this.ContinueWith(new DelegateActionExecutor<InputResponse>(captureId, this._workflowState, action.CaptureResponseAsync, emitResult: false), action.ParentId);
this.ContinueWith(new DelegateActionExecutor<AnswerResponse>(captureId, this._workflowState, action.CaptureResponseAsync, emitResult: false), action.ParentId);
// Transition to post action if complete
this.ContinueWith(new DelegateActionExecutor(postId, this._workflowState, action.CompleteAsync), action.ParentId, QuestionExecutor.IsComplete);
@@ -315,22 +315,31 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor
this.ContinueWith(action);
// Transition to post action if complete
string postId = Steps.Post(action.Id);
this._workflowModel.AddLink(action.Id, postId, result => !InvokeAzureAgentExecutor.RequiresInput(result));
this._workflowModel.AddLink(action.Id, postId, InvokeAzureAgentExecutor.RequiresNothing);
// Define input action
string inputId = InvokeAzureAgentExecutor.Steps.Input(action.Id);
RequestPortAction inputPort = new(RequestPort.Create<AgentToolRequest, AgentToolResponse>(inputId));
this._workflowModel.AddNode(inputPort, action.ParentId);
this._workflowModel.AddLink(action.Id, inputId, InvokeAzureAgentExecutor.RequiresInput);
// Define request-port for function calling action
string functionCallingPortId = InvokeAzureAgentExecutor.Steps.FunctionTool(action.Id);
RequestPortAction functionCallingPort = new(RequestPort.Create<AgentFunctionToolRequest, AgentFunctionToolResponse>(functionCallingPortId));
this._workflowModel.AddNode(functionCallingPort, action.ParentId);
this._workflowModel.AddLink(action.Id, functionCallingPort.Id, InvokeAzureAgentExecutor.RequiresFunctionCall);
// Input port always transitions to resume
// Define request-port for user input, such as: mcp tool & function tool approval
string userInputPortId = InvokeAzureAgentExecutor.Steps.UserInput(action.Id);
RequestPortAction userInputPort = new(RequestPort.Create<UserInputRequest, UserInputResponse>(userInputPortId));
this._workflowModel.AddNode(userInputPort, action.ParentId);
this._workflowModel.AddLink(action.Id, userInputPortId, InvokeAzureAgentExecutor.RequiresUserInput);
// Request ports always transitions to resume
string resumeId = InvokeAzureAgentExecutor.Steps.Resume(action.Id);
this._workflowModel.AddNode(new DelegateActionExecutor<AgentToolResponse>(resumeId, this._workflowState, action.ResumeAsync), action.ParentId);
this._workflowModel.AddLink(inputId, resumeId);
// Transition to request port if more input is required
this._workflowModel.AddLink(resumeId, inputId, InvokeAzureAgentExecutor.RequiresInput);
this._workflowModel.AddNode(new DelegateActionExecutor<AgentFunctionToolResponse>(resumeId, this._workflowState, action.ResumeAsync), action.ParentId);
this._workflowModel.AddLink(functionCallingPortId, resumeId);
this._workflowModel.AddLink(userInputPortId, resumeId);
// Transition to appropriate request port if more function calling is requested
this._workflowModel.AddLink(resumeId, functionCallingPortId, InvokeAzureAgentExecutor.RequiresFunctionCall);
// Transition to appropriate request port if more user input is requested
this._workflowModel.AddLink(resumeId, userInputPortId, InvokeAzureAgentExecutor.RequiresUserInput);
// Transition to post action if complete
this._workflowModel.AddLink(resumeId, postId, result => !InvokeAzureAgentExecutor.RequiresInput(result));
this._workflowModel.AddLink(resumeId, postId, InvokeAzureAgentExecutor.RequiresNothing);
// Define post action
this._workflowModel.AddNode(new DelegateActionExecutor(postId, this._workflowState, action.CompleteAsync), action.ParentId);
@@ -4,6 +4,7 @@
<TargetFrameworks>$(ProjectsTargetFrameworks)</TargetFrameworks>
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsDebugTargetFrameworks)</TargetFrameworks>
<VersionSuffix>preview</VersionSuffix>
<NoWarn>$(NoWarn);MEAI001</NoWarn>
</PropertyGroup>
<PropertyGroup>
@@ -21,12 +21,16 @@ internal sealed class InvokeAzureAgentExecutor(InvokeAzureAgent model, WorkflowA
{
public static class Steps
{
public static string Input(string id) => $"{id}_{nameof(Input)}";
public static string UserInput(string id) => $"{id}_{nameof(UserInput)}";
public static string FunctionTool(string id) => $"{id}_{nameof(FunctionTool)}";
public static string Resume(string id) => $"{id}_{nameof(Resume)}";
}
// Input is requested by a message other than ActionExecutorResult.
public static bool RequiresInput(object? message) => message is not ActionExecutorResult;
public static bool RequiresFunctionCall(object? message) => message is AgentFunctionToolRequest;
public static bool RequiresUserInput(object? message) => message is UserInputRequest;
public static bool RequiresNothing(object? message) => message is ActionExecutorResult;
private AzureAgentUsage AgentUsage => Throw.IfNull(this.Model.Agent, $"{nameof(this.Model)}.{nameof(this.Model.Agent)}");
private AzureAgentInput? AgentInput => this.Model.Input;
@@ -42,7 +46,7 @@ internal sealed class InvokeAzureAgentExecutor(InvokeAzureAgent model, WorkflowA
return default;
}
public ValueTask ResumeAsync(IWorkflowContext context, AgentToolResponse message, CancellationToken cancellationToken) =>
public ValueTask ResumeAsync(IWorkflowContext context, AgentFunctionToolResponse message, CancellationToken cancellationToken) =>
this.InvokeAgentAsync(context, [message.FunctionResults.ToChatMessage()], cancellationToken);
public async ValueTask CompleteAsync(IWorkflowContext context, ActionExecutorResult message, CancellationToken cancellationToken)
@@ -64,12 +68,20 @@ internal sealed class InvokeAzureAgentExecutor(InvokeAzureAgent model, WorkflowA
if (string.IsNullOrEmpty(agentResponse.Text))
{
// Identify function calls that have no associated result.
List<FunctionCallContent> functionCalls = this.GetOrphanedFunctionCalls(agentResponse);
isComplete = functionCalls.Count == 0;
if (!isComplete)
List<UserInputRequestContent> inputRequests = GetUserInputRequests(agentResponse);
if (inputRequests.Count > 0)
{
AgentToolRequest toolRequest = new(agentName, functionCalls);
isComplete = false;
UserInputRequest approvalRequest = new(agentName, inputRequests.OfType<AIContent>().ToArray());
await context.SendMessageAsync(approvalRequest, targetId: null, cancellationToken).ConfigureAwait(false);
}
// Identify function calls that have no associated result.
List<FunctionCallContent> functionCalls = GetOrphanedFunctionCalls(agentResponse);
if (functionCalls.Count > 0)
{
isComplete = false;
AgentFunctionToolRequest toolRequest = new(agentName, functionCalls);
await context.SendMessageAsync(toolRequest, targetId: null, cancellationToken).ConfigureAwait(false);
}
}
@@ -95,7 +107,7 @@ internal sealed class InvokeAzureAgentExecutor(InvokeAzureAgent model, WorkflowA
return userInput?.ToChatMessages();
}
private List<FunctionCallContent> GetOrphanedFunctionCalls(AgentRunResponse agentResponse)
private static List<FunctionCallContent> GetOrphanedFunctionCalls(AgentRunResponse agentResponse)
{
HashSet<string> functionResultIds =
[.. agentResponse.Messages
@@ -117,6 +129,9 @@ internal sealed class InvokeAzureAgentExecutor(InvokeAzureAgent model, WorkflowA
return functionCalls;
}
private static List<UserInputRequestContent> GetUserInputRequests(AgentRunResponse agentResponse) =>
agentResponse.Messages.SelectMany(m => m.Contents.OfType<UserInputRequestContent>()).ToList();
private string? GetConversationId()
{
if (this.Model.ConversationId is null)
@@ -75,12 +75,12 @@ internal sealed class QuestionExecutor(Question model, WorkflowAgentProvider age
public async ValueTask PrepareResponseAsync(IWorkflowContext context, ActionExecutorResult message, CancellationToken cancellationToken)
{
int count = await this._promptCount.ReadAsync(context).ConfigureAwait(false);
InputRequest inputRequest = new(this.FormatPrompt(this.Model.Prompt));
AnswerRequest inputRequest = new(this.FormatPrompt(this.Model.Prompt));
await context.SendMessageAsync(inputRequest, targetId: null, cancellationToken).ConfigureAwait(false);
await this._promptCount.WriteAsync(context, count + 1).ConfigureAwait(false);
}
public async ValueTask CaptureResponseAsync(IWorkflowContext context, InputResponse message, CancellationToken cancellationToken)
public async ValueTask CaptureResponseAsync(IWorkflowContext context, AnswerResponse message, CancellationToken cancellationToken)
{
FormulaValue? extractedValue = null;
if (message.Value is null)
@@ -16,8 +16,8 @@ public abstract class WorkflowAgentProvider
/// <summary>
/// Gets or sets a collection of additional tools an agent is able to automatically invoke.
/// If an agent is configured with a function tool that is not available, a <see cref="RequestPort"/> is executed
/// that provides an <see cref="AgentToolRequest"/> that describes the function calls requested. The caller may
/// then respond with a corrsponding <see cref="AgentToolResponse"/> that includes the results of the function calls.
/// that provides an <see cref="AgentFunctionToolRequest"/> that describes the function calls requested. The caller may
/// then respond with a corrsponding <see cref="AgentFunctionToolResponse"/> that includes the results of the function calls.
/// </summary>
/// <remarks>
/// These will not impact the requests sent to the model by the <see cref="FunctionInvokingChatClient"/>.
@@ -31,7 +31,7 @@ internal sealed class WorkflowHarness(Workflow workflow, string runId)
string inputText = testcase.Setup.Responses[responseCount].Value;
Console.WriteLine($"INPUT: {inputText}");
++responseCount;
WorkflowEvents runEvents = await this.ResumeAsync(new InputResponse(inputText)).ConfigureAwait(false);
WorkflowEvents runEvents = await this.ResumeAsync(new AnswerResponse(inputText)).ConfigureAwait(false);
workflowEvents = new WorkflowEvents([.. workflowEvents.Events, .. runEvents.Events]);
requestCount = (workflowEvents.InputEvents.Count + 1) / 2;
}
@@ -19,7 +19,7 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests;
/// <summary>
/// Tests execution of workflow created by <see cref="DeclarativeWorkflowBuilder"/>.
/// </summary>
public sealed class ToolInputWorkflowTest(ITestOutputHelper output) : IntegrationTest(output)
public sealed class FunctionCallingWorkflowTest(ITestOutputHelper output) : IntegrationTest(output)
{
[Fact]
public Task ValidateAutoInvokeAsync() =>
@@ -47,7 +47,7 @@ public sealed class ToolInputWorkflowTest(ITestOutputHelper output) : Integratio
Assert.False(autoInvoke);
RequestInfoEvent inputEvent = workflowEvents.InputEvents[workflowEvents.InputEvents.Count - 1];
AgentToolRequest? toolRequest = inputEvent.Request.Data.As<AgentToolRequest>();
AgentFunctionToolRequest? toolRequest = inputEvent.Request.Data.As<AgentFunctionToolRequest>();
Assert.NotNull(toolRequest);
List<(FunctionCallContent, AIFunction)> functionCalls = [];
@@ -66,7 +66,7 @@ public sealed class ToolInputWorkflowTest(ITestOutputHelper output) : Integratio
++responseCount;
WorkflowEvents runEvents = await harness.ResumeAsync(AgentToolResponse.Create(toolRequest, functionResults)).ConfigureAwait(false);
WorkflowEvents runEvents = await harness.ResumeAsync(AgentFunctionToolResponse.Create(toolRequest, functionResults)).ConfigureAwait(false);
workflowEvents = new WorkflowEvents([.. workflowEvents.Events, .. runEvents.Events]);
}
@@ -0,0 +1,48 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using Microsoft.Agents.AI.Workflows.Declarative.Events;
using Microsoft.Extensions.AI;
using Xunit.Abstractions;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.Events;
/// <summary>
/// Verify <see cref="AgentFunctionToolRequest"/> class
/// </summary>
public sealed class AgentFunctionToolRequestTest(ITestOutputHelper output) : EventTest(output)
{
[Fact]
public void VerifySerializationEmpty()
{
// Arrange & Act
AgentFunctionToolRequest copy = VerifyEventSerialization(new AgentFunctionToolRequest("testagent", []));
// Assert
Assert.Equal("testagent", copy.AgentName);
Assert.Empty(copy.FunctionCalls);
}
[Fact]
public void VerifySerializationWithRequests()
{
// Arrange & Act
AgentFunctionToolRequest copy =
VerifyEventSerialization(
new AgentFunctionToolRequest(
"agent",
[
new FunctionCallContent("call1", "result1"),
new FunctionCallContent("call2", "result2", new Dictionary<string, object?>() { { "name", "Clam Chowder" } }),
]));
// Assert
Assert.Equal("agent", copy.AgentName);
Assert.Equal(2, copy.FunctionCalls.Count);
Assert.IsType<FunctionCallContent>(copy.FunctionCalls[0]);
Assert.Null(copy.FunctionCalls[0].Arguments);
Assert.IsType<FunctionCallContent>(copy.FunctionCalls[1]);
Assert.NotNull(copy.FunctionCalls[1].Arguments);
Assert.NotEmpty(copy.FunctionCalls[1].Arguments!);
}
}
@@ -0,0 +1,46 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows.Declarative.Events;
using Microsoft.Extensions.AI;
using Xunit.Abstractions;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.Events;
/// <summary>
/// Verify <see cref="AgentFunctionToolResponse"/> class
/// </summary>
public sealed class AgentFunctionToolResponseTest(ITestOutputHelper output) : EventTest(output)
{
[Fact]
public void VerifySerializationEmpty()
{
// Arrange & Act
AgentFunctionToolResponse copy = VerifyEventSerialization(new AgentFunctionToolResponse("testagent", []));
// Assert
Assert.Equal("testagent", copy.AgentName);
Assert.Empty(copy.FunctionResults);
}
[Fact]
public void VerifySerializationWithResults()
{
// Arrange & Act
AgentFunctionToolResponse copy =
VerifyEventSerialization(
new AgentFunctionToolResponse(
"agent",
[
new FunctionResultContent("call1", "result1"),
new FunctionResultContent("call2", "result2"),
]));
// Assert
Assert.Equal("agent", copy.AgentName);
Assert.Equal(2, copy.FunctionResults.Count);
Assert.IsType<FunctionResultContent>(copy.FunctionResults[0]);
Assert.Equal("call1", copy.FunctionResults[0].CallId);
Assert.IsType<FunctionResultContent>(copy.FunctionResults[1]);
Assert.Equal("call2", copy.FunctionResults[1].CallId);
}
}
@@ -1,28 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using Microsoft.Agents.AI.Workflows.Declarative.Events;
using Microsoft.Extensions.AI;
using Xunit.Abstractions;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.Events;
/// <summary>
/// Base class for event tests.
/// </summary>
public sealed class AgentToolRequestTest(ITestOutputHelper output) : EventTest(output)
{
[Fact]
public void VerifySerialization()
{
AgentToolRequest copy =
VerifyEventSerialization(
new AgentToolRequest(
"agent",
[
new FunctionCallContent("call1", "result1"),
new FunctionCallContent("call2", "result2", new Dictionary<string, object?>() { { "name", "Clam Chowder" } })
]));
Assert.Equal("agent", copy.AgentName);
}
}
@@ -1,27 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows.Declarative.Events;
using Microsoft.Extensions.AI;
using Xunit.Abstractions;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.Events;
/// <summary>
/// Base class for event tests.
/// </summary>
public sealed class AgentToolResponseTest(ITestOutputHelper output) : EventTest(output)
{
[Fact]
public void VerifySerialization()
{
AgentToolResponse copy =
VerifyEventSerialization(
new AgentToolResponse(
"agent",
[
new FunctionResultContent("call1", "result1"),
new FunctionResultContent("call2", "result2")
]));
Assert.Equal("agent", copy.AgentName);
}
}
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using Microsoft.Extensions.AI;
using Xunit.Abstractions;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests;
@@ -12,9 +13,9 @@ public abstract class EventTest(ITestOutputHelper output) : WorkflowTest(output)
{
protected static TEvent VerifyEventSerialization<TEvent>(TEvent source)
{
string? text = JsonSerializer.Serialize(source);
string? text = JsonSerializer.Serialize(source, AIJsonUtilities.DefaultOptions);
Assert.NotNull(text);
TEvent? copy = JsonSerializer.Deserialize<TEvent>(text);
TEvent? copy = JsonSerializer.Deserialize<TEvent>(text, AIJsonUtilities.DefaultOptions);
Assert.NotNull(copy);
return copy;
}
@@ -0,0 +1,46 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows.Declarative.Events;
using Microsoft.Extensions.AI;
using Xunit.Abstractions;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.Events;
/// <summary>
/// Verify <see cref="UserInputRequest"/> class
/// </summary>
public sealed class UserInputRequestTest(ITestOutputHelper output) : EventTest(output)
{
[Fact]
public void VerifySerializationEmpty()
{
// Arrange & Act
UserInputRequest copy = VerifyEventSerialization(new UserInputRequest("test agent", []));
// Assert
Assert.Equal("test agent", copy.AgentName);
Assert.Empty(copy.InputRequests);
}
[Fact]
public void VerifySerializationWithRequests()
{
// Arrange & Act
UserInputRequest copy =
VerifyEventSerialization(
new UserInputRequest(
"agent",
[
new McpServerToolApprovalRequestContent("call1", new McpServerToolCallContent("call1", "testmcp", "server-name")),
new FunctionApprovalRequestContent("call2", new FunctionCallContent("call2", "result1")),
]));
// Assert
Assert.Equal("agent", copy.AgentName);
Assert.Equal(2, copy.InputRequests.Count);
McpServerToolApprovalRequestContent mcpRequest = Assert.IsType<McpServerToolApprovalRequestContent>(copy.InputRequests[0]);
Assert.Equal("call1", mcpRequest.Id);
FunctionApprovalRequestContent functionRequest = Assert.IsType<FunctionApprovalRequestContent>(copy.InputRequests[1]);
Assert.Equal("call2", functionRequest.Id);
}
}
@@ -0,0 +1,46 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows.Declarative.Events;
using Microsoft.Extensions.AI;
using Xunit.Abstractions;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.Events;
/// <summary>
/// Verify <see cref="UserInputResponse"/> class
/// </summary>
public sealed class UserInputResponseTest(ITestOutputHelper output) : EventTest(output)
{
[Fact]
public void VerifySerializationEmpty()
{
// Arrange & Act
UserInputResponse copy = VerifyEventSerialization(new UserInputResponse("testagent", []));
// Assert
Assert.Equal("testagent", copy.AgentName);
Assert.Empty(copy.InputResponses);
}
[Fact]
public void VerifySerializationWithResponses()
{
// Arrange & Act
UserInputResponse copy =
VerifyEventSerialization(
new UserInputResponse(
"agent",
[
new McpServerToolApprovalRequestContent("call1", new McpServerToolCallContent("call1", "testmcp", "server-name")).CreateResponse(approved: true),
new FunctionApprovalRequestContent("call2", new FunctionCallContent("call2", "result1")).CreateResponse(approved: true),
]));
// Assert
Assert.Equal("agent", copy.AgentName);
Assert.Equal(2, copy.InputResponses.Count);
McpServerToolApprovalResponseContent mcpResponse = Assert.IsType<McpServerToolApprovalResponseContent>(copy.InputResponses[0]);
Assert.Equal("call1", mcpResponse.Id);
FunctionApprovalResponseContent functionResponse = Assert.IsType<FunctionApprovalResponseContent>(copy.InputResponses[1]);
Assert.Equal("call2", functionResponse.Id);
}
}
@@ -6,14 +6,17 @@ using Xunit.Abstractions;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.Events;
/// <summary>
/// Base class for event tests.
/// Verify <see cref="AnswerRequest"/> class
/// </summary>
public sealed class InputRequestTest(ITestOutputHelper output) : EventTest(output)
public sealed class UserMessageRequestTest(ITestOutputHelper output) : EventTest(output)
{
[Fact]
public void VerifySerialization()
{
InputRequest copy = VerifyEventSerialization(new InputRequest("wassup"));
// Arrange & Act
AnswerRequest copy = VerifyEventSerialization(new AnswerRequest("wassup"));
// Assert
Assert.Equal("wassup", copy.Prompt);
}
}
@@ -7,21 +7,27 @@ using Xunit.Abstractions;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.Events;
/// <summary>
/// Base class for event tests.
/// Verify <see cref="AnswerResponse"/> class
/// </summary>
public sealed class InputResponseTest(ITestOutputHelper output) : EventTest(output)
public sealed class UserMessageResponseTest(ITestOutputHelper output) : EventTest(output)
{
[Fact]
public void VerifySerializationText()
{
InputResponse copy = VerifyEventSerialization(new InputResponse("test response"));
// Arrange & Act
AnswerResponse copy = VerifyEventSerialization(new AnswerResponse("test response"));
// Assert
Assert.Equal("test response", copy.Value.Text);
}
[Fact]
public void VerifySerializationMessage()
{
InputResponse copy = VerifyEventSerialization(new InputResponse(new ChatMessage(ChatRole.User, "test response")));
// Arrange & Act
AnswerResponse copy = VerifyEventSerialization(new AnswerResponse(new ChatMessage(ChatRole.User, "test response")));
// Assert
Assert.Equal("test response", copy.Value.Text);
}
}