.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 17:40:45 +00:00
committed by GitHub
parent f8b427c6ec
commit 92925a8bc7
24 changed files with 424 additions and 146 deletions
@@ -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"/>.