Use native tool approval in workflow sample
Build the approval workflow around one expense-review agent with an explicit checklist and approval-required submission tool. Handle the native approval request/response pair through IChatClient, deduplicate the workflow-correlated request, and map generic interrupt responses unconditionally to function results. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 681114d3-3e0d-47f8-8b87-4e5873ecdd6d
This commit is contained in:
@@ -6,7 +6,7 @@ using Microsoft.Extensions.AI;
|
||||
|
||||
string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888";
|
||||
using HttpClient httpClient = new() { Timeout = TimeSpan.FromSeconds(60) };
|
||||
IChatClient chatClient = new AGUIChatClient(new(httpClient, serverUrl));
|
||||
using IChatClient chatClient = CreateChatClient(httpClient, serverUrl);
|
||||
|
||||
Console.Write("Request: ");
|
||||
string request = Console.ReadLine() ?? "Write a short welcome message for a developer conference.";
|
||||
@@ -31,3 +31,6 @@ await foreach (ChatResponseUpdate update in chatClient.GetStreamingResponseAsync
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
|
||||
static IChatClient CreateChatClient(HttpClient httpClient, string serverUrl)
|
||||
=> new AGUIChatClient(new(httpClient, serverUrl));
|
||||
|
||||
@@ -6,7 +6,7 @@ using Microsoft.Extensions.AI;
|
||||
|
||||
string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888";
|
||||
using HttpClient httpClient = new() { Timeout = TimeSpan.FromSeconds(60) };
|
||||
IChatClient chatClient = new AGUIChatClient(new(httpClient, serverUrl));
|
||||
using IChatClient chatClient = CreateChatClient(httpClient, serverUrl);
|
||||
|
||||
Console.Write("Request: ");
|
||||
string request = Console.ReadLine() ?? "Assess the tradeoffs of adopting a new framework.";
|
||||
@@ -31,3 +31,6 @@ await foreach (ChatResponseUpdate update in chatClient.GetStreamingResponseAsync
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
|
||||
static IChatClient CreateChatClient(HttpClient httpClient, string serverUrl)
|
||||
=> new AGUIChatClient(new(httpClient, serverUrl));
|
||||
|
||||
@@ -6,7 +6,7 @@ using Microsoft.Extensions.AI;
|
||||
|
||||
string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888";
|
||||
using HttpClient httpClient = new() { Timeout = TimeSpan.FromSeconds(60) };
|
||||
IChatClient chatClient = new AGUIChatClient(new(httpClient, serverUrl));
|
||||
using IChatClient chatClient = CreateChatClient(httpClient, serverUrl);
|
||||
|
||||
await foreach (ChatResponseUpdate update in chatClient.GetStreamingResponseAsync(
|
||||
[new ChatMessage(ChatRole.User, "Run the failing workflow.")]))
|
||||
@@ -24,3 +24,6 @@ await foreach (ChatResponseUpdate update in chatClient.GetStreamingResponseAsync
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
static IChatClient CreateChatClient(HttpClient httpClient, string serverUrl)
|
||||
=> new AGUIChatClient(new(httpClient, serverUrl));
|
||||
|
||||
@@ -6,7 +6,7 @@ using Microsoft.Extensions.AI;
|
||||
|
||||
string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888";
|
||||
using HttpClient httpClient = new() { Timeout = TimeSpan.FromSeconds(60) };
|
||||
IChatClient chatClient = new AGUIChatClient(new(httpClient, serverUrl));
|
||||
using IChatClient chatClient = CreateChatClient(httpClient, serverUrl);
|
||||
|
||||
await foreach (ChatResponseUpdate update in chatClient.GetStreamingResponseAsync(
|
||||
[new ChatMessage(ChatRole.User, "What is the weather in Seattle?")]))
|
||||
@@ -39,3 +39,6 @@ await foreach (ChatResponseUpdate update in chatClient.GetStreamingResponseAsync
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
|
||||
static IChatClient CreateChatClient(HttpClient httpClient, string serverUrl)
|
||||
=> new AGUIChatClient(new(httpClient, serverUrl));
|
||||
|
||||
@@ -6,7 +6,7 @@ using Microsoft.Extensions.AI;
|
||||
|
||||
string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888";
|
||||
using HttpClient httpClient = new() { Timeout = TimeSpan.FromSeconds(60) };
|
||||
IChatClient chatClient = new AGUIChatClient(new(httpClient, serverUrl));
|
||||
using IChatClient chatClient = CreateChatClient(httpClient, serverUrl);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -35,3 +35,6 @@ catch (InvalidOperationException exception)
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
|
||||
static IChatClient CreateChatClient(HttpClient httpClient, string serverUrl)
|
||||
=> new AGUIChatClient(new(httpClient, serverUrl));
|
||||
|
||||
@@ -1,46 +1,60 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using AGUI.Abstractions;
|
||||
using AGUI.Client;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888";
|
||||
using HttpClient httpClient = new() { Timeout = TimeSpan.FromSeconds(60) };
|
||||
using IChatClient chatClient = new AGUIChatClient(new(httpClient, serverUrl));
|
||||
using IChatClient chatClient = CreateChatClient(httpClient, serverUrl);
|
||||
ChatOptions options = new();
|
||||
|
||||
var expenseReport = new
|
||||
{
|
||||
id = "EXP-100",
|
||||
employee = "Taylor",
|
||||
amount = 125.00m,
|
||||
businessPurpose = "Developer conference registration",
|
||||
receiptAttached = true,
|
||||
};
|
||||
string reportJson = JsonSerializer.Serialize(expenseReport);
|
||||
|
||||
List<ChatResponseUpdate> firstTurn = await chatClient
|
||||
.GetStreamingResponseAsync([new ChatMessage(ChatRole.User, "Submit expense EXP-100.")])
|
||||
.GetStreamingResponseAsync(
|
||||
[new ChatMessage(ChatRole.User, $"Review and submit this expense report:\n{reportJson}")],
|
||||
options)
|
||||
.ToListAsync();
|
||||
RunFinishedEvent finished = firstTurn.Select(static update => update.RawRepresentation)
|
||||
.OfType<RunFinishedEvent>()
|
||||
|
||||
#pragma warning disable MEAI001 // Tool approval content is experimental.
|
||||
ToolApprovalRequestContent approvalRequest = firstTurn
|
||||
.SelectMany(static update => update.Contents)
|
||||
.OfType<ToolApprovalRequestContent>()
|
||||
.Single();
|
||||
AGUIInterrupt interrupt = ((RunFinishedInterruptOutcome)finished.Outcome!).Interrupts.Single();
|
||||
FunctionCallContent toolCall = (FunctionCallContent)approvalRequest.ToolCall;
|
||||
|
||||
Console.Write($"{interrupt.Message ?? "Approve expense?"} [y/N]: ");
|
||||
Console.WriteLine($"The workflow completed its checks and wants to call {toolCall.Name}.");
|
||||
Console.Write($"Approve submission of expense {expenseReport.id}? [y/N]: ");
|
||||
bool approved = string.Equals(Console.ReadLine(), "y", StringComparison.OrdinalIgnoreCase);
|
||||
ToolApprovalResponseContent approvalResponse = approvalRequest.CreateResponse(
|
||||
approved,
|
||||
approved ? "Approved by the sample user." : "Rejected by the sample user.");
|
||||
|
||||
ChatOptions resumeOptions = new()
|
||||
List<ChatMessage> approvalMessages =
|
||||
[
|
||||
new(ChatRole.Assistant, [approvalRequest]),
|
||||
new(ChatRole.Tool, [approvalResponse]),
|
||||
];
|
||||
|
||||
await foreach (ChatResponseUpdate update in chatClient.GetStreamingResponseAsync(approvalMessages, options))
|
||||
{
|
||||
RawRepresentationFactory = _ => new RunAgentInput
|
||||
foreach (TextContent text in update.Contents.OfType<TextContent>())
|
||||
{
|
||||
Messages = [],
|
||||
ParentRunId = finished.RunId,
|
||||
Resume =
|
||||
[
|
||||
new AGUIResume
|
||||
{
|
||||
InterruptId = interrupt.Id,
|
||||
Payload = JsonSerializer.SerializeToElement(new { approved }),
|
||||
Status = "resolved",
|
||||
},
|
||||
],
|
||||
RunId = Guid.NewGuid().ToString("N"),
|
||||
ThreadId = finished.ThreadId,
|
||||
},
|
||||
};
|
||||
Console.Write(text.Text);
|
||||
}
|
||||
}
|
||||
#pragma warning restore MEAI001
|
||||
|
||||
List<ChatResponseUpdate> secondTurn = await chatClient
|
||||
.GetStreamingResponseAsync([], resumeOptions)
|
||||
.ToListAsync();
|
||||
Console.WriteLine(string.Concat(secondTurn.Select(static update => update.Text)));
|
||||
Console.WriteLine();
|
||||
|
||||
static IChatClient CreateChatClient(HttpClient httpClient, string serverUrl)
|
||||
=> new AGUIChatClient(new(httpClient, serverUrl));
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
# Approval Workflow over AG-UI
|
||||
|
||||
This sample pauses a workflow for approval before submitting an expense. The AG-UI client reads the
|
||||
interruption from `RUN_FINISHED`, asks the user for a decision, and resumes the same workflow thread.
|
||||
This sample hosts a workflow containing one expense-review agent. The agent checks that the report has a
|
||||
business purpose, an attached receipt, a positive amount no greater than 500 USD, and a plausible business
|
||||
expense. If every check passes, it calls an approval-required `SubmitExpense` tool.
|
||||
|
||||
The AG-UI client sends the expense report, receives `ToolApprovalRequestContent`, creates the paired
|
||||
`ToolApprovalResponseContent`, and resumes the same workflow thread through `IChatClient`.
|
||||
|
||||
The sample uses an in-memory session store without user isolation for local demonstration only. Production
|
||||
hosts must isolate persisted sessions by authenticated principal.
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace AGUI.WorkflowApproval;
|
||||
|
||||
@@ -16,51 +11,25 @@ namespace AGUI.WorkflowApproval;
|
||||
public static class ApprovalWorkflow
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a workflow that pauses for approval before submitting an expense.
|
||||
/// Creates a workflow containing one expense-review agent.
|
||||
/// </summary>
|
||||
/// <returns>The approval workflow.</returns>
|
||||
public static Workflow Create()
|
||||
{
|
||||
ExpenseApprovalExecutor executor = new();
|
||||
return new WorkflowBuilder(executor)
|
||||
.AddExternalCall<ExpenseApprovalRequest, JsonElement>(executor, "ApprovalInput")
|
||||
.WithOutputFrom(executor)
|
||||
.Build();
|
||||
}
|
||||
/// <param name="expenseReviewer">The agent that checks and submits expense reports.</param>
|
||||
/// <returns>The expense approval workflow.</returns>
|
||||
public static Workflow Create(AIAgent expenseReviewer)
|
||||
=> new SequentialWorkflowBuilder(expenseReviewer).Build();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The expense approval request presented to the client.
|
||||
/// An expense report submitted to the workflow.
|
||||
/// </summary>
|
||||
/// <param name="ExpenseId">The expense identifier.</param>
|
||||
/// <param name="Amount">The expense amount.</param>
|
||||
public sealed record ExpenseApprovalRequest(string ExpenseId, decimal Amount);
|
||||
|
||||
[SendsMessage(typeof(ExpenseApprovalRequest))]
|
||||
internal sealed partial class ExpenseApprovalExecutor()
|
||||
: ChatProtocolExecutor("ExpenseApproval", new ChatProtocolExecutorOptions { AutoSendTurnToken = false })
|
||||
{
|
||||
protected override ValueTask TakeTurnAsync(
|
||||
List<ChatMessage> messages,
|
||||
IWorkflowContext context,
|
||||
bool? emitEvents,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> context.SendMessageAsync(new ExpenseApprovalRequest("EXP-100", 125.00m), cancellationToken);
|
||||
|
||||
[MessageHandler]
|
||||
public async ValueTask HandleApprovalAsync(
|
||||
JsonElement response,
|
||||
IWorkflowContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
bool approved = response.GetProperty("approved").GetBoolean();
|
||||
string result = approved ? "Expense approved and submitted." : "Expense rejected.";
|
||||
AgentResponseUpdate update = new(ChatRole.Assistant, result)
|
||||
{
|
||||
MessageId = "expense-result",
|
||||
ResponseId = "expense-response",
|
||||
};
|
||||
await context.AddEventAsync(new AgentResponseUpdateEvent(this.Id, update), cancellationToken).ConfigureAwait(false);
|
||||
await context.SendMessageAsync(new TurnToken(false), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
/// <param name="Id">The report identifier.</param>
|
||||
/// <param name="Employee">The submitting employee.</param>
|
||||
/// <param name="Amount">The total expense amount.</param>
|
||||
/// <param name="BusinessPurpose">The business purpose.</param>
|
||||
/// <param name="ReceiptAttached">Whether a receipt is attached.</param>
|
||||
public sealed record ExpenseReport(
|
||||
string Id,
|
||||
string Employee,
|
||||
decimal Amount,
|
||||
string BusinessPurpose,
|
||||
bool ReceiptAttached);
|
||||
|
||||
@@ -1,19 +1,56 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.ComponentModel;
|
||||
using AGUI.WorkflowApproval;
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Hosting;
|
||||
using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI.Chat;
|
||||
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddHttpClient().AddLogging();
|
||||
builder.Services.AddAGUIServer();
|
||||
builder.Services.AddAIAgent(
|
||||
"ApprovalWorkflow",
|
||||
static (_, _) => ApprovalWorkflow.Create().AsAIAgent(
|
||||
name: "ApprovalWorkflow",
|
||||
includeWorkflowOutputsInResponse: true))
|
||||
|
||||
string endpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"]
|
||||
?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
string deploymentName = builder.Configuration["AZURE_OPENAI_DEPLOYMENT_NAME"]
|
||||
?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT_NAME is not set.");
|
||||
|
||||
[Description("Submits an expense report after the user approves the operation.")]
|
||||
static string SubmitExpense(ExpenseReport report)
|
||||
=> $"Expense report {report.Id} for {report.Employee} was submitted.";
|
||||
|
||||
#pragma warning disable MEAI001 // ApprovalRequiredAIFunction is experimental.
|
||||
AITool submitExpense = new ApprovalRequiredAIFunction(AIFunctionFactory.Create(SubmitExpense));
|
||||
#pragma warning restore MEAI001
|
||||
|
||||
ChatClient chatClient = new AzureOpenAIClient(
|
||||
new Uri(endpoint),
|
||||
new DefaultAzureCredential())
|
||||
.GetChatClient(deploymentName);
|
||||
|
||||
AIAgent expenseReviewer = chatClient.AsAIAgent(
|
||||
name: "ExpenseReviewer",
|
||||
instructions: """
|
||||
Review the expense report supplied by the user. Perform every check below:
|
||||
1. The report has a non-empty business purpose.
|
||||
2. A receipt is attached.
|
||||
3. The amount is positive and no greater than 500 USD.
|
||||
4. The expense is plausibly business-related.
|
||||
|
||||
If any check fails, explain every failed check and do not call SubmitExpense.
|
||||
If all checks pass, call SubmitExpense with the complete report. The tool requires user approval.
|
||||
""",
|
||||
tools: [submitExpense]);
|
||||
|
||||
Workflow workflow = ApprovalWorkflow.Create(expenseReviewer);
|
||||
AIAgent workflowAgent = workflow.AsAIAgent(name: "ApprovalWorkflow");
|
||||
|
||||
builder.Services.AddAIAgent("ApprovalWorkflow", (_, _) => workflowAgent)
|
||||
.WithInMemorySessionStore(withIsolation: false);
|
||||
|
||||
WebApplication app = builder.Build();
|
||||
|
||||
@@ -7,13 +7,15 @@
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows.Generators\Microsoft.Agents.AI.Workflows.Generators.csproj"
|
||||
OutputItemType="Analyzer"
|
||||
ReferenceOutputAssembly="false"
|
||||
GlobalPropertiesToRemove="TargetFramework" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -7,7 +7,7 @@ using Microsoft.Extensions.AI;
|
||||
|
||||
string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888";
|
||||
using HttpClient httpClient = new() { Timeout = TimeSpan.FromSeconds(60) };
|
||||
using IChatClient chatClient = new AGUIChatClient(new(httpClient, serverUrl));
|
||||
using IChatClient chatClient = CreateChatClient(httpClient, serverUrl);
|
||||
|
||||
List<ChatResponseUpdate> firstTurn = await chatClient
|
||||
.GetStreamingResponseAsync([new ChatMessage(ChatRole.User, "Plan my conference trip.")])
|
||||
@@ -62,3 +62,6 @@ static AGUIResume Resume(AGUIInterrupt interrupt, object payload)
|
||||
Payload = JsonSerializer.SerializeToElement(payload),
|
||||
Status = "resolved",
|
||||
};
|
||||
|
||||
static IChatClient CreateChatClient(HttpClient httpClient, string serverUrl)
|
||||
=> new AGUIChatClient(new(httpClient, serverUrl));
|
||||
|
||||
+1
-3
@@ -147,9 +147,7 @@ public static class AGUIEndpointRouteBuilderExtensions
|
||||
|
||||
var session = await hostAgent.GetOrCreateSessionAsync(threadId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
IEnumerable<ChatMessage> messages = aiAgent.GetService<Microsoft.Agents.AI.Workflows.Workflow>() is not null
|
||||
? ctx.Messages.MapAGUIInterruptResponsesToWorkflow()
|
||||
: ctx.Messages;
|
||||
IEnumerable<ChatMessage> messages = ctx.Messages.MapAGUIInterruptResponsesToFunctionResults();
|
||||
|
||||
var events = hostAgent
|
||||
.RunStreamingAsync(
|
||||
|
||||
@@ -18,11 +18,20 @@ internal static class WorkflowAGUIExtensions
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(updates);
|
||||
List<ChatResponseUpdate> interruptions = [];
|
||||
List<ChatResponseUpdate> uncorrelatedApprovalRequests = [];
|
||||
|
||||
await foreach (ChatResponseUpdate update in updates.ConfigureAwait(false))
|
||||
{
|
||||
switch (update.RawRepresentation)
|
||||
{
|
||||
case AgentResponseUpdate { RawRepresentation: RequestInfoEvent }
|
||||
when update.Contents.OfType<ToolApprovalRequestContent>().SingleOrDefault() is { } approvalRequest:
|
||||
uncorrelatedApprovalRequests.RemoveAll(candidate =>
|
||||
candidate.Contents.OfType<ToolApprovalRequestContent>().Any(candidateRequest =>
|
||||
candidateRequest.ToolCall.CallId == approvalRequest.ToolCall.CallId));
|
||||
yield return update;
|
||||
break;
|
||||
|
||||
case AgentResponseUpdate { RawRepresentation: RequestInfoEvent requestInfo }
|
||||
when update.Contents.OfType<FunctionCallContent>().SingleOrDefault() is { } request:
|
||||
update.Contents =
|
||||
@@ -63,6 +72,10 @@ internal static class WorkflowAGUIExtensions
|
||||
includeContents: false);
|
||||
break;
|
||||
|
||||
case var _ when update.Contents.OfType<ToolApprovalRequestContent>().Any():
|
||||
uncorrelatedApprovalRequests.Add(update);
|
||||
break;
|
||||
|
||||
default:
|
||||
yield return update;
|
||||
break;
|
||||
@@ -73,9 +86,17 @@ internal static class WorkflowAGUIExtensions
|
||||
{
|
||||
yield return interruption;
|
||||
}
|
||||
|
||||
foreach (ChatResponseUpdate approvalRequest in uncorrelatedApprovalRequests)
|
||||
{
|
||||
yield return approvalRequest;
|
||||
}
|
||||
}
|
||||
#pragma warning restore VSTHRD200
|
||||
|
||||
// TODO: Remove this adapter after consuming an AG-UI .NET release containing
|
||||
// https://github.com/ag-ui-protocol/ag-ui/pull/2455, which makes RUN_ERROR terminal
|
||||
// and prevents the SDK from appending RUN_FINISHED(success).
|
||||
internal static async IAsyncEnumerable<BaseEvent> MakeRunErrorTerminalAsync(
|
||||
this IAsyncEnumerable<BaseEvent> events)
|
||||
{
|
||||
@@ -212,7 +233,7 @@ internal static class WorkflowAGUIExtensions
|
||||
ContinuationToken = update.ContinuationToken,
|
||||
};
|
||||
|
||||
internal static List<ChatMessage> MapAGUIInterruptResponsesToWorkflow(
|
||||
internal static List<ChatMessage> MapAGUIInterruptResponsesToFunctionResults(
|
||||
this IEnumerable<ChatMessage> messages)
|
||||
=> [.. messages.Select(static message =>
|
||||
{
|
||||
|
||||
@@ -55,12 +55,6 @@ internal sealed class WorkflowHostAgent : AIAgent
|
||||
public override string? Name { get; }
|
||||
public override string? Description { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public override object? GetService(Type serviceType, object? serviceKey = null)
|
||||
=> serviceKey is null && serviceType == typeof(Workflow)
|
||||
? this._workflow
|
||||
: base.GetService(serviceType, serviceKey);
|
||||
|
||||
private string GenerateNewId()
|
||||
{
|
||||
string result;
|
||||
|
||||
+103
-50
@@ -1,13 +1,11 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AGUI.Abstractions;
|
||||
using AGUI.Client;
|
||||
using AGUI.WorkflowApproval;
|
||||
using FluentAssertions;
|
||||
@@ -19,61 +17,116 @@ namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests.Workflows
|
||||
public sealed class ApprovalWorkflowTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ClientApprovesInterruptionAndWorkflowResumesAsync()
|
||||
public async Task ClientApprovesToolRequestAndWorkflowResumesAsync()
|
||||
{
|
||||
// Arrange
|
||||
AIAgent workflowAgent = ApprovalWorkflow.Create().AsAIAgent(
|
||||
name: "ApprovalWorkflow",
|
||||
includeWorkflowOutputsInResponse: true);
|
||||
Workflow workflow = ApprovalWorkflow.Create(new DeterministicExpenseReviewer());
|
||||
AIAgent workflowAgent = workflow.AsAIAgent(name: "ApprovalWorkflow");
|
||||
await using WorkflowTestHost host = await WorkflowTestHost.StartAsync(workflowAgent, persistSession: true);
|
||||
RunAgentInput initialInput = new()
|
||||
{
|
||||
Messages = new[] { new ChatMessage(ChatRole.User, "submit") }.AsAGUIMessages().ToList(),
|
||||
RunId = "approval-run-1",
|
||||
ThreadId = "approval-thread",
|
||||
};
|
||||
using AGUIChatClient chatClient = new(new(host.Client, ""));
|
||||
ChatOptions options = new();
|
||||
ExpenseReport report = new(
|
||||
"EXP-100",
|
||||
"Taylor",
|
||||
125.00m,
|
||||
"Developer conference registration",
|
||||
ReceiptAttached: true);
|
||||
|
||||
// Act - initial run pauses for approval.
|
||||
List<BaseEvent> firstTurn = await SendAsync(host.Client, initialInput);
|
||||
RunFinishedEvent finished = firstTurn.OfType<RunFinishedEvent>().Single();
|
||||
RunFinishedInterruptOutcome outcome = finished.Outcome.Should()
|
||||
.BeOfType<RunFinishedInterruptOutcome>().Subject;
|
||||
AGUIInterrupt interrupt = outcome.Interrupts.Should().ContainSingle().Subject;
|
||||
interrupt.Reason.Should().Be(InterruptReasons.InputRequired);
|
||||
// Act - the reviewer requests approval to submit the report.
|
||||
List<ChatResponseUpdate> firstTurn = await chatClient
|
||||
.GetStreamingResponseAsync(
|
||||
[new ChatMessage(ChatRole.User, JsonSerializer.Serialize(report))],
|
||||
options)
|
||||
.ToListAsync();
|
||||
|
||||
RunAgentInput resumeInput = new()
|
||||
{
|
||||
Messages = [],
|
||||
ParentRunId = finished.RunId,
|
||||
Resume =
|
||||
[
|
||||
new AGUIResume
|
||||
{
|
||||
InterruptId = interrupt.Id,
|
||||
Payload = JsonSerializer.SerializeToElement(new { approved = true }),
|
||||
Status = "resolved",
|
||||
},
|
||||
],
|
||||
RunId = "approval-run-2",
|
||||
ThreadId = finished.ThreadId,
|
||||
};
|
||||
List<BaseEvent> secondTurn = await SendAsync(host.Client, resumeInput);
|
||||
#pragma warning disable MEAI001 // Tool approval content is experimental.
|
||||
ToolApprovalRequestContent approvalRequest = firstTurn
|
||||
.SelectMany(static update => update.Contents)
|
||||
.OfType<ToolApprovalRequestContent>()
|
||||
.Single();
|
||||
FunctionCallContent toolCall = approvalRequest.ToolCall.Should()
|
||||
.BeOfType<FunctionCallContent>().Subject;
|
||||
toolCall.Name.Should().Be("SubmitExpense");
|
||||
|
||||
ToolApprovalResponseContent approvalResponse = approvalRequest.CreateResponse(
|
||||
approved: true,
|
||||
reason: "Approved by integration test.");
|
||||
List<ChatMessage> approvalMessages =
|
||||
[
|
||||
new(ChatRole.Assistant, [approvalRequest]),
|
||||
new(ChatRole.Tool, [approvalResponse]),
|
||||
];
|
||||
List<ChatResponseUpdate> secondTurn = await chatClient
|
||||
.GetStreamingResponseAsync(approvalMessages, options)
|
||||
.ToListAsync();
|
||||
#pragma warning restore MEAI001
|
||||
|
||||
// Assert
|
||||
string text = string.Concat(secondTurn.OfType<TextMessageContentEvent>().Select(static evt => evt.Delta));
|
||||
text.Should().Contain(
|
||||
"Expense approved and submitted.",
|
||||
"events were {0}",
|
||||
string.Join(", ", secondTurn.Select(static evt => evt.GetType().Name)));
|
||||
secondTurn.OfType<StepStartedEvent>()
|
||||
.Should().Contain(static evt => evt.StepName == "ExpenseApproval");
|
||||
secondTurn.Should().Contain(static update => update.Text == "Expense report EXP-100 was submitted.");
|
||||
}
|
||||
|
||||
private static async Task<List<BaseEvent>> SendAsync(HttpClient client, RunAgentInput input)
|
||||
private sealed class DeterministicExpenseReviewer : AIAgent
|
||||
{
|
||||
using JsonContent content = JsonContent.Create(input, AGUIJsonSerializerContext.Default.RunAgentInput);
|
||||
using HttpResponseMessage response = await client.PostAsync(new Uri("", UriKind.Relative), content);
|
||||
response.EnsureSuccessStatusCode();
|
||||
return await response.ReadAGUIEventStreamAsync().ToListAsync();
|
||||
public override string? Name => "ExpenseReviewer";
|
||||
|
||||
protected override Task<AgentResponse> RunCoreAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session = null,
|
||||
AgentRunOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> this.RunStreamingAsync(messages, session, options, cancellationToken).ToAgentResponseAsync(cancellationToken);
|
||||
|
||||
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session = null,
|
||||
AgentRunOptions? options = null,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
#pragma warning disable MEAI001 // Tool approval content is experimental.
|
||||
ToolApprovalResponseContent? approvalResponse = messages
|
||||
.SelectMany(static message => message.Contents)
|
||||
.OfType<ToolApprovalResponseContent>()
|
||||
.LastOrDefault();
|
||||
|
||||
if (approvalResponse is not null)
|
||||
{
|
||||
yield return CreateUpdate(approvalResponse.Approved
|
||||
? new TextContent("Expense report EXP-100 was submitted.")
|
||||
: new TextContent("Expense report EXP-100 was rejected."));
|
||||
yield break;
|
||||
}
|
||||
|
||||
FunctionCallContent toolCall = new(
|
||||
"submit-expense-call",
|
||||
"SubmitExpense",
|
||||
new Dictionary<string, object?> { ["reportId"] = "EXP-100" });
|
||||
yield return CreateUpdate(new ToolApprovalRequestContent("submit-expense-approval", toolCall));
|
||||
#pragma warning restore MEAI001
|
||||
await Task.Yield();
|
||||
}
|
||||
|
||||
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default)
|
||||
=> new(new ExpenseSession());
|
||||
|
||||
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(
|
||||
AgentSession session,
|
||||
JsonSerializerOptions? jsonSerializerOptions = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> new(JsonSerializer.SerializeToElement(new Dictionary<string, string>()));
|
||||
|
||||
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(
|
||||
JsonElement serializedState,
|
||||
JsonSerializerOptions? jsonSerializerOptions = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> new(new ExpenseSession());
|
||||
|
||||
private static AgentResponseUpdate CreateUpdate(AIContent content)
|
||||
=> new(ChatRole.Assistant, [content])
|
||||
{
|
||||
MessageId = "expense-review-message",
|
||||
ResponseId = "expense-review-response",
|
||||
};
|
||||
|
||||
private sealed class ExpenseSession : AgentSession;
|
||||
}
|
||||
}
|
||||
|
||||
+64
-1
@@ -3,6 +3,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using AGUI.Abstractions;
|
||||
using FluentAssertions;
|
||||
@@ -12,7 +13,7 @@ using Microsoft.Extensions.AI;
|
||||
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests workflow executor lifecycle mapping to AG-UI step events.
|
||||
/// Tests workflow lifecycle and interruption mapping to AG-UI.
|
||||
/// </summary>
|
||||
public sealed class WorkflowAGUIExtensionsTests
|
||||
{
|
||||
@@ -132,6 +133,58 @@ public sealed class WorkflowAGUIExtensionsTests
|
||||
result.Contents.Should().ContainSingle().Which.Should().BeSameAs(text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapAGUIInterruptResponsesToFunctionResults_MapsResponseUnconditionally()
|
||||
{
|
||||
// Arrange
|
||||
JsonElement payload = JsonSerializer.SerializeToElement(new { approved = true });
|
||||
ChatMessage message = new(
|
||||
ChatRole.User,
|
||||
[new InterruptResponseContent("request-1") { Payload = payload }]);
|
||||
|
||||
// Act
|
||||
List<ChatMessage> results = new[] { message }.MapAGUIInterruptResponsesToFunctionResults();
|
||||
|
||||
// Assert
|
||||
ChatMessage result = results.Should().ContainSingle().Subject;
|
||||
result.Role.Should().Be(ChatRole.User);
|
||||
FunctionResultContent functionResult = result.Contents.Should().ContainSingle()
|
||||
.Which.Should().BeOfType<FunctionResultContent>().Subject;
|
||||
functionResult.CallId.Should().Be("request-1");
|
||||
functionResult.Result.Should().Be(payload);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MapWorkflowEventsToAGUI_PrefersWorkflowCorrelatedApprovalRequestAsync()
|
||||
{
|
||||
// Arrange
|
||||
FunctionCallContent toolCall = new(
|
||||
"call-1",
|
||||
"SubmitExpense",
|
||||
new Dictionary<string, object?>());
|
||||
ToolApprovalRequestContent originalRequest = new("agent-request", toolCall);
|
||||
ToolApprovalRequestContent correlatedRequest = new("workflow-request", toolCall);
|
||||
AgentResponseUpdate original = CreateUpdate(raw: new object(), originalRequest);
|
||||
AgentResponseUpdate correlated = CreateUpdate(
|
||||
new RequestInfoEvent(ExternalRequest.Create(
|
||||
RequestPort.Create<ToolApprovalRequestContent, ToolApprovalResponseContent>("approval"),
|
||||
originalRequest,
|
||||
"workflow-request")),
|
||||
correlatedRequest);
|
||||
|
||||
// Act
|
||||
List<ChatResponseUpdate> results = await ToAsyncEnumerableAsync([original, correlated])
|
||||
.AsChatResponseUpdatesAsync()
|
||||
.MapWorkflowEventsToAGUI()
|
||||
.ToListAsync();
|
||||
|
||||
// Assert
|
||||
ToolApprovalRequestContent request = results.SelectMany(static update => update.Contents)
|
||||
.OfType<ToolApprovalRequestContent>()
|
||||
.Should().ContainSingle().Subject;
|
||||
request.RequestId.Should().Be("workflow-request");
|
||||
}
|
||||
|
||||
private static AgentResponseUpdate CreateUpdate(object raw, params AIContent[] contents)
|
||||
=> new(ChatRole.Assistant, contents)
|
||||
{
|
||||
@@ -149,6 +202,16 @@ public sealed class WorkflowAGUIExtensionsTests
|
||||
yield return update;
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<AgentResponseUpdate> ToAsyncEnumerableAsync(
|
||||
IEnumerable<AgentResponseUpdate> updates)
|
||||
{
|
||||
await Task.Yield();
|
||||
foreach (AgentResponseUpdate update in updates)
|
||||
{
|
||||
yield return update;
|
||||
}
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<ChatResponseUpdate> ToAsyncEnumerableAsync(
|
||||
ChatResponseUpdate update)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user