Skip to content

Commit 92925a8

Browse files
authored
.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
1 parent f8b427c commit 92925a8

File tree

24 files changed

+423
-145
lines changed

24 files changed

+423
-145
lines changed

dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteWorkflow/Program.cs

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -336,17 +336,19 @@ private async ValueTask<object> HandleExternalRequestAsync(ExternalRequest reque
336336
request.Data.TypeId.TypeName switch
337337
{
338338
// Request for human input
339-
_ when request.Data.TypeId.IsMatch<InputRequest>() => HandleInputRequest(request.DataAs<InputRequest>()!),
339+
_ when request.Data.TypeId.IsMatch<AnswerRequest>() => HandleUserMessageRequest(request.DataAs<AnswerRequest>()!),
340340
// Request for function tool invocation. (Only active when functions are defined and IncludeFunctions is true.)
341-
_ when request.Data.TypeId.IsMatch<AgentToolRequest>() => await this.HandleToolRequestAsync(request.DataAs<AgentToolRequest>()!),
341+
_ when request.Data.TypeId.IsMatch<AgentFunctionToolRequest>() => await this.HandleToolRequestAsync(request.DataAs<AgentFunctionToolRequest>()!),
342+
// Request for user input, such as function or mcp tool approval
343+
_ when request.Data.TypeId.IsMatch<UserInputRequest>() => HandleUserInputRequest(request.DataAs<UserInputRequest>()!),
342344
// Unknown request type.
343345
_ => throw new InvalidOperationException($"Unsupported external request type: {request.GetType().Name}."),
344346
};
345347

346348
/// <summary>
347349
/// Handle request for human input.
348350
/// </summary>
349-
private static InputResponse HandleInputRequest(InputRequest request)
351+
private static AnswerResponse HandleUserMessageRequest(AnswerRequest request)
350352
{
351353
string? userInput;
352354
do
@@ -358,7 +360,7 @@ private static InputResponse HandleInputRequest(InputRequest request)
358360
}
359361
while (string.IsNullOrWhiteSpace(userInput));
360362

361-
return new InputResponse(userInput);
363+
return new AnswerResponse(userInput);
362364
}
363365

364366
/// <summary>
@@ -368,13 +370,13 @@ private static InputResponse HandleInputRequest(InputRequest request)
368370
/// This handler is only active when <see cref="IncludeFunctions"/> is set to true and
369371
/// one or more <see cref="AIFunction"/> instances are defined in the constructor.
370372
/// </remarks>
371-
private async ValueTask<AgentToolResponse> HandleToolRequestAsync(AgentToolRequest request)
373+
private async ValueTask<AgentFunctionToolResponse> HandleToolRequestAsync(AgentFunctionToolRequest request)
372374
{
373375
Task<FunctionResultContent>[] functionTasks = request.FunctionCalls.Select(functionCall => InvokesToolAsync(functionCall)).ToArray();
374376

375377
await Task.WhenAll(functionTasks);
376378

377-
return AgentToolResponse.Create(request, functionTasks.Select(task => task.Result));
379+
return AgentFunctionToolResponse.Create(request, functionTasks.Select(task => task.Result));
378380

379381
async Task<FunctionResultContent> InvokesToolAsync(FunctionCallContent functionCall)
380382
{
@@ -385,6 +387,30 @@ async Task<FunctionResultContent> InvokesToolAsync(FunctionCallContent functionC
385387
}
386388
}
387389

390+
/// <summary>
391+
/// Handle request for user input for mcp and function tool approval.
392+
/// </summary>
393+
private static UserInputResponse HandleUserInputRequest(UserInputRequest request)
394+
{
395+
return UserInputResponse.Create(request, ProcessRequests());
396+
397+
IEnumerable<UserInputResponseContent> ProcessRequests()
398+
{
399+
foreach (UserInputRequestContent approvalRequest in request.InputRequests)
400+
{
401+
// Here we are explicitly approving all requests.
402+
// In a real-world scenario, you would replace this logic to either solicit user approval or implement a more complex approval process.
403+
yield return
404+
approvalRequest switch
405+
{
406+
McpServerToolApprovalRequestContent mcpApprovalRequest => mcpApprovalRequest.CreateResponse(approved: true),
407+
FunctionApprovalRequestContent functionApprovalRequest => functionApprovalRequest.CreateResponse(approved: true),
408+
_ => throw new NotSupportedException($"Unsupported request of type {approvalRequest.GetType().Name}"),
409+
};
410+
}
411+
}
412+
}
413+
388414
private static string? ParseWorkflowFile(string[] args)
389415
{
390416
string? workflowFile = args.FirstOrDefault();

dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AgentToolRequest.cs renamed to dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AgentFunctionToolRequest.cs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,24 +7,24 @@
77
namespace Microsoft.Agents.AI.Workflows.Declarative.Events;
88

99
/// <summary>
10-
/// Represents a request for user input.
10+
/// Represents one or more function tool requests.
1111
/// </summary>
12-
public sealed class AgentToolRequest
12+
public sealed class AgentFunctionToolRequest
1313
{
1414
/// <summary>
1515
/// The name of the agent associated with the tool request.
1616
/// </summary>
1717
public string AgentName { get; }
1818

1919
/// <summary>
20-
/// A list of tool requests.
20+
/// A list of function tool requests.
2121
/// </summary>
2222
public IList<FunctionCallContent> FunctionCalls { get; }
2323

2424
[JsonConstructor]
25-
internal AgentToolRequest(string agentName, IList<FunctionCallContent>? functionCalls = null)
25+
internal AgentFunctionToolRequest(string agentName, IList<FunctionCallContent> functionCalls)
2626
{
2727
this.AgentName = agentName;
28-
this.FunctionCalls = functionCalls ?? [];
28+
this.FunctionCalls = functionCalls;
2929
}
3030
}

dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AgentToolResponse.cs renamed to dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AgentFunctionToolResponse.cs

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@
88
namespace Microsoft.Agents.AI.Workflows.Declarative.Events;
99

1010
/// <summary>
11-
/// Represents a user input response.
11+
/// Represents one or more function tool responses.
1212
/// </summary>
13-
public sealed class AgentToolResponse
13+
public sealed class AgentFunctionToolResponse
1414
{
1515
/// <summary>
1616
/// The name of the agent associated with the tool response.
@@ -22,32 +22,31 @@ public sealed class AgentToolResponse
2222
/// </summary>
2323
public IList<FunctionResultContent> FunctionResults { get; }
2424

25-
/// <summary>
26-
/// Initializes a new instance of the <see cref="InputResponse"/> class.
27-
/// </summary>
2825
[JsonConstructor]
29-
internal AgentToolResponse(string agentName, IList<FunctionResultContent> functionResults)
26+
internal AgentFunctionToolResponse(string agentName, IList<FunctionResultContent> functionResults)
3027
{
3128
this.AgentName = agentName;
3229
this.FunctionResults = functionResults;
3330
}
3431

3532
/// <summary>
36-
/// Factory method to create an <see cref="AgentToolResponse"/> from an <see cref="AgentToolRequest"/>
33+
/// Factory method to create an <see cref="AgentFunctionToolResponse"/> from an <see cref="AgentFunctionToolRequest"/>
3734
/// Ensures that all function calls in the request have a corresponding result.
3835
/// </summary>
3936
/// <param name="toolRequest">The tool request.</param>
40-
/// <param name="functionResults">On or more function results</param>
41-
/// <returns>An <see cref="AgentToolResponse"/> that can be provided to the workflow.</returns>
42-
/// <exception cref="DeclarativeActionException">Not all <see cref="AgentToolRequest.FunctionCalls"/> have a corresponding <see cref="FunctionResultContent"/>.</exception>
43-
public static AgentToolResponse Create(AgentToolRequest toolRequest, params IEnumerable<FunctionResultContent> functionResults)
37+
/// <param name="functionResults">One or more function results</param>
38+
/// <returns>An <see cref="AgentFunctionToolResponse"/> that can be provided to the workflow.</returns>
39+
/// <exception cref="DeclarativeActionException">Not all <see cref="AgentFunctionToolRequest.FunctionCalls"/> have a corresponding <see cref="FunctionResultContent"/>.</exception>
40+
public static AgentFunctionToolResponse Create(AgentFunctionToolRequest toolRequest, params IEnumerable<FunctionResultContent> functionResults)
4441
{
4542
HashSet<string> callIds = [.. toolRequest.FunctionCalls.Select(call => call.CallId)];
4643
HashSet<string> resultIds = [.. functionResults.Select(call => call.CallId)];
44+
4745
if (!callIds.SetEquals(resultIds))
4846
{
4947
throw new DeclarativeActionException($"Missing results for: {string.Join(",", callIds.Except(resultIds))}");
5048
}
51-
return new AgentToolResponse(toolRequest.AgentName, [.. functionResults]);
49+
50+
return new AgentFunctionToolResponse(toolRequest.AgentName, [.. functionResults]);
5251
}
5352
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
using System.Text.Json.Serialization;
4+
5+
namespace Microsoft.Agents.AI.Workflows.Declarative.Events;
6+
7+
/// <summary>
8+
/// Represents a request for user input in response to a `Question` action.
9+
/// </summary>
10+
public sealed class AnswerRequest
11+
{
12+
/// <summary>
13+
/// An optional prompt for the user.
14+
/// </summary>
15+
/// <remarks>
16+
/// This prompt is utilized for the "Question" action type in the Declarative Workflow,
17+
/// but is redundant when the user is responding to an agent since the agent's message
18+
/// is the implicit prompt.
19+
/// </remarks>
20+
public string? Prompt { get; }
21+
22+
[JsonConstructor]
23+
internal AnswerRequest(string? prompt = null)
24+
{
25+
this.Prompt = prompt;
26+
}
27+
}

dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/InputResponse.cs renamed to dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AnswerResponse.cs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,28 +8,28 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.Events;
88
/// <summary>
99
/// Represents a user input response.
1010
/// </summary>
11-
public sealed class InputResponse
11+
public sealed class AnswerResponse
1212
{
1313
/// <summary>
1414
/// The response value.
1515
/// </summary>
1616
public ChatMessage Value { get; }
1717

1818
/// <summary>
19-
/// Initializes a new instance of the <see cref="InputResponse"/> class.
19+
/// Initializes a new instance of the <see cref="AnswerResponse"/> class.
2020
/// </summary>
2121
/// <param name="value">The response value.</param>
2222
[JsonConstructor]
23-
public InputResponse(ChatMessage value)
23+
public AnswerResponse(ChatMessage value)
2424
{
2525
this.Value = value;
2626
}
2727

2828
/// <summary>
29-
/// Initializes a new instance of the <see cref="InputResponse"/> class.
29+
/// Initializes a new instance of the <see cref="AnswerResponse"/> class.
3030
/// </summary>
3131
/// <param name="value">The response value.</param>
32-
public InputResponse(string value)
32+
public AnswerResponse(string value)
3333
{
3434
this.Value = new ChatMessage(ChatRole.User, value);
3535
}

dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/InputRequest.cs

Lines changed: 0 additions & 22 deletions
This file was deleted.
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
using System.Collections.Generic;
4+
using System.Text.Json.Serialization;
5+
using Microsoft.Extensions.AI;
6+
7+
namespace Microsoft.Agents.AI.Workflows.Declarative.Events;
8+
9+
/// <summary>
10+
/// Represents one or more user-input requests.
11+
/// </summary>
12+
public sealed class UserInputRequest
13+
{
14+
/// <summary>
15+
/// The name of the agent associated with the tool request.
16+
/// </summary>
17+
public string AgentName { get; }
18+
19+
/// <summary>
20+
/// A list of user input requests.
21+
/// </summary>
22+
public IList<AIContent> InputRequests { get; }
23+
24+
[JsonConstructor]
25+
internal UserInputRequest(string agentName, IList<AIContent> inputRequests)
26+
{
27+
this.AgentName = agentName;
28+
this.InputRequests = inputRequests;
29+
}
30+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
using System.Collections.Generic;
4+
using System.Linq;
5+
using System.Text.Json.Serialization;
6+
using Microsoft.Extensions.AI;
7+
8+
namespace Microsoft.Agents.AI.Workflows.Declarative.Events;
9+
10+
/// <summary>
11+
/// Represents one or more user-input responses.
12+
/// </summary>
13+
public sealed class UserInputResponse
14+
{
15+
/// <summary>
16+
/// The name of the agent associated with the tool request.
17+
/// </summary>
18+
public string AgentName { get; }
19+
20+
/// <summary>
21+
/// A list of approval responses.
22+
/// </summary>
23+
public IList<AIContent> InputResponses { get; }
24+
25+
[JsonConstructor]
26+
internal UserInputResponse(string agentName, IList<AIContent> inputResponses)
27+
{
28+
this.AgentName = agentName;
29+
this.InputResponses = inputResponses;
30+
}
31+
32+
/// <summary>
33+
/// Factory method to create an <see cref="UserInputResponse"/> from a <see cref="UserInputRequest"/>
34+
/// Ensures that all requests have a corresponding result.
35+
/// </summary>
36+
/// <param name="inputRequest">The input request.</param>
37+
/// <param name="inputResponses">One or more responses</param>
38+
/// <returns>An <see cref="UserInputResponse"/> that can be provided to the workflow.</returns>
39+
/// <exception cref="DeclarativeActionException">Not all <see cref="AgentFunctionToolRequest.FunctionCalls"/> have a corresponding <see cref="FunctionResultContent"/>.</exception>
40+
public static UserInputResponse Create(UserInputRequest inputRequest, params IEnumerable<UserInputResponseContent> inputResponses)
41+
{
42+
HashSet<string> callIds = [.. inputRequest.InputRequests.OfType<UserInputRequestContent>().Select(call => call.Id)];
43+
HashSet<string> resultIds = [.. inputResponses.Select(call => call.Id)];
44+
45+
if (!callIds.SetEquals(resultIds))
46+
{
47+
throw new DeclarativeActionException($"Missing responses for: {string.Join(",", callIds.Except(resultIds))}");
48+
}
49+
50+
return new UserInputResponse(inputRequest.AgentName, [.. inputResponses]);
51+
}
52+
}

dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -249,13 +249,13 @@ protected override void Visit(Question item)
249249

250250
// Define input action
251251
string inputId = QuestionExecutor.Steps.Input(action.Id);
252-
RequestPortAction inputPort = new(RequestPort.Create<InputRequest, InputResponse>(inputId));
252+
RequestPortAction inputPort = new(RequestPort.Create<AnswerRequest, AnswerResponse>(inputId));
253253
this._workflowModel.AddNode(inputPort, action.ParentId);
254254
this._workflowModel.AddLinkFromPeer(action.ParentId, inputId);
255255

256256
// Capture input response
257257
string captureId = QuestionExecutor.Steps.Capture(action.Id);
258-
this.ContinueWith(new DelegateActionExecutor<InputResponse>(captureId, this._workflowState, action.CaptureResponseAsync, emitResult: false), action.ParentId);
258+
this.ContinueWith(new DelegateActionExecutor<AnswerResponse>(captureId, this._workflowState, action.CaptureResponseAsync, emitResult: false), action.ParentId);
259259

260260
// Transition to post action if complete
261261
this.ContinueWith(new DelegateActionExecutor(postId, this._workflowState, action.CompleteAsync), action.ParentId, QuestionExecutor.IsComplete);
@@ -315,22 +315,31 @@ protected override void Visit(InvokeAzureAgent item)
315315
this.ContinueWith(action);
316316
// Transition to post action if complete
317317
string postId = Steps.Post(action.Id);
318-
this._workflowModel.AddLink(action.Id, postId, result => !InvokeAzureAgentExecutor.RequiresInput(result));
318+
this._workflowModel.AddLink(action.Id, postId, InvokeAzureAgentExecutor.RequiresNothing);
319319

320-
// Define input action
321-
string inputId = InvokeAzureAgentExecutor.Steps.Input(action.Id);
322-
RequestPortAction inputPort = new(RequestPort.Create<AgentToolRequest, AgentToolResponse>(inputId));
323-
this._workflowModel.AddNode(inputPort, action.ParentId);
324-
this._workflowModel.AddLink(action.Id, inputId, InvokeAzureAgentExecutor.RequiresInput);
320+
// Define request-port for function calling action
321+
string functionCallingPortId = InvokeAzureAgentExecutor.Steps.FunctionTool(action.Id);
322+
RequestPortAction functionCallingPort = new(RequestPort.Create<AgentFunctionToolRequest, AgentFunctionToolResponse>(functionCallingPortId));
323+
this._workflowModel.AddNode(functionCallingPort, action.ParentId);
324+
this._workflowModel.AddLink(action.Id, functionCallingPort.Id, InvokeAzureAgentExecutor.RequiresFunctionCall);
325+
326+
// Define request-port for user input, such as: mcp tool & function tool approval
327+
string userInputPortId = InvokeAzureAgentExecutor.Steps.UserInput(action.Id);
328+
RequestPortAction userInputPort = new(RequestPort.Create<UserInputRequest, UserInputResponse>(userInputPortId));
329+
this._workflowModel.AddNode(userInputPort, action.ParentId);
330+
this._workflowModel.AddLink(action.Id, userInputPortId, InvokeAzureAgentExecutor.RequiresUserInput);
325331

326-
// Input port always transitions to resume
332+
// Request ports always transitions to resume
327333
string resumeId = InvokeAzureAgentExecutor.Steps.Resume(action.Id);
328-
this._workflowModel.AddNode(new DelegateActionExecutor<AgentToolResponse>(resumeId, this._workflowState, action.ResumeAsync), action.ParentId);
329-
this._workflowModel.AddLink(inputId, resumeId);
330-
// Transition to request port if more input is required
331-
this._workflowModel.AddLink(resumeId, inputId, InvokeAzureAgentExecutor.RequiresInput);
334+
this._workflowModel.AddNode(new DelegateActionExecutor<AgentFunctionToolResponse>(resumeId, this._workflowState, action.ResumeAsync), action.ParentId);
335+
this._workflowModel.AddLink(functionCallingPortId, resumeId);
336+
this._workflowModel.AddLink(userInputPortId, resumeId);
337+
// Transition to appropriate request port if more function calling is requested
338+
this._workflowModel.AddLink(resumeId, functionCallingPortId, InvokeAzureAgentExecutor.RequiresFunctionCall);
339+
// Transition to appropriate request port if more user input is requested
340+
this._workflowModel.AddLink(resumeId, userInputPortId, InvokeAzureAgentExecutor.RequiresUserInput);
332341
// Transition to post action if complete
333-
this._workflowModel.AddLink(resumeId, postId, result => !InvokeAzureAgentExecutor.RequiresInput(result));
342+
this._workflowModel.AddLink(resumeId, postId, InvokeAzureAgentExecutor.RequiresNothing);
334343

335344
// Define post action
336345
this._workflowModel.AddNode(new DelegateActionExecutor(postId, this._workflowState, action.CompleteAsync), action.ParentId);

dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Microsoft.Agents.AI.Workflows.Declarative.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
<TargetFrameworks>$(ProjectsTargetFrameworks)</TargetFrameworks>
55
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsDebugTargetFrameworks)</TargetFrameworks>
66
<VersionSuffix>preview</VersionSuffix>
7+
<NoWarn>$(NoWarn);MEAI001</NoWarn>
78
</PropertyGroup>
89

910
<PropertyGroup>

0 commit comments

Comments
 (0)