mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.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:
+5
-5
@@ -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;
|
||||
}
|
||||
}
|
||||
+11
-12
@@ -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;
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -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]);
|
||||
}
|
||||
}
|
||||
+23
-14
@@ -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);
|
||||
|
||||
+1
@@ -4,6 +4,7 @@
|
||||
<TargetFrameworks>$(ProjectsTargetFrameworks)</TargetFrameworks>
|
||||
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsDebugTargetFrameworks)</TargetFrameworks>
|
||||
<VersionSuffix>preview</VersionSuffix>
|
||||
<NoWarn>$(NoWarn);MEAI001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
+25
-10
@@ -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)
|
||||
|
||||
+2
-2
@@ -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"/>.
|
||||
|
||||
Reference in New Issue
Block a user