From 84148a1549895783cbabf75d4cc7955f9ba9b307 Mon Sep 17 00:00:00 2001 From: Shyju Krishnankutty Date: Mon, 23 Feb 2026 21:45:56 -0800 Subject: [PATCH 01/12] Add Azure Functions HITL workflow sample Add 06_WorkflowHITL Azure Functions sample demonstrating Human-in-the-Loop workflow support with HTTP endpoints for status checking and approval responses. The sample includes: - ExpenseReimbursement workflow with RequestPort for manager approval - Custom HTTP endpoint to check workflow status and pending approvals - Custom HTTP endpoint to send approval responses via RaiseEventAsync - demo.http file with step-by-step interaction examples --- dotnet/agent-framework-dotnet.slnx | 4 +- .../03_WorkflowHITL/03_WorkflowHITL.csproj | 43 +++++++ .../03_WorkflowHITL/Executors.cs | 43 +++++++ .../AzureFunctions/03_WorkflowHITL/Program.cs | 36 ++++++ .../AzureFunctions/03_WorkflowHITL/README.md | 58 ++++++++++ .../AzureFunctions/03_WorkflowHITL/demo.http | 32 ++++++ .../AzureFunctions/03_WorkflowHITL/host.json | 20 ++++ .../08_WorkflowHITL/08_WorkflowHITL.csproj | 28 +++++ .../ConsoleApps/08_WorkflowHITL/Executors.cs | 79 +++++++++++++ .../ConsoleApps/08_WorkflowHITL/Program.cs | 88 ++++++++++++++ .../ConsoleApps/08_WorkflowHITL/README.md | 77 +++++++++++++ .../DurableOptions.cs | 4 +- .../Microsoft.Agents.AI.DurableTask/Logs.cs | 18 ++- .../ServiceCollectionExtensions.cs | 10 +- .../Workflows/DurableExecutorDispatcher.cs | 62 +++++++++- .../Workflows/DurableStreamingWorkflowRun.cs | 101 +++++++++++++---- .../Workflows/DurableWorkflowCustomStatus.cs | 22 ---- .../Workflows/DurableWorkflowJsonContext.cs | 8 +- .../Workflows/DurableWorkflowLiveStatus.cs | 59 ++++++++++ .../Workflows/DurableWorkflowOptions.cs | 12 +- .../Workflows/DurableWorkflowRunner.cs | 36 +++--- .../DurableWorkflowWaitingForInputEvent.cs | 42 +++++++ .../Workflows/IStreamingWorkflowRun.cs | 15 ++- .../Workflows/PendingRequestPortStatus.cs | 12 ++ .../BuiltInFunctionExecutor.cs | 30 ++++- .../BuiltInFunctions.cs | 107 +++++++++++++++++- .../FunctionMetadataFactory.cs | 7 +- .../FunctionsApplicationBuilderExtensions.cs | 19 ++-- .../FunctionsDurableOptions.cs | 29 +++++ .../DurableWorkflowOptionsExtensions.cs | 30 +++++ ...bleWorkflowsFunctionMetadataTransformer.cs | 40 ++++++- .../WorkflowConsoleAppSamplesValidation.cs | 47 ++++++++ .../WorkflowSamplesValidation.cs | 69 ++++++++++- 33 files changed, 1187 insertions(+), 100 deletions(-) create mode 100644 dotnet/samples/Durable/Workflow/AzureFunctions/03_WorkflowHITL/03_WorkflowHITL.csproj create mode 100644 dotnet/samples/Durable/Workflow/AzureFunctions/03_WorkflowHITL/Executors.cs create mode 100644 dotnet/samples/Durable/Workflow/AzureFunctions/03_WorkflowHITL/Program.cs create mode 100644 dotnet/samples/Durable/Workflow/AzureFunctions/03_WorkflowHITL/README.md create mode 100644 dotnet/samples/Durable/Workflow/AzureFunctions/03_WorkflowHITL/demo.http create mode 100644 dotnet/samples/Durable/Workflow/AzureFunctions/03_WorkflowHITL/host.json create mode 100644 dotnet/samples/Durable/Workflow/ConsoleApps/08_WorkflowHITL/08_WorkflowHITL.csproj create mode 100644 dotnet/samples/Durable/Workflow/ConsoleApps/08_WorkflowHITL/Executors.cs create mode 100644 dotnet/samples/Durable/Workflow/ConsoleApps/08_WorkflowHITL/Program.cs create mode 100644 dotnet/samples/Durable/Workflow/ConsoleApps/08_WorkflowHITL/README.md delete mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowCustomStatus.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowLiveStatus.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowWaitingForInputEvent.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/PendingRequestPortStatus.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsDurableOptions.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/DurableWorkflowOptionsExtensions.cs diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 246b3e7e7b..1cee9a6ae6 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -55,10 +55,12 @@ + + @@ -475,4 +477,4 @@ - \ No newline at end of file + diff --git a/dotnet/samples/Durable/Workflow/AzureFunctions/03_WorkflowHITL/03_WorkflowHITL.csproj b/dotnet/samples/Durable/Workflow/AzureFunctions/03_WorkflowHITL/03_WorkflowHITL.csproj new file mode 100644 index 0000000000..c569deacd0 --- /dev/null +++ b/dotnet/samples/Durable/Workflow/AzureFunctions/03_WorkflowHITL/03_WorkflowHITL.csproj @@ -0,0 +1,43 @@ + + + net10.0 + v4 + Exe + enable + enable + + WorkflowHITLFunctions + WorkflowHITLFunctions + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dotnet/samples/Durable/Workflow/AzureFunctions/03_WorkflowHITL/Executors.cs b/dotnet/samples/Durable/Workflow/AzureFunctions/03_WorkflowHITL/Executors.cs new file mode 100644 index 0000000000..08ce2acde1 --- /dev/null +++ b/dotnet/samples/Durable/Workflow/AzureFunctions/03_WorkflowHITL/Executors.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.Workflows; + +namespace WorkflowHITLFunctions; + +/// Expense approval request passed to the RequestPort. +public record ApprovalRequest(string ExpenseId, decimal Amount, string EmployeeName); + +/// Approval response received from the RequestPort. +public record ApprovalResponse(bool Approved, string? Comments); + +/// Looks up expense details and creates an approval request. +internal sealed class CreateApprovalRequest() : Executor("RetrieveRequest") +{ + public override ValueTask HandleAsync( + string message, + IWorkflowContext context, + CancellationToken cancellationToken = default) + { + // In a real scenario, this would look up expense details from a database + return new ValueTask(new ApprovalRequest(message, 1500.00m, "Jerry")); + } +} + +/// Processes the expense reimbursement based on the approval decision. +internal sealed class ExpenseReimburse() : Executor("Reimburse") +{ + public override async ValueTask HandleAsync( + ApprovalResponse message, + IWorkflowContext context, + CancellationToken cancellationToken = default) + { + if (!message.Approved) + { + return $"Expense reimbursement denied. Comments: {message.Comments}"; + } + + // Simulate payment processing + await Task.Delay(1000, cancellationToken); + return $"Expense reimbursed at {DateTime.UtcNow:O}"; + } +} diff --git a/dotnet/samples/Durable/Workflow/AzureFunctions/03_WorkflowHITL/Program.cs b/dotnet/samples/Durable/Workflow/AzureFunctions/03_WorkflowHITL/Program.cs new file mode 100644 index 0000000000..53e1770e53 --- /dev/null +++ b/dotnet/samples/Durable/Workflow/AzureFunctions/03_WorkflowHITL/Program.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample demonstrates a Human-in-the-Loop (HITL) workflow hosted in Azure Functions. +// Workflow: CreateApprovalRequest -> ManagerApproval (RequestPort/HITL pause) -> ExpenseReimburse +// +// The workflow pauses at a RequestPort and waits for an external approval response via HTTP. +// The framework auto-generates three HTTP endpoints for each workflow: +// POST /api/workflows/{name}/run - Start the workflow +// GET /api/workflows/{name}/status/{id} - Check status and pending approvals +// POST /api/workflows/{name}/respond/{id} - Send approval response to resume + +using Microsoft.Agents.AI.Hosting.AzureFunctions; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Azure.Functions.Worker.Builder; +using Microsoft.Extensions.Hosting; +using WorkflowHITLFunctions; + +// Define executors and a RequestPort for the HITL pause point +CreateApprovalRequest createRequest = new(); +RequestPort managerApproval = RequestPort.Create("ManagerApproval"); +ExpenseReimburse reimburse = new(); + +// Build the workflow: CreateApprovalRequest -> ManagerApproval (HITL) -> ExpenseReimburse +Workflow expenseApproval = new WorkflowBuilder(createRequest) + .WithName("ExpenseReimbursement") + .WithDescription("Expense reimbursement with manager approval") + .AddEdge(createRequest, managerApproval) + .AddEdge(managerApproval, reimburse) + .Build(); + +using IHost app = FunctionsApplication + .CreateBuilder(args) + .ConfigureFunctionsWebApplication() + .ConfigureDurableWorkflows(workflows => workflows.AddWorkflow(expenseApproval, exposeStatusEndpoint: true)) + .Build(); +app.Run(); diff --git a/dotnet/samples/Durable/Workflow/AzureFunctions/03_WorkflowHITL/README.md b/dotnet/samples/Durable/Workflow/AzureFunctions/03_WorkflowHITL/README.md new file mode 100644 index 0000000000..77ab55cc8d --- /dev/null +++ b/dotnet/samples/Durable/Workflow/AzureFunctions/03_WorkflowHITL/README.md @@ -0,0 +1,58 @@ +# Human-in-the-Loop (HITL) Workflow — Azure Functions + +This sample demonstrates a durable workflow with Human-in-the-Loop support hosted in Azure Functions. The workflow pauses at a `RequestPort` and waits for an external approval response sent via an HTTP endpoint. + +## Key Concepts Demonstrated + +- Using `RequestPort` for human-in-the-loop interaction in a durable workflow +- Auto-generated HTTP endpoints for running workflows, checking status, and sending HITL responses +- Pausing orchestrations via `WaitForExternalEvent` and resuming via `RaiseEventAsync` + +## Workflow + +`CreateApprovalRequest` → `ManagerApproval` (HITL pause) → `ExpenseReimburse` + +## HTTP Endpoints + +The framework auto-generates these endpoints for workflows with `RequestPort` nodes: + +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/api/workflows/ExpenseReimbursement/run` | Start the workflow | +| GET | `/api/workflows/ExpenseReimbursement/status/{runId}` | Check status and pending approvals | +| POST | `/api/workflows/ExpenseReimbursement/respond/{runId}` | Send approval response to resume | + +## Environment Setup + +See the [README.md](../../README.md) file in the parent directory for information on how to configure the environment, including how to install and run the Durable Task Scheduler. + +## Running the Sample + +Use the `demo.http` file or the steps below: + +### Step 1: Start the Workflow + +```bash +curl -X POST "http://localhost:7071/api/workflows/ExpenseReimbursement/run?runId=expense-001" \ + -H "Content-Type: text/plain" -d "EXP-2025-001" +``` + +### Step 2: Check Workflow Status + +The workflow pauses at the `ManagerApproval` RequestPort: + +```bash +curl http://localhost:7071/api/workflows/ExpenseReimbursement/status/expense-001 +``` + +### Step 3: Send Approval Response + +```bash +curl -X POST http://localhost:7071/api/workflows/ExpenseReimbursement/respond/expense-001 \ + -H "Content-Type: application/json" \ + -d '{"eventName": "ManagerApproval", "response": {"Approved": true, "Comments": "Looks good!"}}' +``` + +### DTS Dashboard + +If using the DTS emulator, the dashboard is available at `http://localhost:8082` to visualize the orchestration and inspect the external event interaction. diff --git a/dotnet/samples/Durable/Workflow/AzureFunctions/03_WorkflowHITL/demo.http b/dotnet/samples/Durable/Workflow/AzureFunctions/03_WorkflowHITL/demo.http new file mode 100644 index 0000000000..8014c98540 --- /dev/null +++ b/dotnet/samples/Durable/Workflow/AzureFunctions/03_WorkflowHITL/demo.http @@ -0,0 +1,32 @@ +# Default endpoint address for local testing +@authority=http://localhost:7071 + +### Step 1: Start the expense reimbursement workflow +POST {{authority}}/api/workflows/ExpenseReimbursement/run +Content-Type: text/plain + +EXP-2025-001 + +### Step 1 (alternative): Start the workflow with a custom run ID +POST {{authority}}/api/workflows/ExpenseReimbursement/run?runId=expense-001 +Content-Type: text/plain + +EXP-2025-001 + +### Step 2: Check workflow status (replace {runId} with actual run ID from Step 1) +GET {{authority}}/api/workflows/ExpenseReimbursement/status/{runId} + +### Step 3: Approve the expense (replace {runId} with actual run ID from Step 1) +POST {{authority}}/api/workflows/ExpenseReimbursement/respond/{runId} +Content-Type: application/json + +{"eventName": "ManagerApproval", "response": {"Approved": true, "Comments": "Approved by manager. Looks good!"}} + +### Step 3 (alternative): Deny the expense +POST {{authority}}/api/workflows/ExpenseReimbursement/respond/expense-001 +Content-Type: application/json + +{"eventName": "ManagerApproval", "response": {"Approved": false, "Comments": "Insufficient documentation. Please resubmit."}} + +### Step 4: Check final workflow status after approval +GET {{authority}}/api/workflows/ExpenseReimbursement/status/expense-001 diff --git a/dotnet/samples/Durable/Workflow/AzureFunctions/03_WorkflowHITL/host.json b/dotnet/samples/Durable/Workflow/AzureFunctions/03_WorkflowHITL/host.json new file mode 100644 index 0000000000..9384a0a583 --- /dev/null +++ b/dotnet/samples/Durable/Workflow/AzureFunctions/03_WorkflowHITL/host.json @@ -0,0 +1,20 @@ +{ + "version": "2.0", + "logging": { + "logLevel": { + "Microsoft.Agents.AI.DurableTask": "Information", + "Microsoft.Agents.AI.Hosting.AzureFunctions": "Information", + "DurableTask": "Information", + "Microsoft.DurableTask": "Information" + } + }, + "extensions": { + "durableTask": { + "hubName": "default", + "storageProvider": { + "type": "AzureManaged", + "connectionStringName": "DURABLE_TASK_SCHEDULER_CONNECTION_STRING" + } + } + } +} diff --git a/dotnet/samples/Durable/Workflow/ConsoleApps/08_WorkflowHITL/08_WorkflowHITL.csproj b/dotnet/samples/Durable/Workflow/ConsoleApps/08_WorkflowHITL/08_WorkflowHITL.csproj new file mode 100644 index 0000000000..a9103b6e48 --- /dev/null +++ b/dotnet/samples/Durable/Workflow/ConsoleApps/08_WorkflowHITL/08_WorkflowHITL.csproj @@ -0,0 +1,28 @@ + + + net10.0 + Exe + enable + enable + WorkflowHITL + WorkflowHITL + + + + + + + + + + + + + + + diff --git a/dotnet/samples/Durable/Workflow/ConsoleApps/08_WorkflowHITL/Executors.cs b/dotnet/samples/Durable/Workflow/ConsoleApps/08_WorkflowHITL/Executors.cs new file mode 100644 index 0000000000..a16111d1ab --- /dev/null +++ b/dotnet/samples/Durable/Workflow/ConsoleApps/08_WorkflowHITL/Executors.cs @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.Workflows; + +namespace WorkflowHITL; + +/// +/// Represents an expense approval request. +/// +/// The unique identifier of the expense. +/// The amount of the expense. +/// The name of the employee submitting the expense. +public record ApprovalRequest(string ExpenseId, decimal Amount, string EmployeeName); + +/// +/// Represents the response to an approval request. +/// +/// Whether the expense was approved. +/// Optional comments from the approver. +public record ApprovalResponse(bool Approved, string? Comments); + +/// +/// Retrieves expense details and creates an approval request. +/// +internal sealed class CreateApprovalRequest() : Executor("RetrieveRequest") +{ + /// + public override ValueTask HandleAsync( + string message, + IWorkflowContext context, + CancellationToken cancellationToken = default) + { + // In a real scenario, this would look up expense details from a database + return new ValueTask(new ApprovalRequest(message, 1500.00m, "Jerry")); + } +} + +/// +/// Prepares the approval request for finance review after manager approval. +/// +internal sealed class PrepareFinanceReview() : Executor("PrepareFinanceReview") +{ + /// + public override ValueTask HandleAsync( + ApprovalResponse message, + IWorkflowContext context, + CancellationToken cancellationToken = default) + { + if (!message.Approved) + { + throw new InvalidOperationException("Cannot proceed to finance review — manager denied the expense."); + } + + // In a real scenario, this would retrieve the original expense details + return new ValueTask(new ApprovalRequest("EXP-2025-001", 1500.00m, "Jerry")); + } +} + +/// +/// Processes the expense reimbursement based on the final approval response. +/// +internal sealed class ExpenseReimburse() : Executor("Reimburse") +{ + /// + public override async ValueTask HandleAsync( + ApprovalResponse message, + IWorkflowContext context, + CancellationToken cancellationToken = default) + { + if (!message.Approved) + { + return $"Expense reimbursement denied by finance. Comments: {message.Comments}"; + } + + // Simulate payment processing + await Task.Delay(1000, cancellationToken); + return $"Expense reimbursed at {DateTime.UtcNow:O}"; + } +} diff --git a/dotnet/samples/Durable/Workflow/ConsoleApps/08_WorkflowHITL/Program.cs b/dotnet/samples/Durable/Workflow/ConsoleApps/08_WorkflowHITL/Program.cs new file mode 100644 index 0000000000..7ac7b7aa26 --- /dev/null +++ b/dotnet/samples/Durable/Workflow/ConsoleApps/08_WorkflowHITL/Program.cs @@ -0,0 +1,88 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample demonstrates a Human-in-the-Loop (HITL) workflow using Durable Tasks. +// Workflow: CreateApprovalRequest -> ManagerApproval -> PrepareFinanceReview -> FinanceApproval -> ExpenseReimburse +// +// The workflow pauses at two sequential RequestPorts, emitting a DurableWorkflowWaitingForInputEvent at each. +// This simulates an expense that requires both manager and finance approval. + +using Microsoft.Agents.AI.DurableTask; +using Microsoft.Agents.AI.DurableTask.Workflows; +using Microsoft.Agents.AI.Workflows; +using Microsoft.DurableTask.Client.AzureManaged; +using Microsoft.DurableTask.Worker.AzureManaged; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using WorkflowHITL; + +string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING") + ?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None"; + +// Define executors and RequestPorts for the two HITL pause points +CreateApprovalRequest createRequest = new(); +RequestPort managerApproval = RequestPort.Create("ManagerApproval"); +PrepareFinanceReview prepareFinanceReview = new(); +RequestPort financeApproval = RequestPort.Create("FinanceApproval"); +ExpenseReimburse reimburse = new(); + +// Build the workflow: CreateApprovalRequest -> ManagerApproval -> PrepareFinanceReview -> FinanceApproval -> ExpenseReimburse +Workflow expenseApproval = new WorkflowBuilder(createRequest) + .WithName("ExpenseReimbursement") + .WithDescription("Expense reimbursement with manager and finance approval") + .AddEdge(createRequest, managerApproval) + .AddEdge(managerApproval, prepareFinanceReview) + .AddEdge(prepareFinanceReview, financeApproval) + .AddEdge(financeApproval, reimburse) + .Build(); + +IHost host = Host.CreateDefaultBuilder(args) + .ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Warning)) + .ConfigureServices(services => + { + services.ConfigureDurableWorkflows( + options => options.AddWorkflow(expenseApproval), + workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString), + clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString)); + }) + .Build(); + +await host.StartAsync(); + +IWorkflowClient workflowClient = host.Services.GetRequiredService(); + +// Start the workflow with streaming to observe events including HITL pauses +string expenseId = "EXP-2025-001"; +Console.WriteLine($"Starting expense reimbursement workflow for expense: {expenseId}"); +IStreamingWorkflowRun run = await workflowClient.StreamAsync(expenseApproval, expenseId); +Console.WriteLine($"Workflow started with instance ID: {run.RunId}\n"); + +// Watch for workflow events — handle HITL requests as they arrive +await foreach (WorkflowEvent evt in run.WatchStreamAsync()) +{ + switch (evt) + { + case DurableWorkflowWaitingForInputEvent requestEvent: + Console.WriteLine($"Workflow paused at RequestPort: {requestEvent.RequestPort.Id}"); + Console.WriteLine($" Input: {requestEvent.Input}"); + + // In a real scenario, this would involve human interaction (UI, email, Teams, etc.) + ApprovalRequest? request = requestEvent.GetInputAs(); + Console.WriteLine($" Approval for: {request?.EmployeeName}, Amount: {request?.Amount:C}"); + + ApprovalResponse approvalResponse = new(Approved: true, Comments: "Approved by manager."); + await run.SendResponseAsync(requestEvent, approvalResponse); + Console.WriteLine($" Response sent: Approved={approvalResponse.Approved}\n"); + break; + + case DurableWorkflowCompletedEvent completedEvent: + Console.WriteLine($"Workflow completed: {completedEvent.Result}"); + break; + + case DurableWorkflowFailedEvent failedEvent: + Console.WriteLine($"Workflow failed: {failedEvent.ErrorMessage}"); + break; + } +} + +await host.StopAsync(); diff --git a/dotnet/samples/Durable/Workflow/ConsoleApps/08_WorkflowHITL/README.md b/dotnet/samples/Durable/Workflow/ConsoleApps/08_WorkflowHITL/README.md new file mode 100644 index 0000000000..77ade728f4 --- /dev/null +++ b/dotnet/samples/Durable/Workflow/ConsoleApps/08_WorkflowHITL/README.md @@ -0,0 +1,77 @@ +# Workflow Human-in-the-Loop (HITL) Sample + +This sample demonstrates a **Human-in-the-Loop** pattern in durable workflows using `RequestPort`. The workflow pauses execution at two sequential approval points — manager and finance — and resumes when each response is provided. + +## Key Concepts Demonstrated + +- Using `RequestPort` to define external input points in a workflow +- Multiple sequential HITL pause points in a single workflow +- Streaming workflow events with `IStreamingWorkflowRun` +- Handling `DurableWorkflowWaitingForInputEvent` to detect HITL pauses +- Using `SendResponseAsync` to provide responses and resume the workflow +- **Durability**: The workflow survives process restarts while waiting for human input + +## Workflow + +``` +CreateApprovalRequest -> ManagerApproval (RequestPort) -> PrepareFinanceReview -> FinanceApproval (RequestPort) -> ExpenseReimburse +``` + +| Step | Description | +|------|-------------| +| CreateApprovalRequest | Retrieves expense details and creates an approval request | +| ManagerApproval (RequestPort) | **PAUSES** the workflow and waits for manager approval | +| PrepareFinanceReview | Prepares the request for finance review after manager approval | +| FinanceApproval (RequestPort) | **PAUSES** the workflow and waits for finance approval | +| ExpenseReimburse | Processes the reimbursement based on the final approval | + +## How It Works + +A `RequestPort` defines a typed external input point in the workflow: + +```csharp +RequestPort managerApproval = + RequestPort.Create("ManagerApproval"); +``` + +Use `WatchStreamAsync` to observe events. When the workflow reaches a `RequestPort`, a `DurableWorkflowWaitingForInputEvent` is emitted. Call `SendResponseAsync` to provide the response and resume the workflow: + +```csharp +await foreach (WorkflowEvent evt in run.WatchStreamAsync()) +{ + case DurableWorkflowWaitingForInputEvent requestEvent: + ApprovalRequest? request = requestEvent.GetInputAs(); + await run.SendResponseAsync(requestEvent, new ApprovalResponse(Approved: true, Comments: "Approved.")); + break; +} +``` + +## Environment Setup + +See the [README.md](../README.md) file in the parent directory for information on configuring the environment, including how to install and run the Durable Task Scheduler. + +## Running the Sample + +```bash +cd dotnet/samples/Durable/Workflow/ConsoleApps/08_WorkflowHITL +dotnet run --framework net10.0 +``` + +### Sample Output + +```text +Starting expense reimbursement workflow for expense: EXP-2025-001 +Workflow started with instance ID: abc123... + +Workflow paused at RequestPort: ManagerApproval + Input: {"expenseId":"EXP-2025-001","amount":1500.00,"employeeName":"Jerry"} + Approval for: Jerry, Amount: $1,500.00 + Response sent: Approved=True + +Workflow paused at RequestPort: FinanceApproval + Input: {"expenseId":"EXP-2025-001","amount":1500.00,"employeeName":"Jerry"} + Approval for: Jerry, Amount: $1,500.00 + Response sent: Approved=True + +Workflow completed: Expense reimbursed at 2025-01-23T17:30:00.0000000Z +``` diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableOptions.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableOptions.cs index 3d48249d31..136eb1fb3d 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableOptions.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System.Diagnostics; using Microsoft.Agents.AI.DurableTask.Workflows; @@ -9,7 +9,7 @@ namespace Microsoft.Agents.AI.DurableTask; /// Provides configuration options for durable agents and workflows. /// [DebuggerDisplay("Workflows = {Workflows.Workflows.Count}, Agents = {Agents.AgentCount}")] -public sealed class DurableOptions +public class DurableOptions { /// /// Initializes a new instance of the class. diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Logs.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Logs.cs index 43e2031a6f..fdf09749b5 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Logs.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Logs.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using Microsoft.Agents.AI.DurableTask; using Microsoft.Extensions.AI; @@ -211,4 +211,20 @@ public static partial void LogEdgeRoutingMessage( this ILogger logger, string source, string sink); + + [LoggerMessage( + EventId = 112, + Level = LogLevel.Information, + Message = "Workflow waiting for external input at RequestPort '{RequestPortId}'")] + public static partial void LogWaitingForExternalEvent( + this ILogger logger, + string requestPortId); + + [LoggerMessage( + EventId = 113, + Level = LogLevel.Information, + Message = "Received external event for RequestPort '{RequestPortId}'")] + public static partial void LogReceivedExternalEvent( + this ILogger logger, + string requestPortId); } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/ServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/ServiceCollectionExtensions.cs index 2175cf5bb9..b1002d0851 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/ServiceCollectionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/ServiceCollectionExtensions.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using Microsoft.Agents.AI.DurableTask.Workflows; using Microsoft.Agents.AI.Workflows; @@ -329,12 +329,14 @@ private static WorkflowRegistrationInfo BuildWorkflowRegistration( /// /// Returns for bindings that should be registered as Durable Task activities. - /// (Durable Entities) and (sub-orchestrations) - /// use specialized dispatch and are excluded. + /// (Durable Entities), (sub-orchestrations), + /// and (human-in-the-loop via external events) use specialized dispatch + /// and are excluded. /// private static bool IsActivityBinding(ExecutorBinding binding) => binding is not AIAgentBinding - and not SubworkflowBinding; + and not SubworkflowBinding + and not RequestPortBinding; private static async Task RunWorkflowOrchestrationAsync( TaskOrchestrationContext context, diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableExecutorDispatcher.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableExecutorDispatcher.cs index 6f69b923b6..f776bfbcfb 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableExecutorDispatcher.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableExecutorDispatcher.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. // ConfigureAwait Usage in Orchestration Code: // This file uses ConfigureAwait(true) because it runs within orchestration context. @@ -8,31 +8,34 @@ // Using ConfigureAwait(false) here could cause non-deterministic behavior during replay. using System.Text.Json; +using Microsoft.Agents.AI.Workflows; using Microsoft.DurableTask; using Microsoft.Extensions.Logging; namespace Microsoft.Agents.AI.DurableTask.Workflows; /// -/// Dispatches workflow executors to activities, AI agents, or sub-orchestrations. +/// Dispatches workflow executors to activities, AI agents, sub-orchestrations, or external events (human-in-the-loop). /// /// /// Called during the dispatch phase of each superstep by /// DurableWorkflowRunner.DispatchExecutorsInParallelAsync. For each executor that has /// pending input, this dispatcher determines whether the executor is an AI agent (stateful, -/// backed by Durable Entities), a sub-workflow (dispatched as a sub-orchestration), or a -/// regular activity, and invokes the appropriate Durable Task API. +/// backed by Durable Entities), a request port (human-in-the-loop, backed by external events), +/// a sub-workflow (dispatched as a sub-orchestration), or a regular activity, and invokes the +/// appropriate Durable Task API. /// The serialised string result is returned to the runner for the routing phase. /// internal static class DurableExecutorDispatcher { /// - /// Dispatches an executor based on its type (activity, AI agent, or sub-workflow). + /// Dispatches an executor based on its type (activity, AI agent, request port, or sub-workflow). /// /// The task orchestration context. /// Information about the executor to dispatch. /// The message envelope containing input and type information. /// The shared state dictionary to pass to the executor. + /// The live workflow status used to publish events and pending request port state. /// The logger for tracing. /// The result from the executor. internal static async Task DispatchAsync( @@ -40,10 +43,16 @@ internal static async Task DispatchAsync( WorkflowExecutorInfo executorInfo, DurableMessageEnvelope envelope, Dictionary sharedState, + DurableWorkflowLiveStatus liveStatus, ILogger logger) { logger.LogDispatchingExecutor(executorInfo.ExecutorId, executorInfo.IsAgenticExecutor); + if (executorInfo.IsRequestPortExecutor) + { + return await ExecuteRequestPortAsync(context, executorInfo, envelope.Message, liveStatus, logger).ConfigureAwait(true); + } + if (executorInfo.IsAgenticExecutor) { return await ExecuteAgentAsync(context, executorInfo, logger, envelope.Message).ConfigureAwait(true); @@ -79,6 +88,49 @@ private static async Task ExecuteActivityAsync( return await context.CallActivityAsync(activityName, serializedInput).ConfigureAwait(true); } + /// + /// Executes a request port executor by waiting for an external event (human-in-the-loop). + /// + /// + /// When the workflow reaches a executor, the orchestration publishes + /// the pending request to and waits for an external actor + /// (e.g., a UI or API) to raise the corresponding event via + /// . + /// Multiple RequestPorts may be dispatched in parallel during a fan-out superstep. + /// Each adds its pending request to . + /// The wait has no built-in timeout; for time-limited approvals, callers can combine + /// context.CreateTimer with Task.WhenAny in a wrapper executor. + /// + private static async Task ExecuteRequestPortAsync( + TaskOrchestrationContext context, + WorkflowExecutorInfo executorInfo, + string input, + DurableWorkflowLiveStatus liveStatus, + ILogger logger) + { + RequestPort requestPort = executorInfo.RequestPort!; + string eventName = requestPort.Id; + + logger.LogWaitingForExternalEvent(eventName); + + // Publish pending request so external clients can discover what input is needed + PendingRequestPortStatus pendingEvent = new(EventName: eventName, Input: input); + liveStatus.PendingEvents.Add(pendingEvent); + + context.SetCustomStatus(liveStatus); + + // Wait until the external actor raises the event + string response = await context.WaitForExternalEvent(eventName).ConfigureAwait(true); + + // Remove this pending request after receiving the response + liveStatus.PendingEvents.Remove(pendingEvent); + context.SetCustomStatus(liveStatus.Events.Count > 0 || liveStatus.PendingEvents.Count > 0 ? liveStatus : null); + + logger.LogReceivedExternalEvent(eventName); + + return response; + } + /// /// Executes an AI agent executor through Durable Entities. /// diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableStreamingWorkflowRun.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableStreamingWorkflowRun.cs index a7ed7b11ce..9cfcd24897 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableStreamingWorkflowRun.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableStreamingWorkflowRun.cs @@ -1,7 +1,8 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using System.Linq; using System.Runtime.CompilerServices; using System.Text.Json; using Microsoft.Agents.AI.Workflows; @@ -14,15 +15,24 @@ namespace Microsoft.Agents.AI.DurableTask.Workflows; /// Represents a durable workflow run that supports streaming workflow events as they occur. /// /// +/// /// Events are detected by monitoring the orchestration's custom status at regular intervals. /// When executors emit events via or /// , they are written to the orchestration's /// custom status and picked up by this streaming run. +/// +/// +/// When the workflow reaches a executor, a +/// is yielded containing the request data. The caller should then call +/// +/// to provide the response and resume the workflow. +/// /// [DebuggerDisplay("{WorkflowName} ({RunId})")] internal sealed class DurableStreamingWorkflowRun : IStreamingWorkflowRun { private readonly DurableTaskClient _client; + private readonly Dictionary _requestPorts; /// /// Initializes a new instance of the class. @@ -35,6 +45,7 @@ internal DurableStreamingWorkflowRun(DurableTaskClient client, string instanceId this._client = client; this.RunId = instanceId; this.WorkflowName = workflow.Name ?? string.Empty; + this._requestPorts = ExtractRequestPorts(workflow); } /// @@ -92,9 +103,12 @@ private async IAsyncEnumerable WatchStreamAsync( TimeSpan maxInterval = TimeSpan.FromSeconds(2); TimeSpan currentInterval = minInterval; - // Track how many events we've already read from custom status + // Track how many events we've already read from the durable workflow status int lastReadEventIndex = 0; + // Track which pending events we've already yielded to avoid duplicates + HashSet yieldedPendingEvents = []; + while (!cancellationToken.IsCancellationRequested) { // Poll with getInputsAndOutputs: true because SerializedCustomStatus @@ -111,26 +125,54 @@ private async IAsyncEnumerable WatchStreamAsync( bool hasNewEvents = false; - // Always drain any unread events from custom status before checking terminal states. + // Always drain any unread events from the durable workflow status before checking terminal states. // The orchestration may complete before the next poll, so events would be lost if we // check terminal status first. if (metadata.SerializedCustomStatus is not null) { - if (TryParseCustomStatus(metadata.SerializedCustomStatus, out DurableWorkflowCustomStatus customStatus)) + if (DurableWorkflowLiveStatus.TryParse(metadata.SerializedCustomStatus, out DurableWorkflowLiveStatus liveStatus)) { - (List events, lastReadEventIndex) = DrainNewEvents(customStatus.Events, lastReadEventIndex); + (List events, lastReadEventIndex) = DrainNewEvents(liveStatus.Events, lastReadEventIndex); foreach (WorkflowEvent evt in events) { hasNewEvents = true; yield return evt; } + + // Yield a DurableWorkflowWaitingForInputEvent for each new pending request port + foreach (PendingRequestPortStatus pending in liveStatus.PendingEvents) + { + if (yieldedPendingEvents.Add(pending.EventName)) + { + if (!this._requestPorts.TryGetValue(pending.EventName, out RequestPort? matchingPort)) + { + // RequestPort may not exist in the current workflow definition (e.g., during rolling deployments). + continue; + } + + hasNewEvents = true; + yield return new DurableWorkflowWaitingForInputEvent( + pending.Input, + matchingPort); + } + } + + // Sync tracking with current pending events so re-used RequestPort names can be yielded again + if (liveStatus.PendingEvents.Count == 0) + { + yieldedPendingEvents.Clear(); + } + else + { + yieldedPendingEvents.IntersectWith(liveStatus.PendingEvents.Select(p => p.EventName)); + } } } - // Check terminal states after draining events from custom status + // Check terminal states after draining events from the durable workflow status if (metadata.RuntimeStatus == OrchestrationRuntimeStatus.Completed) { - // The framework clears custom status on completion, so events may be in + // The framework clears the durable workflow status on completion, so events may be in // SerializedOutput as a DurableWorkflowResult wrapper. if (TryParseWorkflowResult(metadata.SerializedOutput, out DurableWorkflowResult? outputResult)) { @@ -183,6 +225,28 @@ private async IAsyncEnumerable WatchStreamAsync( } } + /// + /// Sends a response to a to resume the workflow. + /// + /// The type of the response data. + /// The request event to respond to. + /// The response data to send. + /// A cancellation token to observe. + /// A representing the asynchronous operation. + [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing workflow types provided by the caller.")] + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing workflow types provided by the caller.")] + public async ValueTask SendResponseAsync(DurableWorkflowWaitingForInputEvent requestEvent, TResponse response, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(requestEvent); + + string serializedResponse = JsonSerializer.Serialize(response, DurableSerialization.Options); + await this._client.RaiseEventAsync( + this.RunId, + requestEvent.RequestPort.Id, + serializedResponse, + cancellationToken).ConfigureAwait(false); + } + /// /// Waits for the workflow to complete and returns the result. /// @@ -242,22 +306,6 @@ private static (List Events, int UpdatedIndex) DrainNewEvents(Lis return (events, lastReadIndex); } - [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow custom status.")] - [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow custom status.")] - private static bool TryParseCustomStatus(string serializedStatus, out DurableWorkflowCustomStatus result) - { - try - { - result = JsonSerializer.Deserialize(serializedStatus, DurableWorkflowJsonContext.Default.DurableWorkflowCustomStatus)!; - return result is not null; - } - catch (JsonException) - { - result = default!; - return false; - } - } - /// /// Attempts to parse the orchestration output as a wrapper. /// @@ -395,4 +443,11 @@ private static bool TryParseWorkflowResult(string? serializedOutput, [NotNullWhe return dataElement.ValueKind == JsonValueKind.Null ? null : dataElement.Clone(); } + + private static Dictionary ExtractRequestPorts(Workflow workflow) + { + return WorkflowAnalyzer.GetExecutorsFromWorkflowInOrder(workflow) + .Where(e => e.RequestPort is not null) + .ToDictionary(e => e.RequestPort!.Id, e => e.RequestPort!); + } } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowCustomStatus.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowCustomStatus.cs deleted file mode 100644 index f6d403e861..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowCustomStatus.cs +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -namespace Microsoft.Agents.AI.DurableTask.Workflows; - -/// -/// Represents the custom status written by the orchestration for streaming consumption. -/// -/// -/// The Durable Task framework exposes SerializedCustomStatus on orchestration metadata, -/// which is the only orchestration state readable by external clients while the orchestration -/// is still running. The orchestrator writes this object via SetCustomStatus after each -/// superstep so that can poll for new events. -/// On orchestration completion the framework clears custom status, so events are also -/// embedded in the output via . -/// -internal sealed class DurableWorkflowCustomStatus -{ - /// - /// Gets or sets the serialized workflow events emitted so far. - /// - public List Events { get; set; } = []; -} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowJsonContext.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowJsonContext.cs index 9058c41e0a..e68ec842a8 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowJsonContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowJsonContext.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System.Text.Json.Serialization; @@ -16,7 +16,7 @@ namespace Microsoft.Agents.AI.DurableTask.Workflows; /// : Activity input wrapper with state /// : Executor output wrapper with results, events, and state updates /// : Serialized payload wrapper with type info (events and messages) -/// : Custom status for streaming consumption +/// : Live status payload (streaming events and pending request ports) /// /// /// Note: User-defined executor input/output types still use reflection-based serialization @@ -31,8 +31,10 @@ namespace Microsoft.Agents.AI.DurableTask.Workflows; [JsonSerializable(typeof(DurableExecutorOutput))] [JsonSerializable(typeof(TypedPayload))] [JsonSerializable(typeof(List))] -[JsonSerializable(typeof(DurableWorkflowCustomStatus))] +[JsonSerializable(typeof(DurableWorkflowLiveStatus))] [JsonSerializable(typeof(DurableWorkflowResult))] +[JsonSerializable(typeof(PendingRequestPortStatus))] +[JsonSerializable(typeof(List))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(Dictionary))] [JsonSerializable(typeof(Dictionary))] diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowLiveStatus.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowLiveStatus.cs new file mode 100644 index 0000000000..1568755800 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowLiveStatus.cs @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.Workflows; + +namespace Microsoft.Agents.AI.DurableTask.Workflows; + +/// +/// Live status payload written to the orchestration via SetCustomStatus. +/// +/// +/// +/// This is the only orchestration state readable by external clients while the workflow +/// is still running. It is written after each superstep so that +/// can poll for new events. +/// On completion the framework clears it, so events are also +/// embedded in the output via . +/// +/// +/// When the workflow is paused at one or more nodes, +/// contains the request data for each. +/// +/// +internal sealed class DurableWorkflowLiveStatus +{ + /// + /// Gets or sets the pending request ports the workflow is waiting on. Empty when no input is needed. + /// + public List PendingEvents { get; set; } = []; + + /// + /// Gets or sets the serialized workflow events emitted so far. + /// + public List Events { get; set; } = []; + + /// + /// Attempts to deserialize a serialized custom status string into a . + /// + [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing durable workflow status.")] + [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing durable workflow status.")] + internal static bool TryParse(string? serializedStatus, out DurableWorkflowLiveStatus result) + { + if (serializedStatus is null) + { + result = default!; + return false; + } + + try + { + result = System.Text.Json.JsonSerializer.Deserialize(serializedStatus, DurableSerialization.Options)!; + return result is not null; + } + catch (System.Text.Json.JsonException) + { + result = default!; + return false; + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowOptions.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowOptions.cs index eb9ee92758..52cb54b20a 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowOptions.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System.Diagnostics; using Microsoft.Agents.AI.Workflows; @@ -12,7 +12,6 @@ namespace Microsoft.Agents.AI.DurableTask.Workflows; public sealed class DurableWorkflowOptions { private readonly Dictionary _workflows = new(StringComparer.OrdinalIgnoreCase); - private readonly DurableOptions? _parentOptions; /// /// Initializes a new instance of the class. @@ -20,9 +19,14 @@ public sealed class DurableWorkflowOptions /// Optional parent options container for accessing related configuration. internal DurableWorkflowOptions(DurableOptions? parentOptions = null) { - this._parentOptions = parentOptions; + this.ParentOptions = parentOptions; } + /// + /// Gets the parent container, if available. + /// + internal DurableOptions? ParentOptions { get; } + /// /// Gets the collection of workflows available in the current context, keyed by their unique names. /// @@ -77,7 +81,7 @@ public void AddWorkflows(params Workflow[] workflows) /// private void RegisterWorkflowExecutors(Workflow workflow) { - DurableAgentsOptions? agentOptions = this._parentOptions?.Agents; + DurableAgentsOptions? agentOptions = this.ParentOptions?.Agents; foreach ((string executorId, ExecutorBinding binding) in workflow.ReflectExecutors()) { diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowRunner.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowRunner.cs index 8836a4973a..942d4ecc9a 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowRunner.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowRunner.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. // ConfigureAwait Usage in Orchestration Code: // This file uses ConfigureAwait(true) because it runs within orchestration context. @@ -173,7 +173,7 @@ private static async Task RunSuperstepLoopAsync( logger.LogSuperstepExecutors(superstep, string.Join(", ", executorInputs.Select(e => e.ExecutorId))); } - string[] results = await DispatchExecutorsInParallelAsync(context, executorInputs, state.SharedState, logger).ConfigureAwait(true); + string[] results = await DispatchExecutorsInParallelAsync(context, executorInputs, state, logger).ConfigureAwait(true); haltRequested = ProcessSuperstepResults(executorInputs, results, state, context, logger); @@ -193,7 +193,7 @@ private static async Task RunSuperstepLoopAsync( // Publish final events for live streaming (skip during replay) if (!context.IsReplaying) { - PublishEventsToCustomStatus(context, state); + PublishEventsToLiveStatus(context, state); } string finalResult = GetFinalResult(state.LastResults); @@ -226,11 +226,11 @@ private static int CountRemainingExecutors(Dictionary DispatchExecutorsInParallelAsync( TaskOrchestrationContext context, List executorInputs, - Dictionary sharedState, + SuperstepState state, ILogger logger) { Task[] dispatchTasks = executorInputs - .Select(input => DurableExecutorDispatcher.DispatchAsync(context, input.Info, input.Envelope, sharedState, logger)) + .Select(input => DurableExecutorDispatcher.DispatchAsync(context, input.Info, input.Envelope, state.SharedState, state.LiveStatus, logger)) .ToArray(); return await Task.WhenAll(dispatchTasks).ConfigureAwait(true); @@ -273,9 +273,14 @@ public SuperstepState(Workflow workflow, DurableEdgeMap edgeMap) public Dictionary SharedState { get; } = []; /// - /// Accumulated workflow events for custom status (streaming consumption). + /// Accumulated workflow events for the durable workflow status (streaming consumption). /// public List AccumulatedEvents { get; } = []; + + /// + /// Workflow status published via SetCustomStatus so external clients can poll for streaming events and pending HITL requests. + /// + public DurableWorkflowLiveStatus LiveStatus { get; } = new(); } /// @@ -378,7 +383,7 @@ private static bool ProcessSuperstepResults( // Merge state updates from activity into shared state MergeStateUpdates(state, resultInfo.StateUpdates, resultInfo.ClearedScopes); - // Accumulate events for custom status (streaming) + // Accumulate events for the durable workflow status (streaming) state.AccumulatedEvents.AddRange(resultInfo.Events); // Check for halt request @@ -387,7 +392,7 @@ private static bool ProcessSuperstepResults( // Publish events for live streaming (skip during replay) if (!context.IsReplaying) { - PublishEventsToCustomStatus(context, state); + PublishEventsToLiveStatus(context, state); } RouteOutputToSuccessors(executorId, resultInfo.Result, resultInfo.SentMessages, state, logger); @@ -464,24 +469,23 @@ private static void ApplyClearedScopes(Dictionary shared, List - /// Publishes accumulated workflow events to the orchestration's custom status, + /// Publishes accumulated workflow events to the durable workflow's custom status, /// making them available to for live streaming. /// /// - /// Custom status is the only orchestration metadata readable by external clients while + /// Custom status is the only orchestration state readable by external clients while /// the orchestration is still running. It is cleared by the framework on completion, /// so events are also included in for final retrieval. /// - private static void PublishEventsToCustomStatus(TaskOrchestrationContext context, SuperstepState state) + private static void PublishEventsToLiveStatus( + TaskOrchestrationContext context, + SuperstepState state) { - DurableWorkflowCustomStatus customStatus = new() - { - Events = state.AccumulatedEvents - }; + state.LiveStatus.Events = state.AccumulatedEvents; // Pass the object directly — the framework's DataConverter handles serialization. // Pre-serializing would cause double-serialization (string wrapped in JSON quotes). - context.SetCustomStatus(customStatus); + context.SetCustomStatus(state.LiveStatus); } /// diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowWaitingForInputEvent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowWaitingForInputEvent.cs new file mode 100644 index 0000000000..86532852c8 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowWaitingForInputEvent.cs @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using Microsoft.Agents.AI.Workflows; + +namespace Microsoft.Agents.AI.DurableTask.Workflows; + +/// +/// Event raised when the durable workflow is waiting for external input at a . +/// +/// The serialized input data that was passed to the RequestPort. +/// The request port definition. +[DebuggerDisplay("RequestPort = {RequestPort.Id}")] +public sealed class DurableWorkflowWaitingForInputEvent( + string Input, + RequestPort RequestPort) : WorkflowEvent +{ + /// + /// Gets the serialized input data that was passed to the RequestPort. + /// + public string Input { get; } = Input; + + /// + /// Gets the request port definition. + /// + public RequestPort RequestPort { get; } = RequestPort; + + /// + /// Attempts to deserialize the input data to the specified type. + /// + /// The type to deserialize to. + /// The deserialized input. + /// Thrown when the input cannot be deserialized to the specified type. + [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow types provided by the caller.")] + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow types provided by the caller.")] + public T? GetInputAs() + { + return JsonSerializer.Deserialize(this.Input, DurableSerialization.Options); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/IStreamingWorkflowRun.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/IStreamingWorkflowRun.cs index e34e9b39d1..79771e8bd2 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/IStreamingWorkflowRun.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/IStreamingWorkflowRun.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using Microsoft.Agents.AI.Workflows; @@ -39,4 +39,17 @@ public interface IStreamingWorkflowRun /// workflow state changes. /// IAsyncEnumerable WatchStreamAsync(CancellationToken cancellationToken = default); + + /// + /// Sends a response to a to resume the workflow. + /// + /// The type of the response data. + /// The request event to respond to. + /// The response data to send. + /// A cancellation token to observe. + /// A representing the asynchronous operation. + ValueTask SendResponseAsync( + DurableWorkflowWaitingForInputEvent requestEvent, + TResponse response, + CancellationToken cancellationToken = default); } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/PendingRequestPortStatus.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/PendingRequestPortStatus.cs new file mode 100644 index 0000000000..d75ea1427d --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/PendingRequestPortStatus.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.DurableTask.Workflows; + +/// +/// Represents a RequestPort the workflow is paused at, waiting for a response. +/// +/// The RequestPort ID identifying which input is needed. +/// The serialized request data passed to the RequestPort. +internal sealed record PendingRequestPortStatus( + string EventName, + string Input); diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctionExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctionExecutor.cs index 07db07a1e7..25200c30bb 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctionExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctionExecutor.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Context.Features; @@ -85,6 +85,34 @@ public async ValueTask ExecuteAsync(FunctionContext context) return; } + if (context.FunctionDefinition.EntryPoint == BuiltInFunctions.GetWorkflowStatusHttpFunctionEntryPoint) + { + if (httpRequestData == null) + { + throw new InvalidOperationException($"HTTP request data binding is missing for the invocation {context.InvocationId}."); + } + + context.GetInvocationResult().Value = await BuiltInFunctions.GetWorkflowStatusAsync( + httpRequestData, + durableTaskClient, + context); + return; + } + + if (context.FunctionDefinition.EntryPoint == BuiltInFunctions.RespondToWorkflowHttpFunctionEntryPoint) + { + if (httpRequestData == null) + { + throw new InvalidOperationException($"HTTP request data binding is missing for the invocation {context.InvocationId}."); + } + + context.GetInvocationResult().Value = await BuiltInFunctions.RespondToWorkflowAsync( + httpRequestData, + durableTaskClient, + context); + return; + } + if (context.FunctionDefinition.EntryPoint == BuiltInFunctions.InvokeWorkflowActivityFunctionEntryPoint) { if (encodedEntityRequest is null) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs index 97c6bbcaeb..5cd3d20714 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs @@ -1,6 +1,8 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. +using System.Diagnostics.CodeAnalysis; using System.Net; +using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Agents.AI.DurableTask; using Microsoft.Agents.AI.DurableTask.Workflows; @@ -26,6 +28,8 @@ internal static class BuiltInFunctions internal static readonly string RunWorkflowOrchestrationHttpFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RunWorkflowOrchestrationHttpTriggerAsync)}"; internal static readonly string RunWorkflowOrchestrationFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RunWorkflowOrchestration)}"; internal static readonly string InvokeWorkflowActivityFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(InvokeWorkflowActivityAsync)}"; + internal static readonly string GetWorkflowStatusHttpFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(GetWorkflowStatusAsync)}"; + internal static readonly string RespondToWorkflowHttpFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RespondToWorkflowAsync)}"; #pragma warning disable IL3000 // Avoid accessing Assembly file path when publishing as a single file - Azure Functions does not use single-file publishing internal static readonly string ScriptFile = Path.GetFileName(typeof(BuiltInFunctions).Assembly.Location); @@ -63,6 +67,98 @@ public static async Task RunWorkflowOrchestrationHttpTriggerAs return response; } + /// + /// Returns the workflow status including any pending HITL requests. + /// The run ID is extracted from the route parameter {runId}. + /// + public static async Task GetWorkflowStatusAsync( + [HttpTrigger] HttpRequestData req, + [DurableClient] DurableTaskClient client, + FunctionContext context) + { + string? runId = context.BindingContext.BindingData.TryGetValue("runId", out object? value) ? value?.ToString() : null; + if (string.IsNullOrEmpty(runId)) + { + return await CreateErrorResponseAsync(req, context, HttpStatusCode.BadRequest, "Run ID is required."); + } + + OrchestrationMetadata? metadata = await client.GetInstanceAsync(runId, getInputsAndOutputs: true); + if (metadata is null) + { + return await CreateErrorResponseAsync(req, context, HttpStatusCode.NotFound, $"Workflow run '{runId}' not found."); + } + + // Parse pending HITL requests from the durable workflow status + List? pendingRequests = null; + if (DurableWorkflowLiveStatus.TryParse(metadata.SerializedCustomStatus, out DurableWorkflowLiveStatus liveStatus) + && liveStatus.PendingEvents.Count > 0) + { + pendingRequests = liveStatus.PendingEvents; + } + + HttpResponseData response = req.CreateResponse(HttpStatusCode.OK); + await response.WriteAsJsonAsync(new + { + runId, + status = metadata.RuntimeStatus.ToString(), + pendingRequests = pendingRequests?.Select(p => new { eventName = p.EventName }) + }); + return response; + } + + /// + /// Sends a response to a pending RequestPort, resuming the workflow. + /// Expects a JSON body: { "eventName": "...", "response": { ... } }. + /// + public static async Task RespondToWorkflowAsync( + [HttpTrigger] HttpRequestData req, + [DurableClient] DurableTaskClient client, + FunctionContext context) + { + string? runId = context.BindingContext.BindingData.TryGetValue("runId", out object? value) ? value?.ToString() : null; + if (string.IsNullOrEmpty(runId)) + { + return await CreateErrorResponseAsync(req, context, HttpStatusCode.BadRequest, "Run ID is required."); + } + + WorkflowRespondRequest? request; + try + { + request = await req.ReadFromJsonAsync(context.CancellationToken); + } + catch (JsonException) + { + return await CreateErrorResponseAsync(req, context, HttpStatusCode.BadRequest, "Request body is not valid JSON."); + } + + if (request is null || string.IsNullOrEmpty(request.EventName) + || request.Response.ValueKind == JsonValueKind.Undefined) + { + return await CreateErrorResponseAsync(req, context, HttpStatusCode.BadRequest, "Body must contain a non-empty 'eventName' and a 'response' property."); + } + + // Verify the orchestration exists and is waiting for the specified event + OrchestrationMetadata? metadata = await client.GetInstanceAsync(runId, getInputsAndOutputs: true); + if (metadata is null) + { + return await CreateErrorResponseAsync(req, context, HttpStatusCode.NotFound, $"Workflow run '{runId}' not found."); + } + + if (DurableWorkflowLiveStatus.TryParse(metadata.SerializedCustomStatus, out DurableWorkflowLiveStatus liveStatus) + && !liveStatus.PendingEvents.Exists(p => string.Equals(p.EventName, request.EventName, StringComparison.Ordinal))) + { + return await CreateErrorResponseAsync(req, context, HttpStatusCode.BadRequest, + $"Workflow is not waiting for event '{request.EventName}'."); + } + + // Raise the external event to unblock the orchestration's WaitForExternalEvent call + await client.RaiseEventAsync(runId, request.EventName, request.Response.GetRawText()); + + HttpResponseData response = req.CreateResponse(HttpStatusCode.OK); + await response.WriteAsJsonAsync(new { message = "Response sent to workflow.", runId, eventName = request.EventName }); + return response; + } + /// /// Executes a workflow activity by looking up the registered executor and delegating to it. /// The executor name is derived from the activity function name via . @@ -413,6 +509,15 @@ private sealed record AgentRunAcceptedResponse( [property: JsonPropertyName("status")] int Status, [property: JsonPropertyName("thread_id")] string ThreadId); + /// + /// Represents a request to respond to a pending RequestPort in a workflow. + /// + /// The name of the event to raise (the RequestPort ID). + /// The response payload to send to the workflow. + private sealed record WorkflowRespondRequest( + [property: JsonPropertyName("eventName")] string? EventName, + [property: JsonPropertyName("response")] JsonElement Response); + /// /// A service provider that combines the original service provider with an additional DurableTaskClient instance. /// diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionMetadataFactory.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionMetadataFactory.cs index e28b02a3b7..f50f3a03be 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionMetadataFactory.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionMetadataFactory.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using Microsoft.Agents.AI.DurableTask; using Microsoft.Azure.Functions.Worker.Core.FunctionMetadata; @@ -38,8 +38,9 @@ internal static DefaultFunctionMetadata CreateEntityTrigger(string name) /// The base name used to derive the HTTP function name. /// The HTTP route for the trigger. /// The entry point method for the HTTP trigger. + /// The allowed HTTP methods as a JSON array fragment (e.g., "\"get\""). Defaults to POST. /// A configured for an HTTP trigger. - internal static DefaultFunctionMetadata CreateHttpTrigger(string name, string route, string entryPoint) + internal static DefaultFunctionMetadata CreateHttpTrigger(string name, string route, string entryPoint, string methods = "\"post\"") { return new DefaultFunctionMetadata() { @@ -47,7 +48,7 @@ internal static DefaultFunctionMetadata CreateHttpTrigger(string name, string ro Language = "dotnet-isolated", RawBindings = [ - $"{{\"name\":\"req\",\"type\":\"httpTrigger\",\"direction\":\"In\",\"authLevel\":\"function\",\"methods\": [\"post\"],\"route\":\"{route}\"}}", + $"{{\"name\":\"req\",\"type\":\"httpTrigger\",\"direction\":\"In\",\"authLevel\":\"function\",\"methods\": [{methods}],\"route\":\"{route}\"}}", "{\"name\":\"$return\",\"type\":\"http\",\"direction\":\"Out\"}", "{\"name\":\"client\",\"type\":\"durableClient\",\"direction\":\"In\"}" ], diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsApplicationBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsApplicationBuilderExtensions.cs index 87e390ac4a..9cf22b27f7 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsApplicationBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsApplicationBuilderExtensions.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using Microsoft.Agents.AI.DurableTask; using Microsoft.Agents.AI.DurableTask.Workflows; @@ -62,10 +62,10 @@ public static FunctionsApplicationBuilder ConfigureDurableOptions( ArgumentNullException.ThrowIfNull(builder); ArgumentNullException.ThrowIfNull(configure); - builder.Services.ConfigureDurableOptions(configure); + // Ensure FunctionsDurableOptions is registered BEFORE the core extension creates a plain DurableOptions + FunctionsDurableOptions sharedOptions = GetOrCreateSharedOptions(builder.Services); - // Read the shared options to check if workflows were added - DurableOptions sharedOptions = GetOrCreateSharedOptions(builder.Services); + builder.Services.ConfigureDurableOptions(configure); if (sharedOptions.Workflows.Workflows.Count > 0) { @@ -105,7 +105,9 @@ private static void EnsureMiddlewareRegistered(FunctionsApplicationBuilder build string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunAgentEntityFunctionEntryPoint, StringComparison.Ordinal) || string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunWorkflowOrchestrationHttpFunctionEntryPoint, StringComparison.Ordinal) || string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunWorkflowOrchestrationFunctionEntryPoint, StringComparison.Ordinal) || - string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.InvokeWorkflowActivityFunctionEntryPoint, StringComparison.Ordinal) + string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.InvokeWorkflowActivityFunctionEntryPoint, StringComparison.Ordinal) || + string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.GetWorkflowStatusHttpFunctionEntryPoint, StringComparison.Ordinal) || + string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RespondToWorkflowHttpFunctionEntryPoint, StringComparison.Ordinal) ); builder.Services.TryAddSingleton(); } @@ -113,17 +115,18 @@ private static void EnsureMiddlewareRegistered(FunctionsApplicationBuilder build /// /// Gets or creates a shared instance from the service collection. /// - private static DurableOptions GetOrCreateSharedOptions(IServiceCollection services) + private static FunctionsDurableOptions GetOrCreateSharedOptions(IServiceCollection services) { ServiceDescriptor? existingDescriptor = services.FirstOrDefault( d => d.ServiceType == typeof(DurableOptions) && d.ImplementationInstance is not null); - if (existingDescriptor?.ImplementationInstance is DurableOptions existing) + if (existingDescriptor?.ImplementationInstance is FunctionsDurableOptions existing) { return existing; } - DurableOptions options = new(); + FunctionsDurableOptions options = new(); + services.AddSingleton(options); services.AddSingleton(options); return options; } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsDurableOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsDurableOptions.cs new file mode 100644 index 0000000000..7a6ef6bec6 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsDurableOptions.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.DurableTask; + +namespace Microsoft.Agents.AI.Hosting.AzureFunctions; + +/// +/// Extends with Azure Functions–specific configuration. +/// +internal sealed class FunctionsDurableOptions : DurableOptions +{ + private readonly HashSet _statusEndpointWorkflows = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Enables the status HTTP endpoint for the specified workflow. + /// + internal void EnableStatusEndpoint(string workflowName) + { + this._statusEndpointWorkflows.Add(workflowName); + } + + /// + /// Returns whether the status endpoint is enabled for the specified workflow. + /// + internal bool IsStatusEndpointEnabled(string workflowName) + { + return this._statusEndpointWorkflows.Contains(workflowName); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/DurableWorkflowOptionsExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/DurableWorkflowOptionsExtensions.cs new file mode 100644 index 0000000000..de822cfa45 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/DurableWorkflowOptionsExtensions.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.DurableTask.Workflows; +using Microsoft.Agents.AI.Workflows; + +namespace Microsoft.Agents.AI.Hosting.AzureFunctions; + +/// +/// Extension methods for to configure Azure Functions HTTP trigger options. +/// +public static class DurableWorkflowOptionsExtensions +{ + /// + /// Adds a workflow and optionally exposes a status HTTP endpoint for querying pending HITL requests. + /// + /// The workflow options to add the workflow to. + /// The workflow instance to add. + /// If , a GET endpoint is generated at workflows/{name}/status/{runId}. + public static void AddWorkflow(this DurableWorkflowOptions options, Workflow workflow, bool exposeStatusEndpoint) + { + ArgumentNullException.ThrowIfNull(options); + + options.AddWorkflow(workflow); + + if (exposeStatusEndpoint && options.ParentOptions is FunctionsDurableOptions functionsOptions) + { + functionsOptions.EnableStatusEndpoint(workflow.Name!); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/DurableWorkflowsFunctionMetadataTransformer.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/DurableWorkflowsFunctionMetadataTransformer.cs index d1dd71061b..72775420f6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/DurableWorkflowsFunctionMetadataTransformer.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/DurableWorkflowsFunctionMetadataTransformer.cs @@ -25,18 +25,20 @@ namespace Microsoft.Agents.AI.Hosting.AzureFunctions; internal sealed class DurableWorkflowsFunctionMetadataTransformer : IFunctionMetadataTransformer { private readonly ILogger _logger; - private readonly DurableWorkflowOptions _options; + private readonly FunctionsDurableOptions _options; /// /// Initializes a new instance of the class. /// /// The logger instance for diagnostic output. /// The durable options containing workflow configurations. - public DurableWorkflowsFunctionMetadataTransformer(ILogger logger, DurableOptions durableOptions) + public DurableWorkflowsFunctionMetadataTransformer( + ILogger logger, + FunctionsDurableOptions durableOptions) { this._logger = logger ?? throw new ArgumentNullException(nameof(logger)); ArgumentNullException.ThrowIfNull(durableOptions); - this._options = durableOptions.Workflows; + this._options = durableOptions; } /// @@ -51,7 +53,7 @@ public void Transform(IList original) // Track registered function names to avoid duplicates when workflows share executors. HashSet registeredFunctions = []; - foreach (var workflow in this._options.Workflows) + foreach (var workflow in this._options.Workflows.Workflows) { string httpFunctionName = $"{BuiltInFunctions.HttpPrefix}{workflow.Key}"; @@ -80,6 +82,36 @@ public void Transform(IList original) BuiltInFunctions.RunWorkflowOrchestrationHttpFunctionEntryPoint)); } + // Register a status endpoint if opted in via AddWorkflow(exposeStatusEndpoint: true). + if (this._options.IsStatusEndpointEnabled(workflow.Key)) + { + string statusFunctionName = $"{BuiltInFunctions.HttpPrefix}{workflow.Key}-status"; + if (registeredFunctions.Add(statusFunctionName)) + { + this._logger.LogRegisteringWorkflowTrigger(workflow.Key, statusFunctionName, "http-status"); + original.Add(FunctionMetadataFactory.CreateHttpTrigger( + $"{workflow.Key}-status", + $"workflows/{workflow.Key}/status/{{runId}}", + BuiltInFunctions.GetWorkflowStatusHttpFunctionEntryPoint, + methods: "\"get\"")); + } + } + + // Register a respond endpoint when the workflow contains RequestPort nodes. + bool hasRequestPorts = workflow.Value.ReflectExecutors().Values.Any(b => b is RequestPortBinding); + if (hasRequestPorts) + { + string respondFunctionName = $"{BuiltInFunctions.HttpPrefix}{workflow.Key}-respond"; + if (registeredFunctions.Add(respondFunctionName)) + { + this._logger.LogRegisteringWorkflowTrigger(workflow.Key, respondFunctionName, "http-respond"); + original.Add(FunctionMetadataFactory.CreateHttpTrigger( + $"{workflow.Key}-respond", + $"workflows/{workflow.Key}/respond/{{runId}}", + BuiltInFunctions.RespondToWorkflowHttpFunctionEntryPoint)); + } + } + // Register activity or entity functions for each executor in the workflow. // ReflectExecutors() returns all executors across the graph; no need to manually traverse edges. foreach (KeyValuePair entry in workflow.Value.ReflectExecutors()) diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/WorkflowConsoleAppSamplesValidation.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/WorkflowConsoleAppSamplesValidation.cs index 97e2a1ef13..70bdfa4562 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/WorkflowConsoleAppSamplesValidation.cs +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/WorkflowConsoleAppSamplesValidation.cs @@ -451,6 +451,53 @@ await this.RunSampleTestAsync(samplePath, async (process, logs) => }); } + [Fact] + public async Task WorkflowHITLSampleValidationAsync() + { + using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts(); + string samplePath = Path.Combine(s_samplesPath, "08_WorkflowHITL"); + + await this.RunSampleTestAsync(samplePath, (process, logs) => + { + bool foundStarted = false; + bool foundManagerApprovalPause = false; + bool foundManagerApprovalInput = false; + bool foundManagerResponseSent = false; + bool foundFinanceApprovalPause = false; + bool foundFinanceResponseSent = false; + bool foundWorkflowCompleted = false; + + string? line; + while ((line = this.ReadLogLine(logs, testTimeoutCts.Token)) != null) + { + foundStarted |= line.Contains("Starting expense reimbursement workflow", StringComparison.Ordinal); + foundManagerApprovalPause |= line.Contains("Workflow paused at RequestPort: ManagerApproval", StringComparison.Ordinal); + foundManagerApprovalInput |= line.Contains("Approval for: Jerry", StringComparison.Ordinal); + foundManagerResponseSent |= line.Contains("Response sent: Approved=True", StringComparison.Ordinal) && foundManagerApprovalPause && !foundFinanceApprovalPause; + foundFinanceApprovalPause |= line.Contains("Workflow paused at RequestPort: FinanceApproval", StringComparison.Ordinal); + foundFinanceResponseSent |= line.Contains("Response sent: Approved=True", StringComparison.Ordinal) && foundFinanceApprovalPause; + + if (line.Contains("Workflow completed: Expense reimbursed at", StringComparison.Ordinal)) + { + foundWorkflowCompleted = true; + break; + } + + this.AssertNoError(line); + } + + Assert.True(foundStarted, "Workflow start message not found."); + Assert.True(foundManagerApprovalPause, "Manager approval pause not found."); + Assert.True(foundManagerApprovalInput, "Manager approval input (Jerry) not found."); + Assert.True(foundManagerResponseSent, "Manager approval response not sent."); + Assert.True(foundFinanceApprovalPause, "Finance approval pause not found."); + Assert.True(foundFinanceResponseSent, "Finance approval response not sent."); + Assert.True(foundWorkflowCompleted, "Workflow did not complete successfully."); + + return Task.CompletedTask; + }); + } + [Fact] public async Task WorkflowAndAgentsSampleValidationAsync() { diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/WorkflowSamplesValidation.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/WorkflowSamplesValidation.cs index 53be67daf2..f19d9237cc 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/WorkflowSamplesValidation.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/WorkflowSamplesValidation.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System.Diagnostics; using System.Reflection; @@ -117,6 +117,73 @@ await this.WaitForConditionAsync( }); } + [Fact] + public async Task HITLWorkflowSampleValidationAsync() + { + string samplePath = Path.Combine(s_samplesPath, "03_WorkflowHITL"); + await this.RunSampleTestAsync(samplePath, requiresOpenAI: false, async (logs) => + { + // Step 1: Start the expense reimbursement workflow + Uri runUri = new($"http://localhost:{AzureFunctionsPort}/api/workflows/ExpenseReimbursement/run?runId=hitl-test-001"); + this._outputHelper.WriteLine($"Starting ExpenseReimbursement workflow via POST request to {runUri}..."); + + using HttpContent runContent = new StringContent("EXP-2025-001", Encoding.UTF8, "text/plain"); + using HttpResponseMessage runResponse = await s_sharedHttpClient.PostAsync(runUri, runContent); + + Assert.True(runResponse.IsSuccessStatusCode, $"Run request failed with status: {runResponse.StatusCode}"); + string runResponseText = await runResponse.Content.ReadAsStringAsync(); + Assert.Contains("ExpenseReimbursement", runResponseText); + this._outputHelper.WriteLine($"Run response: {runResponseText}"); + + // Step 2: Wait for the workflow to pause at the ManagerApproval RequestPort + await this.WaitForConditionAsync( + condition: () => + { + lock (logs) + { + bool exists = logs.Any(log => log.Message.Contains("Workflow waiting for external input at RequestPort 'ManagerApproval'")); + return Task.FromResult(exists); + } + }, + message: "Workflow paused at ManagerApproval RequestPort", + timeout: s_orchestrationTimeout); + + // Step 3: Send approval response to resume the workflow + Uri respondUri = new($"http://localhost:{AzureFunctionsPort}/api/workflows/ExpenseReimbursement/respond/hitl-test-001"); + this._outputHelper.WriteLine($"Sending approval response via POST request to {respondUri}..."); + + using HttpContent respondContent = new StringContent( + """{"eventName": "ManagerApproval", "response": {"Approved": true, "Comments": "Approved by test."}}""", + Encoding.UTF8, "application/json"); + using HttpResponseMessage respondResponse = await s_sharedHttpClient.PostAsync(respondUri, respondContent); + + Assert.True(respondResponse.IsSuccessStatusCode, $"Respond request failed with status: {respondResponse.StatusCode}"); + string respondResponseText = await respondResponse.Content.ReadAsStringAsync(); + Assert.Contains("Response sent to workflow", respondResponseText); + this._outputHelper.WriteLine($"Respond response: {respondResponseText}"); + + // Step 4: Wait for the workflow to complete + await this.WaitForConditionAsync( + condition: () => + { + lock (logs) + { + bool exists = logs.Any(log => log.Message.Contains("Workflow completed")); + return Task.FromResult(exists); + } + }, + message: "HITL workflow completed", + timeout: s_orchestrationTimeout); + + // Verify executor activities ran + lock (logs) + { + Assert.True(logs.Any(log => log.Message.Contains("Received external event for RequestPort 'ManagerApproval'")), + "ManagerApproval external event receipt not found in logs."); + } + }); + } + [Fact] public async Task ConcurrentWorkflowSampleValidationAsync() { From dc8a881b141436d0483c12327f0d7bb9a035c4ce Mon Sep 17 00:00:00 2001 From: Shyju Krishnankutty Date: Fri, 27 Feb 2026 11:58:41 -0800 Subject: [PATCH 02/12] PR feedback fixes --- .../ConsoleApps/08_WorkflowHITL/README.md | 11 ++++++---- .../Workflows/DurableExecutorDispatcher.cs | 20 +++++++++++-------- .../BuiltInFunctions.cs | 11 +++++++++- .../DurableStreamingWorkflowRunTests.cs | 4 ++-- .../WorkflowSamplesValidation.cs | 7 +++++-- 5 files changed, 36 insertions(+), 17 deletions(-) diff --git a/dotnet/samples/Durable/Workflow/ConsoleApps/08_WorkflowHITL/README.md b/dotnet/samples/Durable/Workflow/ConsoleApps/08_WorkflowHITL/README.md index 77ade728f4..573a2e8d7b 100644 --- a/dotnet/samples/Durable/Workflow/ConsoleApps/08_WorkflowHITL/README.md +++ b/dotnet/samples/Durable/Workflow/ConsoleApps/08_WorkflowHITL/README.md @@ -39,10 +39,13 @@ Use `WatchStreamAsync` to observe events. When the workflow reaches a `RequestPo ```csharp await foreach (WorkflowEvent evt in run.WatchStreamAsync()) { - case DurableWorkflowWaitingForInputEvent requestEvent: - ApprovalRequest? request = requestEvent.GetInputAs(); - await run.SendResponseAsync(requestEvent, new ApprovalResponse(Approved: true, Comments: "Approved.")); - break; + switch (evt) + { + case DurableWorkflowWaitingForInputEvent requestEvent: + ApprovalRequest? request = requestEvent.GetInputAs(); + await run.SendResponseAsync(requestEvent, new ApprovalResponse(Approved: true, Comments: "Approved.")); + break; + } } ``` diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableExecutorDispatcher.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableExecutorDispatcher.cs index f776bfbcfb..394ac9aba2 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableExecutorDispatcher.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableExecutorDispatcher.cs @@ -113,18 +113,22 @@ private static async Task ExecuteRequestPortAsync( logger.LogWaitingForExternalEvent(eventName); - // Publish pending request so external clients can discover what input is needed - PendingRequestPortStatus pendingEvent = new(EventName: eventName, Input: input); - liveStatus.PendingEvents.Add(pendingEvent); - - context.SetCustomStatus(liveStatus); + // Publish pending request so external clients can discover what input is needed (skip during replay) + if (!context.IsReplaying) + { + liveStatus.PendingEvents.Add(new PendingRequestPortStatus(EventName: eventName, Input: input)); + context.SetCustomStatus(liveStatus); + } // Wait until the external actor raises the event string response = await context.WaitForExternalEvent(eventName).ConfigureAwait(true); - // Remove this pending request after receiving the response - liveStatus.PendingEvents.Remove(pendingEvent); - context.SetCustomStatus(liveStatus.Events.Count > 0 || liveStatus.PendingEvents.Count > 0 ? liveStatus : null); + // Remove this pending request after receiving the response (skip during replay) + if (!context.IsReplaying) + { + liveStatus.PendingEvents.RemoveAll(p => p.EventName == eventName); + context.SetCustomStatus(liveStatus.Events.Count > 0 || liveStatus.PendingEvents.Count > 0 ? liveStatus : null); + } logger.LogReceivedExternalEvent(eventName); diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs index 5cd3d20714..20181db44f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs @@ -137,13 +137,22 @@ public static async Task RespondToWorkflowAsync( return await CreateErrorResponseAsync(req, context, HttpStatusCode.BadRequest, "Body must contain a non-empty 'eventName' and a 'response' property."); } - // Verify the orchestration exists and is waiting for the specified event + // Verify the orchestration exists and is in a valid state OrchestrationMetadata? metadata = await client.GetInstanceAsync(runId, getInputsAndOutputs: true); if (metadata is null) { return await CreateErrorResponseAsync(req, context, HttpStatusCode.NotFound, $"Workflow run '{runId}' not found."); } + if (metadata.RuntimeStatus is OrchestrationRuntimeStatus.Completed + or OrchestrationRuntimeStatus.Failed + or OrchestrationRuntimeStatus.Terminated) + { + return await CreateErrorResponseAsync(req, context, HttpStatusCode.BadRequest, + $"Workflow run '{runId}' is in terminal state '{metadata.RuntimeStatus}'."); + } + + // Verify the workflow is waiting for the specified event if (DurableWorkflowLiveStatus.TryParse(metadata.SerializedCustomStatus, out DurableWorkflowLiveStatus liveStatus) && !liveStatus.PendingEvents.Exists(p => string.Equals(p.EventName, request.EventName, StringComparison.Ordinal))) { diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableStreamingWorkflowRunTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableStreamingWorkflowRunTests.cs index c4b9037c94..a07ef0b989 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableStreamingWorkflowRunTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableStreamingWorkflowRunTests.cs @@ -36,8 +36,8 @@ private static OrchestrationMetadata CreateMetadata( private static string SerializeCustomStatus(List events) { - DurableWorkflowCustomStatus status = new() { Events = events }; - return JsonSerializer.Serialize(status, DurableWorkflowJsonContext.Default.DurableWorkflowCustomStatus); + DurableWorkflowLiveStatus status = new() { Events = events }; + return JsonSerializer.Serialize(status, DurableWorkflowJsonContext.Default.DurableWorkflowLiveStatus); } private static string SerializeWorkflowResult(string? result, List events) diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/WorkflowSamplesValidation.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/WorkflowSamplesValidation.cs index f19d9237cc..24b0f0c714 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/WorkflowSamplesValidation.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/WorkflowSamplesValidation.cs @@ -123,8 +123,11 @@ public async Task HITLWorkflowSampleValidationAsync() string samplePath = Path.Combine(s_samplesPath, "03_WorkflowHITL"); await this.RunSampleTestAsync(samplePath, requiresOpenAI: false, async (logs) => { + // Use a unique run ID to avoid conflicts with previous test runs + string runId = $"hitl-test-{Guid.NewGuid():N}"; + // Step 1: Start the expense reimbursement workflow - Uri runUri = new($"http://localhost:{AzureFunctionsPort}/api/workflows/ExpenseReimbursement/run?runId=hitl-test-001"); + Uri runUri = new($"http://localhost:{AzureFunctionsPort}/api/workflows/ExpenseReimbursement/run?runId={runId}"); this._outputHelper.WriteLine($"Starting ExpenseReimbursement workflow via POST request to {runUri}..."); using HttpContent runContent = new StringContent("EXP-2025-001", Encoding.UTF8, "text/plain"); @@ -149,7 +152,7 @@ await this.WaitForConditionAsync( timeout: s_orchestrationTimeout); // Step 3: Send approval response to resume the workflow - Uri respondUri = new($"http://localhost:{AzureFunctionsPort}/api/workflows/ExpenseReimbursement/respond/hitl-test-001"); + Uri respondUri = new($"http://localhost:{AzureFunctionsPort}/api/workflows/ExpenseReimbursement/respond/{runId}"); this._outputHelper.WriteLine($"Sending approval response via POST request to {respondUri}..."); using HttpContent respondContent = new StringContent( From 1179b4d752ac7afd3e588b2911f674afd9755056 Mon Sep 17 00:00:00 2001 From: Shyju Krishnankutty Date: Fri, 27 Feb 2026 12:17:46 -0800 Subject: [PATCH 03/12] Minor comment cleanup --- .../FunctionsDurableOptions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsDurableOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsDurableOptions.cs index 7a6ef6bec6..1d67166aaa 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsDurableOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsDurableOptions.cs @@ -5,7 +5,7 @@ namespace Microsoft.Agents.AI.Hosting.AzureFunctions; /// -/// Extends with Azure Functions–specific configuration. +/// Provides Azure Functions–specific configuration for durable workflows. /// internal sealed class FunctionsDurableOptions : DurableOptions { From 719c525f0809a93201329216091b62f48554d136 Mon Sep 17 00:00:00 2001 From: Shyju Krishnankutty Date: Fri, 27 Feb 2026 13:05:54 -0800 Subject: [PATCH 04/12] Minor comment clReverted the `!context.IsReplaying` guards on `PendingEvents.Add`/`RemoveAll` and `SetCustomStatus` in `ExecuteRequestPortAsync`. The guards broke fan-out scenarios where parallel RequestPorts need to be discoverable after replay. `SetCustomStatus` is idempotent metadata that doesn't affect replay determinism.eanup --- .../Workflows/DurableExecutorDispatcher.cs | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableExecutorDispatcher.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableExecutorDispatcher.cs index 394ac9aba2..afb7a774fb 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableExecutorDispatcher.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableExecutorDispatcher.cs @@ -113,22 +113,16 @@ private static async Task ExecuteRequestPortAsync( logger.LogWaitingForExternalEvent(eventName); - // Publish pending request so external clients can discover what input is needed (skip during replay) - if (!context.IsReplaying) - { - liveStatus.PendingEvents.Add(new PendingRequestPortStatus(EventName: eventName, Input: input)); - context.SetCustomStatus(liveStatus); - } + // Publish pending request so external clients can discover what input is needed + liveStatus.PendingEvents.Add(new PendingRequestPortStatus(EventName: eventName, Input: input)); + context.SetCustomStatus(liveStatus); // Wait until the external actor raises the event string response = await context.WaitForExternalEvent(eventName).ConfigureAwait(true); - // Remove this pending request after receiving the response (skip during replay) - if (!context.IsReplaying) - { - liveStatus.PendingEvents.RemoveAll(p => p.EventName == eventName); - context.SetCustomStatus(liveStatus.Events.Count > 0 || liveStatus.PendingEvents.Count > 0 ? liveStatus : null); - } + // Remove this pending request after receiving the response + liveStatus.PendingEvents.RemoveAll(p => p.EventName == eventName); + context.SetCustomStatus(liveStatus.Events.Count > 0 || liveStatus.PendingEvents.Count > 0 ? liveStatus : null); logger.LogReceivedExternalEvent(eventName); From 927f172c4a0bf1f34c6002eac72291b54ed70931 Mon Sep 17 00:00:00 2001 From: Shyju Krishnankutty Date: Fri, 27 Feb 2026 13:18:26 -0800 Subject: [PATCH 05/12] fix for PR feedback --- .../BuiltInFunctions.cs | 29 ++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs index 20181db44f..a7db46e025 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs @@ -152,19 +152,34 @@ or OrchestrationRuntimeStatus.Failed $"Workflow run '{runId}' is in terminal state '{metadata.RuntimeStatus}'."); } - // Verify the workflow is waiting for the specified event - if (DurableWorkflowLiveStatus.TryParse(metadata.SerializedCustomStatus, out DurableWorkflowLiveStatus liveStatus) - && !liveStatus.PendingEvents.Exists(p => string.Equals(p.EventName, request.EventName, StringComparison.Ordinal))) + // Verify the workflow is waiting for the specified event. + // If status can't be parsed (e.g., not yet set during early execution), allow the event through — + // Durable Task safely queues it until the orchestration reaches WaitForExternalEvent. + bool eventValidated = false; + if (DurableWorkflowLiveStatus.TryParse(metadata.SerializedCustomStatus, out DurableWorkflowLiveStatus liveStatus)) { - return await CreateErrorResponseAsync(req, context, HttpStatusCode.BadRequest, - $"Workflow is not waiting for event '{request.EventName}'."); + if (!liveStatus.PendingEvents.Exists(p => string.Equals(p.EventName, request.EventName, StringComparison.Ordinal))) + { + return await CreateErrorResponseAsync(req, context, HttpStatusCode.BadRequest, + $"Workflow is not waiting for event '{request.EventName}'."); + } + + eventValidated = true; } // Raise the external event to unblock the orchestration's WaitForExternalEvent call await client.RaiseEventAsync(runId, request.EventName, request.Response.GetRawText()); - HttpResponseData response = req.CreateResponse(HttpStatusCode.OK); - await response.WriteAsJsonAsync(new { message = "Response sent to workflow.", runId, eventName = request.EventName }); + HttpResponseData response = req.CreateResponse(HttpStatusCode.Accepted); + await response.WriteAsJsonAsync(new + { + message = eventValidated + ? "Response sent to workflow." + : "Response sent to workflow. Event could not be validated against pending requests.", + runId, + eventName = request.EventName, + validated = eventValidated, + }); return response; } From 1946f031a1bfc2eb4f867e4bc368271e13d4549b Mon Sep 17 00:00:00 2001 From: Shyju Krishnankutty Date: Fri, 27 Feb 2026 14:03:49 -0800 Subject: [PATCH 06/12] PR feedback updates --- ...bleWorkflowsFunctionMetadataTransformer.cs | 4 +- .../DurableStreamingWorkflowRunTests.cs | 143 +++++++++++++++++- 2 files changed, 144 insertions(+), 3 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/DurableWorkflowsFunctionMetadataTransformer.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/DurableWorkflowsFunctionMetadataTransformer.cs index 72775420f6..cada02d387 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/DurableWorkflowsFunctionMetadataTransformer.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/DurableWorkflowsFunctionMetadataTransformer.cs @@ -116,8 +116,8 @@ public void Transform(IList original) // ReflectExecutors() returns all executors across the graph; no need to manually traverse edges. foreach (KeyValuePair entry in workflow.Value.ReflectExecutors()) { - // Sub-workflow bindings are handled as separate orchestrations, not activities. - if (entry.Value is SubworkflowBinding) + // Sub-workflow and RequestPort bindings use specialized dispatch, not activities. + if (entry.Value is SubworkflowBinding or RequestPortBinding) { continue; } diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableStreamingWorkflowRunTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableStreamingWorkflowRunTests.cs index a07ef0b989..4f07167942 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableStreamingWorkflowRunTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableStreamingWorkflowRunTests.cs @@ -37,7 +37,27 @@ private static OrchestrationMetadata CreateMetadata( private static string SerializeCustomStatus(List events) { DurableWorkflowLiveStatus status = new() { Events = events }; - return JsonSerializer.Serialize(status, DurableWorkflowJsonContext.Default.DurableWorkflowLiveStatus); + return JsonSerializer.Serialize(status, DurableSerialization.Options); + } + + private static string SerializeCustomStatusWithPendingEvents( + List events, + List pendingEvents) + { + DurableWorkflowLiveStatus status = new() { Events = events, PendingEvents = pendingEvents }; + return JsonSerializer.Serialize(status, DurableSerialization.Options); + } + + private static Workflow CreateTestWorkflowWithRequestPort(string requestPortId) + { + FunctionExecutor start = new("start", (_, _, _) => default); + RequestPort requestPort = RequestPort.Create(requestPortId); + FunctionExecutor end = new("end", (_, _, _) => default); + return new WorkflowBuilder(start) + .WithName(WorkflowTestName) + .AddEdge(start, requestPort) + .AddEdge(requestPort, end) + .Build(); } private static string SerializeWorkflowResult(string? result, List events) @@ -486,6 +506,127 @@ public async Task WatchStreamAsync_Cancellation_EndsGracefullyAsync() Assert.Empty(events); } + [Fact] + public async Task WatchStreamAsync_PendingRequestPort_YieldsWaitingForInputEventAsync() + { + // Arrange + string customStatus = SerializeCustomStatusWithPendingEvents( + [], + [new PendingRequestPortStatus("ApprovalPort", """{"amount":100}""")]); + string serializedOutput = SerializeWorkflowResult("approved", []); + + int callCount = 0; + Mock mockClient = new("test"); + mockClient.Setup(c => c.GetInstanceAsync(InstanceId, true, It.IsAny())) + .ReturnsAsync(() => + { + callCount++; + return callCount == 1 + ? CreateMetadata(OrchestrationRuntimeStatus.Running, serializedCustomStatus: customStatus) + : CreateMetadata(OrchestrationRuntimeStatus.Completed, serializedOutput: serializedOutput); + }); + + Workflow workflow = CreateTestWorkflowWithRequestPort("ApprovalPort"); + DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, workflow); + + // Act + List events = []; + await foreach (WorkflowEvent evt in run.WatchStreamAsync()) + { + events.Add(evt); + } + + // Assert + Assert.Equal(2, events.Count); + DurableWorkflowWaitingForInputEvent waitingEvent = Assert.IsType(events[0]); + Assert.Equal("ApprovalPort", waitingEvent.RequestPort.Id); + Assert.Contains("amount", waitingEvent.Input); + DurableWorkflowCompletedEvent completedEvent = Assert.IsType(events[1]); + Assert.Equal("approved", completedEvent.Result); + } + + [Fact] + public async Task WatchStreamAsync_PendingRequestPort_DoesNotDuplicateOnSubsequentPollsAsync() + { + // Arrange — same pending event across 2 polls, then completion + string customStatus = SerializeCustomStatusWithPendingEvents( + [], + [new PendingRequestPortStatus("ApprovalPort", """{"amount":100}""")]); + string serializedOutput = SerializeWorkflowResult("done", []); + + int callCount = 0; + Mock mockClient = new("test"); + mockClient.Setup(c => c.GetInstanceAsync(InstanceId, true, It.IsAny())) + .ReturnsAsync(() => + { + callCount++; + return callCount switch + { + <= 2 => CreateMetadata(OrchestrationRuntimeStatus.Running, serializedCustomStatus: customStatus), + _ => CreateMetadata(OrchestrationRuntimeStatus.Completed, serializedOutput: serializedOutput), + }; + }); + + Workflow workflow = CreateTestWorkflowWithRequestPort("ApprovalPort"); + DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, workflow); + + // Act + List events = []; + await foreach (WorkflowEvent evt in run.WatchStreamAsync()) + { + events.Add(evt); + } + + // Assert — WaitingForInputEvent yielded only once despite 2 polls + Assert.Equal(2, events.Count); + Assert.IsType(events[0]); + Assert.IsType(events[1]); + } + + #endregion + + #region SendResponseAsync + + [Fact] + public async Task SendResponseAsync_SerializesAndRaisesEventAsync() + { + // Arrange + Mock mockClient = new("test"); + mockClient.Setup(c => c.RaiseEventAsync( + InstanceId, + "ApprovalPort", + It.IsAny(), + It.IsAny())) + .Returns(Task.CompletedTask); + + RequestPort approvalPort = RequestPort.Create("ApprovalPort"); + DurableWorkflowWaitingForInputEvent requestEvent = new("""{"amount":100}""", approvalPort); + Workflow workflow = CreateTestWorkflowWithRequestPort("ApprovalPort"); + DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, workflow); + + // Act + await run.SendResponseAsync(requestEvent, new { approved = true, comments = "Looks good" }); + + // Assert + mockClient.Verify(c => c.RaiseEventAsync( + InstanceId, + "ApprovalPort", + It.Is(s => s.Contains("approved") && s.Contains("true")), + It.IsAny()), Times.Once); + } + + [Fact] + public async Task SendResponseAsync_NullRequestEvent_ThrowsAsync() + { + // Arrange + Mock mockClient = new("test"); + DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow()); + + // Act & Assert + await Assert.ThrowsAsync(() => + run.SendResponseAsync(null!, "response").AsTask()); + } + #endregion #region WaitForCompletionAsync From 5dfbe3e0e070986eed0b1c9196383cf83fd1d9f4 Mon Sep 17 00:00:00 2001 From: Shyju Krishnankutty Date: Fri, 27 Feb 2026 17:28:58 -0800 Subject: [PATCH 07/12] Improvements to samples --- .../03_WorkflowHITL/Executors.cs | 24 +++- .../AzureFunctions/03_WorkflowHITL/Program.cs | 19 ++- .../AzureFunctions/03_WorkflowHITL/README.md | 133 ++++++++++++++++-- .../AzureFunctions/03_WorkflowHITL/demo.http | 27 +++- .../BuiltInFunctions.cs | 11 +- 5 files changed, 179 insertions(+), 35 deletions(-) diff --git a/dotnet/samples/Durable/Workflow/AzureFunctions/03_WorkflowHITL/Executors.cs b/dotnet/samples/Durable/Workflow/AzureFunctions/03_WorkflowHITL/Executors.cs index 08ce2acde1..3a6550b4b0 100644 --- a/dotnet/samples/Durable/Workflow/AzureFunctions/03_WorkflowHITL/Executors.cs +++ b/dotnet/samples/Durable/Workflow/AzureFunctions/03_WorkflowHITL/Executors.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using Microsoft.Agents.AI.Workflows; @@ -23,7 +23,25 @@ public override ValueTask HandleAsync( } } -/// Processes the expense reimbursement based on the approval decision. +/// Prepares the approval request for finance review after manager approval. +internal sealed class PrepareFinanceReview() : Executor("PrepareFinanceReview") +{ + public override ValueTask HandleAsync( + ApprovalResponse message, + IWorkflowContext context, + CancellationToken cancellationToken = default) + { + if (!message.Approved) + { + throw new InvalidOperationException("Cannot proceed to finance review — manager denied the expense."); + } + + // In a real scenario, this would retrieve the original expense details + return new ValueTask(new ApprovalRequest("EXP-2025-001", 1500.00m, "Jerry")); + } +} + +/// Processes the expense reimbursement based on the final approval response. internal sealed class ExpenseReimburse() : Executor("Reimburse") { public override async ValueTask HandleAsync( @@ -33,7 +51,7 @@ public override async ValueTask HandleAsync( { if (!message.Approved) { - return $"Expense reimbursement denied. Comments: {message.Comments}"; + return $"Expense reimbursement denied by finance. Comments: {message.Comments}"; } // Simulate payment processing diff --git a/dotnet/samples/Durable/Workflow/AzureFunctions/03_WorkflowHITL/Program.cs b/dotnet/samples/Durable/Workflow/AzureFunctions/03_WorkflowHITL/Program.cs index 53e1770e53..1bea3e02e4 100644 --- a/dotnet/samples/Durable/Workflow/AzureFunctions/03_WorkflowHITL/Program.cs +++ b/dotnet/samples/Durable/Workflow/AzureFunctions/03_WorkflowHITL/Program.cs @@ -1,9 +1,10 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. // This sample demonstrates a Human-in-the-Loop (HITL) workflow hosted in Azure Functions. -// Workflow: CreateApprovalRequest -> ManagerApproval (RequestPort/HITL pause) -> ExpenseReimburse +// Workflow: CreateApprovalRequest -> ManagerApproval (HITL) -> PrepareFinanceReview -> FinanceApproval (HITL) -> ExpenseReimburse // -// The workflow pauses at a RequestPort and waits for an external approval response via HTTP. +// The workflow pauses at two sequential RequestPorts and waits for external approval responses via HTTP. +// This simulates an expense that requires both manager and finance approval. // The framework auto-generates three HTTP endpoints for each workflow: // POST /api/workflows/{name}/run - Start the workflow // GET /api/workflows/{name}/status/{id} - Check status and pending approvals @@ -15,17 +16,21 @@ using Microsoft.Extensions.Hosting; using WorkflowHITLFunctions; -// Define executors and a RequestPort for the HITL pause point +// Define executors and RequestPorts for the two HITL pause points CreateApprovalRequest createRequest = new(); RequestPort managerApproval = RequestPort.Create("ManagerApproval"); +PrepareFinanceReview prepareFinanceReview = new(); +RequestPort financeApproval = RequestPort.Create("FinanceApproval"); ExpenseReimburse reimburse = new(); -// Build the workflow: CreateApprovalRequest -> ManagerApproval (HITL) -> ExpenseReimburse +// Build the workflow: CreateApprovalRequest -> ManagerApproval -> PrepareFinanceReview -> FinanceApproval -> ExpenseReimburse Workflow expenseApproval = new WorkflowBuilder(createRequest) .WithName("ExpenseReimbursement") - .WithDescription("Expense reimbursement with manager approval") + .WithDescription("Expense reimbursement with manager and finance approval") .AddEdge(createRequest, managerApproval) - .AddEdge(managerApproval, reimburse) + .AddEdge(managerApproval, prepareFinanceReview) + .AddEdge(prepareFinanceReview, financeApproval) + .AddEdge(financeApproval, reimburse) .Build(); using IHost app = FunctionsApplication diff --git a/dotnet/samples/Durable/Workflow/AzureFunctions/03_WorkflowHITL/README.md b/dotnet/samples/Durable/Workflow/AzureFunctions/03_WorkflowHITL/README.md index 77ab55cc8d..3a7ca3a0b9 100644 --- a/dotnet/samples/Durable/Workflow/AzureFunctions/03_WorkflowHITL/README.md +++ b/dotnet/samples/Durable/Workflow/AzureFunctions/03_WorkflowHITL/README.md @@ -1,16 +1,17 @@ # Human-in-the-Loop (HITL) Workflow — Azure Functions -This sample demonstrates a durable workflow with Human-in-the-Loop support hosted in Azure Functions. The workflow pauses at a `RequestPort` and waits for an external approval response sent via an HTTP endpoint. +This sample demonstrates a durable workflow with Human-in-the-Loop support hosted in Azure Functions. The workflow pauses at two sequential `RequestPort` nodes and waits for external approval responses sent via HTTP endpoints. This simulates an expense that requires both manager and finance approval. ## Key Concepts Demonstrated -- Using `RequestPort` for human-in-the-loop interaction in a durable workflow +- Using multiple `RequestPort` nodes for sequential human-in-the-loop interactions in a durable workflow - Auto-generated HTTP endpoints for running workflows, checking status, and sending HITL responses - Pausing orchestrations via `WaitForExternalEvent` and resuming via `RaiseEventAsync` +- Viewing inputs the workflow is waiting for via the status endpoint ## Workflow -`CreateApprovalRequest` → `ManagerApproval` (HITL pause) → `ExpenseReimburse` +`CreateApprovalRequest` → `ManagerApproval` (HITL pause) → `PrepareFinanceReview` → `FinanceApproval` (HITL pause) → `ExpenseReimburse` ## HTTP Endpoints @@ -19,7 +20,7 @@ The framework auto-generates these endpoints for workflows with `RequestPort` no | Method | Endpoint | Description | |--------|----------|-------------| | POST | `/api/workflows/ExpenseReimbursement/run` | Start the workflow | -| GET | `/api/workflows/ExpenseReimbursement/status/{runId}` | Check status and pending approvals | +| GET | `/api/workflows/ExpenseReimbursement/status/{runId}` | Check status and inputs the workflow is waiting for | | POST | `/api/workflows/ExpenseReimbursement/respond/{runId}` | Send approval response to resume | ## Environment Setup @@ -28,31 +29,137 @@ See the [README.md](../../README.md) file in the parent directory for informatio ## Running the Sample -Use the `demo.http` file or the steps below: +With the environment setup and function app running, you can test the sample by sending HTTP requests to the workflow endpoints. + +You can use the `demo.http` file to trigger the workflow, or a command line tool like `curl` as shown below: ### Step 1: Start the Workflow +Bash (Linux/macOS/WSL): + ```bash -curl -X POST "http://localhost:7071/api/workflows/ExpenseReimbursement/run?runId=expense-001" \ +curl -X POST http://localhost:7071/api/workflows/ExpenseReimbursement/run \ -H "Content-Type: text/plain" -d "EXP-2025-001" ``` +PowerShell: + +```powershell +Invoke-RestMethod -Method Post ` + -Uri http://localhost:7071/api/workflows/ExpenseReimbursement/run ` + -ContentType text/plain ` + -Body "EXP-2025-001" +``` + +The response will confirm the workflow orchestration has started: + +```text +Workflow orchestration started for ExpenseReimbursement. Orchestration runId: abc123def456 +``` + +> **Tip:** You can provide a custom run ID by appending a `runId` query parameter: +> +> ```bash +> curl -X POST "http://localhost:7071/api/workflows/ExpenseReimbursement/run?runId=expense-001" \ +> -H "Content-Type: text/plain" -d "EXP-2025-001" +> ``` +> +> If not provided, a unique run ID is auto-generated. + ### Step 2: Check Workflow Status -The workflow pauses at the `ManagerApproval` RequestPort: +The workflow pauses at the `ManagerApproval` RequestPort. Query the status endpoint to see what input it is waiting for: ```bash -curl http://localhost:7071/api/workflows/ExpenseReimbursement/status/expense-001 +curl http://localhost:7071/api/workflows/ExpenseReimbursement/status/{runId} ``` -### Step 3: Send Approval Response +```json +{ + "runId": "{runId}", + "status": "Running", + "waitingForInput": [ + { "eventName": "ManagerApproval", "input": { "ExpenseId": "EXP-2025-001", "Amount": 1500.00, "EmployeeName": "Jerry" } } + ] +} +``` + +> **Tip:** You can also verify this in the DTS dashboard at `http://localhost:8082`. Find the orchestration by its `runId` and you will see it is in a "Running" state, paused at a `WaitForExternalEvent` call for the `ManagerApproval` event. + +### Step 3: Send Manager Approval Response ```bash -curl -X POST http://localhost:7071/api/workflows/ExpenseReimbursement/respond/expense-001 \ +curl -X POST http://localhost:7071/api/workflows/ExpenseReimbursement/respond/{runId} \ -H "Content-Type: application/json" \ - -d '{"eventName": "ManagerApproval", "response": {"Approved": true, "Comments": "Looks good!"}}' + -d '{"eventName": "ManagerApproval", "response": {"Approved": true, "Comments": "Approved by manager."}}' +``` + +```json +{ + "message": "Response sent to workflow.", + "runId": "{runId}", + "eventName": "ManagerApproval", + "validated": true +} +``` + +### Step 4: Check Workflow Status Again + +The workflow now pauses at the `FinanceApproval` RequestPort: + +```bash +curl http://localhost:7071/api/workflows/ExpenseReimbursement/status/{runId} ``` -### DTS Dashboard +```json +{ + "runId": "{runId}", + "status": "Running", + "waitingForInput": [ + { "eventName": "FinanceApproval", "input": { "ExpenseId": "EXP-2025-001", "Amount": 1500.00, "EmployeeName": "Jerry" } } + ] +} +``` + +### Step 5: Send Finance Approval Response + +```bash +curl -X POST http://localhost:7071/api/workflows/ExpenseReimbursement/respond/{runId} \ + -H "Content-Type: application/json" \ + -d '{"eventName": "FinanceApproval", "response": {"Approved": true, "Comments": "Finance approved."}}' +``` + +```json +{ + "message": "Response sent to workflow.", + "runId": "{runId}", + "eventName": "FinanceApproval", + "validated": true +} +``` + +### Step 6: Check Final Status + +After both approvals, the workflow completes and the expense is reimbursed: + +```bash +curl http://localhost:7071/api/workflows/ExpenseReimbursement/status/{runId} +``` + +```json +{ + "runId": "{runId}", + "status": "Completed", + "waitingForInput": null +} +``` + +### Viewing Workflows in the DTS Dashboard + +After running a workflow, you can navigate to the Durable Task Scheduler (DTS) dashboard to visualize the orchestration and inspect its execution history. + +If you are using the DTS emulator, the dashboard is available at `http://localhost:8082`. -If using the DTS emulator, the dashboard is available at `http://localhost:8082` to visualize the orchestration and inspect the external event interaction. +1. Open the dashboard and look for the orchestration instance matching the `runId` returned in Step 1 (e.g., `abc123def456` or your custom ID like `expense-001`). +2. Click into the instance to see the execution timeline, which shows each executor activity and the two `WaitForExternalEvent` pauses where the workflow waited for human input. +3. Expand individual activity steps to inspect inputs and outputs — for example, the `ManagerApproval` and `FinanceApproval` external events will show the approval request sent and the response received. diff --git a/dotnet/samples/Durable/Workflow/AzureFunctions/03_WorkflowHITL/demo.http b/dotnet/samples/Durable/Workflow/AzureFunctions/03_WorkflowHITL/demo.http index 8014c98540..bf5d637a3a 100644 --- a/dotnet/samples/Durable/Workflow/AzureFunctions/03_WorkflowHITL/demo.http +++ b/dotnet/samples/Durable/Workflow/AzureFunctions/03_WorkflowHITL/demo.http @@ -16,17 +16,32 @@ EXP-2025-001 ### Step 2: Check workflow status (replace {runId} with actual run ID from Step 1) GET {{authority}}/api/workflows/ExpenseReimbursement/status/{runId} -### Step 3: Approve the expense (replace {runId} with actual run ID from Step 1) +### Step 3: Send manager approval (replace {runId} with actual run ID from Step 1) POST {{authority}}/api/workflows/ExpenseReimbursement/respond/{runId} Content-Type: application/json -{"eventName": "ManagerApproval", "response": {"Approved": true, "Comments": "Approved by manager. Looks good!"}} +{"eventName": "ManagerApproval", "response": {"Approved": true, "Comments": "Approved by manager."}} -### Step 3 (alternative): Deny the expense -POST {{authority}}/api/workflows/ExpenseReimbursement/respond/expense-001 +### Step 3 (alternative): Deny the expense at manager level +POST {{authority}}/api/workflows/ExpenseReimbursement/respond/{runId} Content-Type: application/json {"eventName": "ManagerApproval", "response": {"Approved": false, "Comments": "Insufficient documentation. Please resubmit."}} -### Step 4: Check final workflow status after approval -GET {{authority}}/api/workflows/ExpenseReimbursement/status/expense-001 +### Step 4: Check workflow status after manager approval (now waiting for finance approval) +GET {{authority}}/api/workflows/ExpenseReimbursement/status/{runId} + +### Step 5: Send finance approval (replace {runId} with actual run ID from Step 1) +POST {{authority}}/api/workflows/ExpenseReimbursement/respond/{runId} +Content-Type: application/json + +{"eventName": "FinanceApproval", "response": {"Approved": true, "Comments": "Finance approved."}} + +### Step 5 (alternative): Deny the expense at finance level +POST {{authority}}/api/workflows/ExpenseReimbursement/respond/{runId} +Content-Type: application/json + +{"eventName": "FinanceApproval", "response": {"Approved": false, "Comments": "Budget exceeded for this quarter."}} + +### Step 6: Check final workflow status after both approvals +GET {{authority}}/api/workflows/ExpenseReimbursement/status/{runId} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs index a7db46e025..6dc1ab2244 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs @@ -1,6 +1,5 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. -using System.Diagnostics.CodeAnalysis; using System.Net; using System.Text.Json; using System.Text.Json.Serialization; @@ -88,12 +87,12 @@ public static async Task GetWorkflowStatusAsync( return await CreateErrorResponseAsync(req, context, HttpStatusCode.NotFound, $"Workflow run '{runId}' not found."); } - // Parse pending HITL requests from the durable workflow status - List? pendingRequests = null; + // Parse HITL inputs the workflow is waiting for from the durable workflow status + List? waitingForInput = null; if (DurableWorkflowLiveStatus.TryParse(metadata.SerializedCustomStatus, out DurableWorkflowLiveStatus liveStatus) && liveStatus.PendingEvents.Count > 0) { - pendingRequests = liveStatus.PendingEvents; + waitingForInput = liveStatus.PendingEvents; } HttpResponseData response = req.CreateResponse(HttpStatusCode.OK); @@ -101,7 +100,7 @@ await response.WriteAsJsonAsync(new { runId, status = metadata.RuntimeStatus.ToString(), - pendingRequests = pendingRequests?.Select(p => new { eventName = p.EventName }) + waitingForInput = waitingForInput?.Select(p => new { eventName = p.EventName, input = JsonDocument.Parse(p.Input).RootElement }) }); return response; } From 3d27a7ae8c08b081daf56c63bea9d6baf40853a3 Mon Sep 17 00:00:00 2001 From: Shyju Krishnankutty Date: Fri, 27 Feb 2026 17:38:05 -0800 Subject: [PATCH 08/12] Improvements to README --- .../Workflow/ConsoleApps/08_WorkflowHITL/README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/dotnet/samples/Durable/Workflow/ConsoleApps/08_WorkflowHITL/README.md b/dotnet/samples/Durable/Workflow/ConsoleApps/08_WorkflowHITL/README.md index 573a2e8d7b..4a8e717c3e 100644 --- a/dotnet/samples/Durable/Workflow/ConsoleApps/08_WorkflowHITL/README.md +++ b/dotnet/samples/Durable/Workflow/ConsoleApps/08_WorkflowHITL/README.md @@ -78,3 +78,13 @@ Workflow paused at RequestPort: FinanceApproval Workflow completed: Expense reimbursed at 2025-01-23T17:30:00.0000000Z ``` + +### Viewing Workflows in the DTS Dashboard + +After running the sample, you can navigate to the Durable Task Scheduler (DTS) dashboard to visualize the completed orchestration and inspect its execution history. + +If you are using the DTS emulator, the dashboard is available at `http://localhost:8082`. + +1. Open the dashboard and look for the orchestration instance matching the instance ID logged in the console output (e.g., `abc123...`). +2. Click into the instance to see the execution timeline, which shows each executor activity and the two `WaitForExternalEvent` pauses where the workflow waited for human input. +3. Expand individual activity steps to inspect inputs and outputs — for example, the `ManagerApproval` and `FinanceApproval` external events will show the approval request sent and the response received. From f7ae1ce64ec4386e5a2ad66b87f9f964a0ba67e4 Mon Sep 17 00:00:00 2001 From: Shyju Krishnankutty Date: Fri, 27 Feb 2026 22:33:06 -0800 Subject: [PATCH 09/12] Update samples to use parallel request ports. --- .../03_WorkflowHITL/Executors.cs | 12 +++-- .../AzureFunctions/03_WorkflowHITL/Program.cs | 28 ++++++---- .../AzureFunctions/03_WorkflowHITL/README.md | 53 +++++++++++++++---- .../AzureFunctions/03_WorkflowHITL/demo.http | 18 ++++--- .../ConsoleApps/08_WorkflowHITL/Executors.cs | 12 +++-- .../ConsoleApps/08_WorkflowHITL/Program.cs | 28 ++++++---- .../ConsoleApps/08_WorkflowHITL/README.md | 32 ++++++++--- .../Workflows/DurableActivityExecutor.cs | 29 ++++++++-- .../Workflows/DurableStreamingWorkflowRun.cs | 3 +- .../WorkflowConsoleAppSamplesValidation.cs | 20 ++++--- .../WorkflowSamplesValidation.cs | 41 +++++++++++++- 11 files changed, 210 insertions(+), 66 deletions(-) diff --git a/dotnet/samples/Durable/Workflow/AzureFunctions/03_WorkflowHITL/Executors.cs b/dotnet/samples/Durable/Workflow/AzureFunctions/03_WorkflowHITL/Executors.cs index 3a6550b4b0..c299ee2cd5 100644 --- a/dotnet/samples/Durable/Workflow/AzureFunctions/03_WorkflowHITL/Executors.cs +++ b/dotnet/samples/Durable/Workflow/AzureFunctions/03_WorkflowHITL/Executors.cs @@ -41,17 +41,19 @@ public override ValueTask HandleAsync( } } -/// Processes the expense reimbursement based on the final approval response. -internal sealed class ExpenseReimburse() : Executor("Reimburse") +/// Processes the expense reimbursement based on the parallel approval responses. +internal sealed class ExpenseReimburse() : Executor("Reimburse") { public override async ValueTask HandleAsync( - ApprovalResponse message, + ApprovalResponse[] message, IWorkflowContext context, CancellationToken cancellationToken = default) { - if (!message.Approved) + // Check that all parallel approvals passed + ApprovalResponse? denied = Array.Find(message, r => !r.Approved); + if (denied is not null) { - return $"Expense reimbursement denied by finance. Comments: {message.Comments}"; + return $"Expense reimbursement denied. Comments: {denied.Comments}"; } // Simulate payment processing diff --git a/dotnet/samples/Durable/Workflow/AzureFunctions/03_WorkflowHITL/Program.cs b/dotnet/samples/Durable/Workflow/AzureFunctions/03_WorkflowHITL/Program.cs index 1bea3e02e4..65f4b7acba 100644 --- a/dotnet/samples/Durable/Workflow/AzureFunctions/03_WorkflowHITL/Program.cs +++ b/dotnet/samples/Durable/Workflow/AzureFunctions/03_WorkflowHITL/Program.cs @@ -1,10 +1,19 @@ // Copyright (c) Microsoft. All rights reserved. // This sample demonstrates a Human-in-the-Loop (HITL) workflow hosted in Azure Functions. -// Workflow: CreateApprovalRequest -> ManagerApproval (HITL) -> PrepareFinanceReview -> FinanceApproval (HITL) -> ExpenseReimburse // -// The workflow pauses at two sequential RequestPorts and waits for external approval responses via HTTP. -// This simulates an expense that requires both manager and finance approval. +// ┌──────────────────────┐ ┌────────────────┐ ┌─────────────────────┐ ┌────────────────────┐ +// │ CreateApprovalRequest│──►│ManagerApproval │──►│PrepareFinanceReview │──┬►│ BudgetApproval │──┐ +// └──────────────────────┘ │ (RequestPort) │ └─────────────────────┘ │ │ (RequestPort) │ │ +// └────────────────┘ │ └────────────────────┘ │ ┌─────────────────┐ +// │ ├─►│ExpenseReimburse │ +// │ ┌────────────────────┐ │ └─────────────────┘ +// └►│ComplianceApproval │──┘ +// │ (RequestPort) │ +// └────────────────────┘ +// +// The workflow pauses at three RequestPorts — one for the manager, then two in parallel for finance. +// After manager approval, BudgetApproval and ComplianceApproval run concurrently via fan-out/fan-in. // The framework auto-generates three HTTP endpoints for each workflow: // POST /api/workflows/{name}/run - Start the workflow // GET /api/workflows/{name}/status/{id} - Check status and pending approvals @@ -16,21 +25,22 @@ using Microsoft.Extensions.Hosting; using WorkflowHITLFunctions; -// Define executors and RequestPorts for the two HITL pause points +// Define executors and RequestPorts for the three HITL pause points CreateApprovalRequest createRequest = new(); RequestPort managerApproval = RequestPort.Create("ManagerApproval"); PrepareFinanceReview prepareFinanceReview = new(); -RequestPort financeApproval = RequestPort.Create("FinanceApproval"); +RequestPort budgetApproval = RequestPort.Create("BudgetApproval"); +RequestPort complianceApproval = RequestPort.Create("ComplianceApproval"); ExpenseReimburse reimburse = new(); -// Build the workflow: CreateApprovalRequest -> ManagerApproval -> PrepareFinanceReview -> FinanceApproval -> ExpenseReimburse +// Build the workflow: CreateApprovalRequest -> ManagerApproval -> PrepareFinanceReview -> [BudgetApproval AND ComplianceApproval] -> ExpenseReimburse Workflow expenseApproval = new WorkflowBuilder(createRequest) .WithName("ExpenseReimbursement") - .WithDescription("Expense reimbursement with manager and finance approval") + .WithDescription("Expense reimbursement with manager and parallel finance approvals") .AddEdge(createRequest, managerApproval) .AddEdge(managerApproval, prepareFinanceReview) - .AddEdge(prepareFinanceReview, financeApproval) - .AddEdge(financeApproval, reimburse) + .AddFanOutEdge(prepareFinanceReview, [budgetApproval, complianceApproval]) + .AddFanInEdge([budgetApproval, complianceApproval], reimburse) .Build(); using IHost app = FunctionsApplication diff --git a/dotnet/samples/Durable/Workflow/AzureFunctions/03_WorkflowHITL/README.md b/dotnet/samples/Durable/Workflow/AzureFunctions/03_WorkflowHITL/README.md index 3a7ca3a0b9..c7634a09d6 100644 --- a/dotnet/samples/Durable/Workflow/AzureFunctions/03_WorkflowHITL/README.md +++ b/dotnet/samples/Durable/Workflow/AzureFunctions/03_WorkflowHITL/README.md @@ -1,17 +1,30 @@ # Human-in-the-Loop (HITL) Workflow — Azure Functions -This sample demonstrates a durable workflow with Human-in-the-Loop support hosted in Azure Functions. The workflow pauses at two sequential `RequestPort` nodes and waits for external approval responses sent via HTTP endpoints. This simulates an expense that requires both manager and finance approval. +This sample demonstrates a durable workflow with Human-in-the-Loop support hosted in Azure Functions. The workflow pauses at three `RequestPort` nodes — one sequential manager approval, then two parallel finance approvals (budget and compliance) via fan-out/fan-in. Approval responses are sent via HTTP endpoints. ## Key Concepts Demonstrated -- Using multiple `RequestPort` nodes for sequential human-in-the-loop interactions in a durable workflow +- Using multiple `RequestPort` nodes for sequential and parallel human-in-the-loop interactions in a durable workflow +- Fan-out/fan-in pattern for parallel approval steps - Auto-generated HTTP endpoints for running workflows, checking status, and sending HITL responses - Pausing orchestrations via `WaitForExternalEvent` and resuming via `RaiseEventAsync` - Viewing inputs the workflow is waiting for via the status endpoint ## Workflow -`CreateApprovalRequest` → `ManagerApproval` (HITL pause) → `PrepareFinanceReview` → `FinanceApproval` (HITL pause) → `ExpenseReimburse` +This sample implements the following workflow: + +``` +┌──────────────────────┐ ┌────────────────┐ ┌─────────────────────┐ ┌────────────────────┐ +│ CreateApprovalRequest│──►│ManagerApproval │──►│PrepareFinanceReview │──┬►│ BudgetApproval │──┐ +└──────────────────────┘ │ (RequestPort) │ └─────────────────────┘ │ │ (RequestPort) │ │ + └────────────────┘ │ └────────────────────┘ │ ┌─────────────────┐ + │ ├─►│ExpenseReimburse │ + │ ┌────────────────────┐ │ └─────────────────┘ + └►│ComplianceApproval │──┘ + │ (RequestPort) │ + └────────────────────┘ +``` ## HTTP Endpoints @@ -105,7 +118,7 @@ curl -X POST http://localhost:7071/api/workflows/ExpenseReimbursement/respond/{r ### Step 4: Check Workflow Status Again -The workflow now pauses at the `FinanceApproval` RequestPort: +The workflow now pauses at both the `BudgetApproval` and `ComplianceApproval` RequestPorts in parallel: ```bash curl http://localhost:7071/api/workflows/ExpenseReimbursement/status/{runId} @@ -116,31 +129,49 @@ curl http://localhost:7071/api/workflows/ExpenseReimbursement/status/{runId} "runId": "{runId}", "status": "Running", "waitingForInput": [ - { "eventName": "FinanceApproval", "input": { "ExpenseId": "EXP-2025-001", "Amount": 1500.00, "EmployeeName": "Jerry" } } + { "eventName": "BudgetApproval", "input": { "ExpenseId": "EXP-2025-001", "Amount": 1500.00, "EmployeeName": "Jerry" } }, + { "eventName": "ComplianceApproval", "input": { "ExpenseId": "EXP-2025-001", "Amount": 1500.00, "EmployeeName": "Jerry" } } ] } ``` -### Step 5: Send Finance Approval Response +### Step 5a: Send Budget Approval Response + +```bash +curl -X POST http://localhost:7071/api/workflows/ExpenseReimbursement/respond/{runId} \ + -H "Content-Type: application/json" \ + -d '{"eventName": "BudgetApproval", "response": {"Approved": true, "Comments": "Budget approved."}}' +``` + +```json +{ + "message": "Response sent to workflow.", + "runId": "{runId}", + "eventName": "BudgetApproval", + "validated": true +} +``` + +### Step 5b: Send Compliance Approval Response ```bash curl -X POST http://localhost:7071/api/workflows/ExpenseReimbursement/respond/{runId} \ -H "Content-Type: application/json" \ - -d '{"eventName": "FinanceApproval", "response": {"Approved": true, "Comments": "Finance approved."}}' + -d '{"eventName": "ComplianceApproval", "response": {"Approved": true, "Comments": "Compliance approved."}}' ``` ```json { "message": "Response sent to workflow.", "runId": "{runId}", - "eventName": "FinanceApproval", + "eventName": "ComplianceApproval", "validated": true } ``` ### Step 6: Check Final Status -After both approvals, the workflow completes and the expense is reimbursed: +After all approvals, the workflow completes and the expense is reimbursed: ```bash curl http://localhost:7071/api/workflows/ExpenseReimbursement/status/{runId} @@ -161,5 +192,5 @@ After running a workflow, you can navigate to the Durable Task Scheduler (DTS) d If you are using the DTS emulator, the dashboard is available at `http://localhost:8082`. 1. Open the dashboard and look for the orchestration instance matching the `runId` returned in Step 1 (e.g., `abc123def456` or your custom ID like `expense-001`). -2. Click into the instance to see the execution timeline, which shows each executor activity and the two `WaitForExternalEvent` pauses where the workflow waited for human input. -3. Expand individual activity steps to inspect inputs and outputs — for example, the `ManagerApproval` and `FinanceApproval` external events will show the approval request sent and the response received. +2. Click into the instance to see the execution timeline, which shows each executor activity and the `WaitForExternalEvent` pauses where the workflow waited for human input — including the two parallel finance approvals. +3. Expand individual activity steps to inspect inputs and outputs — for example, the `ManagerApproval`, `BudgetApproval`, and `ComplianceApproval` external events will show the approval request sent and the response received. diff --git a/dotnet/samples/Durable/Workflow/AzureFunctions/03_WorkflowHITL/demo.http b/dotnet/samples/Durable/Workflow/AzureFunctions/03_WorkflowHITL/demo.http index bf5d637a3a..5e2993ac1c 100644 --- a/dotnet/samples/Durable/Workflow/AzureFunctions/03_WorkflowHITL/demo.http +++ b/dotnet/samples/Durable/Workflow/AzureFunctions/03_WorkflowHITL/demo.http @@ -28,20 +28,26 @@ Content-Type: application/json {"eventName": "ManagerApproval", "response": {"Approved": false, "Comments": "Insufficient documentation. Please resubmit."}} -### Step 4: Check workflow status after manager approval (now waiting for finance approval) +### Step 4: Check workflow status after manager approval (now waiting for parallel finance approvals) GET {{authority}}/api/workflows/ExpenseReimbursement/status/{runId} -### Step 5: Send finance approval (replace {runId} with actual run ID from Step 1) +### Step 5a: Send budget approval (replace {runId} with actual run ID from Step 1) POST {{authority}}/api/workflows/ExpenseReimbursement/respond/{runId} Content-Type: application/json -{"eventName": "FinanceApproval", "response": {"Approved": true, "Comments": "Finance approved."}} +{"eventName": "BudgetApproval", "response": {"Approved": true, "Comments": "Budget approved."}} -### Step 5 (alternative): Deny the expense at finance level +### Step 5b: Send compliance approval (replace {runId} with actual run ID from Step 1) POST {{authority}}/api/workflows/ExpenseReimbursement/respond/{runId} Content-Type: application/json -{"eventName": "FinanceApproval", "response": {"Approved": false, "Comments": "Budget exceeded for this quarter."}} +{"eventName": "ComplianceApproval", "response": {"Approved": true, "Comments": "Compliance approved."}} -### Step 6: Check final workflow status after both approvals +### Step 5b (alternative): Deny the expense at compliance level +POST {{authority}}/api/workflows/ExpenseReimbursement/respond/{runId} +Content-Type: application/json + +{"eventName": "ComplianceApproval", "response": {"Approved": false, "Comments": "Compliance requirements not met."}} + +### Step 6: Check final workflow status after all approvals GET {{authority}}/api/workflows/ExpenseReimbursement/status/{runId} diff --git a/dotnet/samples/Durable/Workflow/ConsoleApps/08_WorkflowHITL/Executors.cs b/dotnet/samples/Durable/Workflow/ConsoleApps/08_WorkflowHITL/Executors.cs index a16111d1ab..db1a873260 100644 --- a/dotnet/samples/Durable/Workflow/ConsoleApps/08_WorkflowHITL/Executors.cs +++ b/dotnet/samples/Durable/Workflow/ConsoleApps/08_WorkflowHITL/Executors.cs @@ -57,19 +57,21 @@ public override ValueTask HandleAsync( } /// -/// Processes the expense reimbursement based on the final approval response. +/// Processes the expense reimbursement based on the parallel approval responses from budget and compliance. /// -internal sealed class ExpenseReimburse() : Executor("Reimburse") +internal sealed class ExpenseReimburse() : Executor("Reimburse") { /// public override async ValueTask HandleAsync( - ApprovalResponse message, + ApprovalResponse[] message, IWorkflowContext context, CancellationToken cancellationToken = default) { - if (!message.Approved) + // Check that all parallel approvals passed + ApprovalResponse? denied = Array.Find(message, r => !r.Approved); + if (denied is not null) { - return $"Expense reimbursement denied by finance. Comments: {message.Comments}"; + return $"Expense reimbursement denied. Comments: {denied.Comments}"; } // Simulate payment processing diff --git a/dotnet/samples/Durable/Workflow/ConsoleApps/08_WorkflowHITL/Program.cs b/dotnet/samples/Durable/Workflow/ConsoleApps/08_WorkflowHITL/Program.cs index 7ac7b7aa26..3b73b2d9b0 100644 --- a/dotnet/samples/Durable/Workflow/ConsoleApps/08_WorkflowHITL/Program.cs +++ b/dotnet/samples/Durable/Workflow/ConsoleApps/08_WorkflowHITL/Program.cs @@ -1,10 +1,19 @@ // Copyright (c) Microsoft. All rights reserved. // This sample demonstrates a Human-in-the-Loop (HITL) workflow using Durable Tasks. -// Workflow: CreateApprovalRequest -> ManagerApproval -> PrepareFinanceReview -> FinanceApproval -> ExpenseReimburse // -// The workflow pauses at two sequential RequestPorts, emitting a DurableWorkflowWaitingForInputEvent at each. -// This simulates an expense that requires both manager and finance approval. +// ┌──────────────────────┐ ┌────────────────┐ ┌─────────────────────┐ ┌────────────────────┐ +// │ CreateApprovalRequest│──►│ManagerApproval │──►│PrepareFinanceReview │──┬►│ BudgetApproval │──┐ +// └──────────────────────┘ │ (RequestPort) │ └─────────────────────┘ │ │ (RequestPort) │ │ +// └────────────────┘ │ └────────────────────┘ │ ┌─────────────────┐ +// │ ├─►│ExpenseReimburse │ +// │ ┌────────────────────┐ │ └─────────────────┘ +// └►│ComplianceApproval │──┘ +// │ (RequestPort) │ +// └────────────────────┘ +// +// The workflow pauses at three RequestPorts — one for the manager, then two in parallel for finance. +// After manager approval, BudgetApproval and ComplianceApproval run concurrently via fan-out/fan-in. using Microsoft.Agents.AI.DurableTask; using Microsoft.Agents.AI.DurableTask.Workflows; @@ -19,21 +28,22 @@ string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING") ?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None"; -// Define executors and RequestPorts for the two HITL pause points +// Define executors and RequestPorts for the three HITL pause points CreateApprovalRequest createRequest = new(); RequestPort managerApproval = RequestPort.Create("ManagerApproval"); PrepareFinanceReview prepareFinanceReview = new(); -RequestPort financeApproval = RequestPort.Create("FinanceApproval"); +RequestPort budgetApproval = RequestPort.Create("BudgetApproval"); +RequestPort complianceApproval = RequestPort.Create("ComplianceApproval"); ExpenseReimburse reimburse = new(); -// Build the workflow: CreateApprovalRequest -> ManagerApproval -> PrepareFinanceReview -> FinanceApproval -> ExpenseReimburse +// Build the workflow: CreateApprovalRequest -> ManagerApproval -> PrepareFinanceReview -> [BudgetApproval AND ComplianceApproval] -> ExpenseReimburse Workflow expenseApproval = new WorkflowBuilder(createRequest) .WithName("ExpenseReimbursement") - .WithDescription("Expense reimbursement with manager and finance approval") + .WithDescription("Expense reimbursement with manager and parallel finance approvals") .AddEdge(createRequest, managerApproval) .AddEdge(managerApproval, prepareFinanceReview) - .AddEdge(prepareFinanceReview, financeApproval) - .AddEdge(financeApproval, reimburse) + .AddFanOutEdge(prepareFinanceReview, [budgetApproval, complianceApproval]) + .AddFanInEdge([budgetApproval, complianceApproval], reimburse) .Build(); IHost host = Host.CreateDefaultBuilder(args) diff --git a/dotnet/samples/Durable/Workflow/ConsoleApps/08_WorkflowHITL/README.md b/dotnet/samples/Durable/Workflow/ConsoleApps/08_WorkflowHITL/README.md index 4a8e717c3e..01a62da53d 100644 --- a/dotnet/samples/Durable/Workflow/ConsoleApps/08_WorkflowHITL/README.md +++ b/dotnet/samples/Durable/Workflow/ConsoleApps/08_WorkflowHITL/README.md @@ -1,11 +1,11 @@ # Workflow Human-in-the-Loop (HITL) Sample -This sample demonstrates a **Human-in-the-Loop** pattern in durable workflows using `RequestPort`. The workflow pauses execution at two sequential approval points — manager and finance — and resumes when each response is provided. +This sample demonstrates a **Human-in-the-Loop** pattern in durable workflows using `RequestPort`. The workflow pauses execution at a manager approval point, then fans out to two parallel finance approval points — budget and compliance — before resuming. ## Key Concepts Demonstrated - Using `RequestPort` to define external input points in a workflow -- Multiple sequential HITL pause points in a single workflow +- Sequential and parallel HITL pause points in a single workflow using fan-out/fan-in - Streaming workflow events with `IStreamingWorkflowRun` - Handling `DurableWorkflowWaitingForInputEvent` to detect HITL pauses - Using `SendResponseAsync` to provide responses and resume the workflow @@ -13,8 +13,18 @@ This sample demonstrates a **Human-in-the-Loop** pattern in durable workflows us ## Workflow +This sample implements the following workflow: + ``` -CreateApprovalRequest -> ManagerApproval (RequestPort) -> PrepareFinanceReview -> FinanceApproval (RequestPort) -> ExpenseReimburse +┌──────────────────────┐ ┌────────────────┐ ┌─────────────────────┐ ┌────────────────────┐ +│ CreateApprovalRequest│──►│ManagerApproval │──►│PrepareFinanceReview │──┬►│ BudgetApproval │──┐ +└──────────────────────┘ │ (RequestPort) │ └─────────────────────┘ │ │ (RequestPort) │ │ + └────────────────┘ │ └────────────────────┘ │ ┌─────────────────┐ + │ ├─►│ExpenseReimburse │ + │ ┌────────────────────┐ │ └─────────────────┘ + └►│ComplianceApproval │──┘ + │ (RequestPort) │ + └────────────────────┘ ``` | Step | Description | @@ -22,8 +32,9 @@ CreateApprovalRequest -> ManagerApproval (RequestPort) -> PrepareFinanceReview - | CreateApprovalRequest | Retrieves expense details and creates an approval request | | ManagerApproval (RequestPort) | **PAUSES** the workflow and waits for manager approval | | PrepareFinanceReview | Prepares the request for finance review after manager approval | -| FinanceApproval (RequestPort) | **PAUSES** the workflow and waits for finance approval | -| ExpenseReimburse | Processes the reimbursement based on the final approval | +| BudgetApproval (RequestPort) | **PAUSES** the workflow and waits for budget approval (parallel) | +| ComplianceApproval (RequestPort) | **PAUSES** the workflow and waits for compliance approval (parallel) | +| ExpenseReimburse | Processes the reimbursement after all approvals pass | ## How It Works @@ -71,7 +82,12 @@ Workflow paused at RequestPort: ManagerApproval Approval for: Jerry, Amount: $1,500.00 Response sent: Approved=True -Workflow paused at RequestPort: FinanceApproval +Workflow paused at RequestPort: BudgetApproval + Input: {"expenseId":"EXP-2025-001","amount":1500.00,"employeeName":"Jerry"} + Approval for: Jerry, Amount: $1,500.00 + Response sent: Approved=True + +Workflow paused at RequestPort: ComplianceApproval Input: {"expenseId":"EXP-2025-001","amount":1500.00,"employeeName":"Jerry"} Approval for: Jerry, Amount: $1,500.00 Response sent: Approved=True @@ -86,5 +102,5 @@ After running the sample, you can navigate to the Durable Task Scheduler (DTS) d If you are using the DTS emulator, the dashboard is available at `http://localhost:8082`. 1. Open the dashboard and look for the orchestration instance matching the instance ID logged in the console output (e.g., `abc123...`). -2. Click into the instance to see the execution timeline, which shows each executor activity and the two `WaitForExternalEvent` pauses where the workflow waited for human input. -3. Expand individual activity steps to inspect inputs and outputs — for example, the `ManagerApproval` and `FinanceApproval` external events will show the approval request sent and the response received. +2. Click into the instance to see the execution timeline, which shows each executor activity and the `WaitForExternalEvent` pauses where the workflow waited for human input — including the two parallel finance approvals. +3. Expand individual activity steps to inspect inputs and outputs — for example, the `ManagerApproval`, `BudgetApproval`, and `ComplianceApproval` external events will show the approval request sent and the response received. diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableActivityExecutor.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableActivityExecutor.cs index 526a0f00d4..fa9f18630b 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableActivityExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableActivityExecutor.cs @@ -118,6 +118,26 @@ private static object DeserializeInput(string input, Type targetType) return input; } + // Fan-in aggregation serializes results as a JSON array of strings (e.g., ["{...}", "{...}"]). + // When the target type is a non-string array, deserialize each element individually. + if (targetType.IsArray && targetType != typeof(string[])) + { + Type elementType = targetType.GetElementType()!; + string[]? stringArray = JsonSerializer.Deserialize(input, DurableSerialization.Options); + if (stringArray is not null) + { + Array result = Array.CreateInstance(elementType, stringArray.Length); + for (int i = 0; i < stringArray.Length; i++) + { + object element = JsonSerializer.Deserialize(stringArray[i], elementType, DurableSerialization.Options) + ?? throw new InvalidOperationException($"Failed to deserialize element {i} to type '{elementType.Name}'."); + result.SetValue(element, i); + } + + return result; + } + } + return JsonSerializer.Deserialize(input, targetType, DurableSerialization.Options) ?? throw new InvalidOperationException($"Failed to deserialize input to type '{targetType.Name}'."); } @@ -141,10 +161,13 @@ private static Type ResolveInputType(string? inputTypeName, ISet supported Type? loadedType = Type.GetType(inputTypeName); - // Fall back if type is string but executor doesn't support string - if (loadedType == typeof(string) && !supportedTypes.Contains(typeof(string))) + // Fall back if type is string or string[] but executor doesn't support it + if (loadedType is not null && !supportedTypes.Contains(loadedType)) { - return supportedTypes.FirstOrDefault() ?? typeof(string); + if (loadedType == typeof(string) || loadedType == typeof(string[])) + { + return supportedTypes.FirstOrDefault() ?? typeof(string); + } } return loadedType ?? supportedTypes.FirstOrDefault() ?? typeof(string); diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableStreamingWorkflowRun.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableStreamingWorkflowRun.cs index 9cfcd24897..6cacf871e0 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableStreamingWorkflowRun.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableStreamingWorkflowRun.cs @@ -1,8 +1,7 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System.Diagnostics; using System.Diagnostics.CodeAnalysis; -using System.Linq; using System.Runtime.CompilerServices; using System.Text.Json; using Microsoft.Agents.AI.Workflows; diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/WorkflowConsoleAppSamplesValidation.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/WorkflowConsoleAppSamplesValidation.cs index 70bdfa4562..83158803dc 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/WorkflowConsoleAppSamplesValidation.cs +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/WorkflowConsoleAppSamplesValidation.cs @@ -463,8 +463,10 @@ await this.RunSampleTestAsync(samplePath, (process, logs) => bool foundManagerApprovalPause = false; bool foundManagerApprovalInput = false; bool foundManagerResponseSent = false; - bool foundFinanceApprovalPause = false; - bool foundFinanceResponseSent = false; + bool foundBudgetApprovalPause = false; + bool foundBudgetResponseSent = false; + bool foundComplianceApprovalPause = false; + bool foundComplianceResponseSent = false; bool foundWorkflowCompleted = false; string? line; @@ -473,9 +475,11 @@ await this.RunSampleTestAsync(samplePath, (process, logs) => foundStarted |= line.Contains("Starting expense reimbursement workflow", StringComparison.Ordinal); foundManagerApprovalPause |= line.Contains("Workflow paused at RequestPort: ManagerApproval", StringComparison.Ordinal); foundManagerApprovalInput |= line.Contains("Approval for: Jerry", StringComparison.Ordinal); - foundManagerResponseSent |= line.Contains("Response sent: Approved=True", StringComparison.Ordinal) && foundManagerApprovalPause && !foundFinanceApprovalPause; - foundFinanceApprovalPause |= line.Contains("Workflow paused at RequestPort: FinanceApproval", StringComparison.Ordinal); - foundFinanceResponseSent |= line.Contains("Response sent: Approved=True", StringComparison.Ordinal) && foundFinanceApprovalPause; + foundManagerResponseSent |= line.Contains("Response sent: Approved=True", StringComparison.Ordinal) && foundManagerApprovalPause && !foundBudgetApprovalPause && !foundComplianceApprovalPause; + foundBudgetApprovalPause |= line.Contains("Workflow paused at RequestPort: BudgetApproval", StringComparison.Ordinal); + foundBudgetResponseSent |= line.Contains("Response sent: Approved=True", StringComparison.Ordinal) && foundBudgetApprovalPause; + foundComplianceApprovalPause |= line.Contains("Workflow paused at RequestPort: ComplianceApproval", StringComparison.Ordinal); + foundComplianceResponseSent |= line.Contains("Response sent: Approved=True", StringComparison.Ordinal) && foundComplianceApprovalPause; if (line.Contains("Workflow completed: Expense reimbursed at", StringComparison.Ordinal)) { @@ -490,8 +494,10 @@ await this.RunSampleTestAsync(samplePath, (process, logs) => Assert.True(foundManagerApprovalPause, "Manager approval pause not found."); Assert.True(foundManagerApprovalInput, "Manager approval input (Jerry) not found."); Assert.True(foundManagerResponseSent, "Manager approval response not sent."); - Assert.True(foundFinanceApprovalPause, "Finance approval pause not found."); - Assert.True(foundFinanceResponseSent, "Finance approval response not sent."); + Assert.True(foundBudgetApprovalPause, "Budget approval pause not found."); + Assert.True(foundBudgetResponseSent, "Budget approval response not sent."); + Assert.True(foundComplianceApprovalPause, "Compliance approval pause not found."); + Assert.True(foundComplianceResponseSent, "Compliance approval response not sent."); Assert.True(foundWorkflowCompleted, "Workflow did not complete successfully."); return Task.CompletedTask; diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/WorkflowSamplesValidation.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/WorkflowSamplesValidation.cs index 24b0f0c714..34e8f9ef61 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/WorkflowSamplesValidation.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/WorkflowSamplesValidation.cs @@ -165,7 +165,42 @@ await this.WaitForConditionAsync( Assert.Contains("Response sent to workflow", respondResponseText); this._outputHelper.WriteLine($"Respond response: {respondResponseText}"); - // Step 4: Wait for the workflow to complete + // Step 4: Wait for the workflow to pause at the parallel BudgetApproval and ComplianceApproval RequestPorts + await this.WaitForConditionAsync( + condition: () => + { + lock (logs) + { + bool exists = logs.Any(log => log.Message.Contains("Workflow waiting for external input at RequestPort 'BudgetApproval'")); + return Task.FromResult(exists); + } + }, + message: "Workflow paused at BudgetApproval RequestPort", + timeout: s_orchestrationTimeout); + + // Step 5a: Send budget approval response + this._outputHelper.WriteLine("Sending BudgetApproval response..."); + + using HttpContent budgetContent = new StringContent( + """{"eventName": "BudgetApproval", "response": {"Approved": true, "Comments": "Budget approved by test."}}""", + Encoding.UTF8, "application/json"); + using HttpResponseMessage budgetResponse = await s_sharedHttpClient.PostAsync(respondUri, budgetContent); + + Assert.True(budgetResponse.IsSuccessStatusCode, $"BudgetApproval request failed with status: {budgetResponse.StatusCode}"); + this._outputHelper.WriteLine($"BudgetApproval response: {await budgetResponse.Content.ReadAsStringAsync()}"); + + // Step 5b: Send compliance approval response + this._outputHelper.WriteLine("Sending ComplianceApproval response..."); + + using HttpContent complianceContent = new StringContent( + """{"eventName": "ComplianceApproval", "response": {"Approved": true, "Comments": "Compliance approved by test."}}""", + Encoding.UTF8, "application/json"); + using HttpResponseMessage complianceResponse = await s_sharedHttpClient.PostAsync(respondUri, complianceContent); + + Assert.True(complianceResponse.IsSuccessStatusCode, $"ComplianceApproval request failed with status: {complianceResponse.StatusCode}"); + this._outputHelper.WriteLine($"ComplianceApproval response: {await complianceResponse.Content.ReadAsStringAsync()}"); + + // Step 6: Wait for the workflow to complete await this.WaitForConditionAsync( condition: () => { @@ -183,6 +218,10 @@ await this.WaitForConditionAsync( { Assert.True(logs.Any(log => log.Message.Contains("Received external event for RequestPort 'ManagerApproval'")), "ManagerApproval external event receipt not found in logs."); + Assert.True(logs.Any(log => log.Message.Contains("Received external event for RequestPort 'BudgetApproval'")), + "BudgetApproval external event receipt not found in logs."); + Assert.True(logs.Any(log => log.Message.Contains("Received external event for RequestPort 'ComplianceApproval'")), + "ComplianceApproval external event receipt not found in logs."); } }); } From 9274da4e29dd4325f06167c9d1e23618eb90aa13 Mon Sep 17 00:00:00 2001 From: Shyju Krishnankutty Date: Fri, 27 Feb 2026 22:40:57 -0800 Subject: [PATCH 10/12] Unit tests --- .../Workflows/DurableActivityExecutor.cs | 4 +- .../Workflows/DurableActivityExecutorTests.cs | 235 ++++++++++++++++++ 2 files changed, 237 insertions(+), 2 deletions(-) create mode 100644 dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableActivityExecutorTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableActivityExecutor.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableActivityExecutor.cs index fa9f18630b..e3ad1980e7 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableActivityExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableActivityExecutor.cs @@ -111,7 +111,7 @@ private static string SerializeResult(object? result) } } - private static object DeserializeInput(string input, Type targetType) + internal static object DeserializeInput(string input, Type targetType) { if (targetType == typeof(string)) { @@ -142,7 +142,7 @@ private static object DeserializeInput(string input, Type targetType) ?? throw new InvalidOperationException($"Failed to deserialize input to type '{targetType.Name}'."); } - private static Type ResolveInputType(string? inputTypeName, ISet supportedTypes) + internal static Type ResolveInputType(string? inputTypeName, ISet supportedTypes) { if (string.IsNullOrEmpty(inputTypeName)) { diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableActivityExecutorTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableActivityExecutorTests.cs new file mode 100644 index 0000000000..6b817fe84b --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableActivityExecutorTests.cs @@ -0,0 +1,235 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using Microsoft.Agents.AI.DurableTask.Workflows; + +namespace Microsoft.Agents.AI.DurableTask.UnitTests.Workflows; + +public sealed class DurableActivityExecutorTests +{ + private static readonly JsonSerializerOptions s_camelCaseOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true + }; + + #region DeserializeInput + + [Fact] + public void DeserializeInput_StringType_ReturnsInputAsIs() + { + // Arrange + const string Input = "hello world"; + + // Act + object result = DurableActivityExecutor.DeserializeInput(Input, typeof(string)); + + // Assert + Assert.Equal("hello world", result); + } + + [Fact] + public void DeserializeInput_SimpleObject_DeserializesCorrectly() + { + // Arrange + string input = JsonSerializer.Serialize(new TestRecord("EXP-001", 100.50m), s_camelCaseOptions); + + // Act + object result = DurableActivityExecutor.DeserializeInput(input, typeof(TestRecord)); + + // Assert + TestRecord record = Assert.IsType(result); + Assert.Equal("EXP-001", record.Id); + Assert.Equal(100.50m, record.Amount); + } + + [Fact] + public void DeserializeInput_StringArray_DeserializesDirectly() + { + // Arrange + string input = JsonSerializer.Serialize((string[])["a", "b", "c"]); + + // Act + object result = DurableActivityExecutor.DeserializeInput(input, typeof(string[])); + + // Assert + string[] array = Assert.IsType(result); + Assert.Equal(["a", "b", "c"], array); + } + + [Fact] + public void DeserializeInput_TypedArrayFromFanIn_DeserializesEachElement() + { + // Arrange — fan-in produces a JSON array of serialized strings + TestRecord r1 = new("EXP-001", 100m); + TestRecord r2 = new("EXP-002", 200m); + string[] serializedElements = + [ + JsonSerializer.Serialize(r1, s_camelCaseOptions), + JsonSerializer.Serialize(r2, s_camelCaseOptions) + ]; + string input = JsonSerializer.Serialize(serializedElements); + + // Act + object result = DurableActivityExecutor.DeserializeInput(input, typeof(TestRecord[])); + + // Assert + TestRecord[] records = Assert.IsType(result); + Assert.Equal(2, records.Length); + Assert.Equal("EXP-001", records[0].Id); + Assert.Equal(100m, records[0].Amount); + Assert.Equal("EXP-002", records[1].Id); + Assert.Equal(200m, records[1].Amount); + } + + [Fact] + public void DeserializeInput_TypedArrayWithSingleElement_DeserializesCorrectly() + { + // Arrange + TestRecord r1 = new("EXP-001", 50m); + string[] serializedElements = [JsonSerializer.Serialize(r1, s_camelCaseOptions)]; + string input = JsonSerializer.Serialize(serializedElements); + + // Act + object result = DurableActivityExecutor.DeserializeInput(input, typeof(TestRecord[])); + + // Assert + TestRecord[] records = Assert.IsType(result); + Assert.Single(records); + Assert.Equal("EXP-001", records[0].Id); + } + + [Fact] + public void DeserializeInput_TypedArrayWithNullElement_ThrowsInvalidOperationException() + { + // Arrange — one element is "null" + string input = JsonSerializer.Serialize((string[])["null"]); + + // Act & Assert + Assert.Throws( + () => DurableActivityExecutor.DeserializeInput(input, typeof(TestRecord[]))); + } + + [Fact] + public void DeserializeInput_InvalidJson_ThrowsJsonException() + { + // Arrange + const string Input = "not valid json"; + + // Act & Assert + Assert.ThrowsAny( + () => DurableActivityExecutor.DeserializeInput(Input, typeof(TestRecord))); + } + + #endregion + + #region ResolveInputType + + [Fact] + public void ResolveInputType_NullTypeName_ReturnsFirstSupportedType() + { + // Arrange + HashSet supportedTypes = [typeof(TestRecord), typeof(string)]; + + // Act + Type result = DurableActivityExecutor.ResolveInputType(null, supportedTypes); + + // Assert + Assert.Equal(typeof(TestRecord), result); + } + + [Fact] + public void ResolveInputType_EmptyTypeName_ReturnsFirstSupportedType() + { + // Arrange + HashSet supportedTypes = [typeof(TestRecord)]; + + // Act + Type result = DurableActivityExecutor.ResolveInputType(string.Empty, supportedTypes); + + // Assert + Assert.Equal(typeof(TestRecord), result); + } + + [Fact] + public void ResolveInputType_EmptySupportedTypes_DefaultsToString() + { + // Arrange + HashSet supportedTypes = []; + + // Act + Type result = DurableActivityExecutor.ResolveInputType(null, supportedTypes); + + // Assert + Assert.Equal(typeof(string), result); + } + + [Fact] + public void ResolveInputType_MatchesByFullName() + { + // Arrange + HashSet supportedTypes = [typeof(TestRecord)]; + + // Act + Type result = DurableActivityExecutor.ResolveInputType(typeof(TestRecord).FullName, supportedTypes); + + // Assert + Assert.Equal(typeof(TestRecord), result); + } + + [Fact] + public void ResolveInputType_MatchesByName() + { + // Arrange + HashSet supportedTypes = [typeof(TestRecord)]; + + // Act + Type result = DurableActivityExecutor.ResolveInputType("TestRecord", supportedTypes); + + // Assert + Assert.Equal(typeof(TestRecord), result); + } + + [Fact] + public void ResolveInputType_StringArrayFallsBackToSupportedType() + { + // Arrange — fan-in sends string[] but executor expects TestRecord[] + HashSet supportedTypes = [typeof(TestRecord[])]; + + // Act + Type result = DurableActivityExecutor.ResolveInputType(typeof(string[]).FullName, supportedTypes); + + // Assert + Assert.Equal(typeof(TestRecord[]), result); + } + + [Fact] + public void ResolveInputType_StringFallsBackToSupportedType() + { + // Arrange — executor doesn't support string + HashSet supportedTypes = [typeof(TestRecord)]; + + // Act + Type result = DurableActivityExecutor.ResolveInputType(typeof(string).FullName, supportedTypes); + + // Assert + Assert.Equal(typeof(TestRecord), result); + } + + [Fact] + public void ResolveInputType_StringArrayRetainedWhenSupported() + { + // Arrange — executor explicitly supports string[] + HashSet supportedTypes = [typeof(string[])]; + + // Act + Type result = DurableActivityExecutor.ResolveInputType(typeof(string[]).FullName, supportedTypes); + + // Assert + Assert.Equal(typeof(string[]), result); + } + + #endregion + + private sealed record TestRecord(string Id, decimal Amount); +} From 38b807e7f332872703fc530d4125d92e1939a820 Mon Sep 17 00:00:00 2001 From: Shyju Krishnankutty Date: Mon, 2 Mar 2026 17:40:21 -0800 Subject: [PATCH 11/12] Introduce local variables to improve readability of Workflows.Workflows access patter --- .../ServiceCollectionExtensions.cs | 5 +++-- .../Workflows/DurableWorkflowsFunctionMetadataTransformer.cs | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/ServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/ServiceCollectionExtensions.cs index b1002d0851..ee9f0c77d6 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/ServiceCollectionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/ServiceCollectionExtensions.cs @@ -234,11 +234,12 @@ private static void RegisterTasksFromOptions(DurableTaskRegistry registry, Durab HashSet registeredActivities = []; HashSet registeredOrchestrations = []; - foreach (Workflow workflow in durableOptions.Workflows.Workflows.Values.ToList()) + DurableWorkflowOptions workflowOptions = durableOptions.Workflows; + foreach (Workflow workflow in workflowOptions.Workflows.Values.ToList()) { BuildWorkflowRegistrationRecursive( workflow, - durableOptions.Workflows, + workflowOptions, registrations, registeredActivities, registeredOrchestrations); diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/DurableWorkflowsFunctionMetadataTransformer.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/DurableWorkflowsFunctionMetadataTransformer.cs index cada02d387..c7ad9a5ebd 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/DurableWorkflowsFunctionMetadataTransformer.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/DurableWorkflowsFunctionMetadataTransformer.cs @@ -53,7 +53,8 @@ public void Transform(IList original) // Track registered function names to avoid duplicates when workflows share executors. HashSet registeredFunctions = []; - foreach (var workflow in this._options.Workflows.Workflows) + DurableWorkflowOptions workflowOptions = this._options.Workflows; + foreach (var workflow in workflowOptions.Workflows) { string httpFunctionName = $"{BuiltInFunctions.HttpPrefix}{workflow.Key}"; From eb10d0a5a7722cb0b62ec9a167f3ca56c87a9582 Mon Sep 17 00:00:00 2001 From: Shyju Krishnankutty Date: Tue, 3 Mar 2026 10:58:18 -0800 Subject: [PATCH 12/12] Use GitHub-style callouts and add PowerShell command variants in HITL sample README --- .../AzureFunctions/03_WorkflowHITL/README.md | 74 ++++++++++++++++++- 1 file changed, 72 insertions(+), 2 deletions(-) diff --git a/dotnet/samples/Durable/Workflow/AzureFunctions/03_WorkflowHITL/README.md b/dotnet/samples/Durable/Workflow/AzureFunctions/03_WorkflowHITL/README.md index c7634a09d6..27322b7b6a 100644 --- a/dotnet/samples/Durable/Workflow/AzureFunctions/03_WorkflowHITL/README.md +++ b/dotnet/samples/Durable/Workflow/AzureFunctions/03_WorkflowHITL/README.md @@ -70,23 +70,43 @@ The response will confirm the workflow orchestration has started: Workflow orchestration started for ExpenseReimbursement. Orchestration runId: abc123def456 ``` -> **Tip:** You can provide a custom run ID by appending a `runId` query parameter: +> [!TIP] +> You can provide a custom run ID by appending a `runId` query parameter: +> +> Bash (Linux/macOS/WSL): > > ```bash > curl -X POST "http://localhost:7071/api/workflows/ExpenseReimbursement/run?runId=expense-001" \ > -H "Content-Type: text/plain" -d "EXP-2025-001" > ``` > +> PowerShell: +> +> ```powershell +> Invoke-RestMethod -Method Post ` +> -Uri "http://localhost:7071/api/workflows/ExpenseReimbursement/run?runId=expense-001" ` +> -ContentType text/plain ` +> -Body "EXP-2025-001" +> ``` +> > If not provided, a unique run ID is auto-generated. ### Step 2: Check Workflow Status The workflow pauses at the `ManagerApproval` RequestPort. Query the status endpoint to see what input it is waiting for: +Bash (Linux/macOS/WSL): + ```bash curl http://localhost:7071/api/workflows/ExpenseReimbursement/status/{runId} ``` +PowerShell: + +```powershell +Invoke-RestMethod -Uri http://localhost:7071/api/workflows/ExpenseReimbursement/status/{runId} +``` + ```json { "runId": "{runId}", @@ -97,16 +117,28 @@ curl http://localhost:7071/api/workflows/ExpenseReimbursement/status/{runId} } ``` -> **Tip:** You can also verify this in the DTS dashboard at `http://localhost:8082`. Find the orchestration by its `runId` and you will see it is in a "Running" state, paused at a `WaitForExternalEvent` call for the `ManagerApproval` event. +> [!TIP] +> You can also verify this in the DTS dashboard at `http://localhost:8082`. Find the orchestration by its `runId` and you will see it is in a "Running" state, paused at a `WaitForExternalEvent` call for the `ManagerApproval` event. ### Step 3: Send Manager Approval Response +Bash (Linux/macOS/WSL): + ```bash curl -X POST http://localhost:7071/api/workflows/ExpenseReimbursement/respond/{runId} \ -H "Content-Type: application/json" \ -d '{"eventName": "ManagerApproval", "response": {"Approved": true, "Comments": "Approved by manager."}}' ``` +PowerShell: + +```powershell +Invoke-RestMethod -Method Post ` + -Uri http://localhost:7071/api/workflows/ExpenseReimbursement/respond/{runId} ` + -ContentType application/json ` + -Body '{"eventName": "ManagerApproval", "response": {"Approved": true, "Comments": "Approved by manager."}}' +``` + ```json { "message": "Response sent to workflow.", @@ -120,10 +152,18 @@ curl -X POST http://localhost:7071/api/workflows/ExpenseReimbursement/respond/{r The workflow now pauses at both the `BudgetApproval` and `ComplianceApproval` RequestPorts in parallel: +Bash (Linux/macOS/WSL): + ```bash curl http://localhost:7071/api/workflows/ExpenseReimbursement/status/{runId} ``` +PowerShell: + +```powershell +Invoke-RestMethod -Uri http://localhost:7071/api/workflows/ExpenseReimbursement/status/{runId} +``` + ```json { "runId": "{runId}", @@ -137,12 +177,23 @@ curl http://localhost:7071/api/workflows/ExpenseReimbursement/status/{runId} ### Step 5a: Send Budget Approval Response +Bash (Linux/macOS/WSL): + ```bash curl -X POST http://localhost:7071/api/workflows/ExpenseReimbursement/respond/{runId} \ -H "Content-Type: application/json" \ -d '{"eventName": "BudgetApproval", "response": {"Approved": true, "Comments": "Budget approved."}}' ``` +PowerShell: + +```powershell +Invoke-RestMethod -Method Post ` + -Uri http://localhost:7071/api/workflows/ExpenseReimbursement/respond/{runId} ` + -ContentType application/json ` + -Body '{"eventName": "BudgetApproval", "response": {"Approved": true, "Comments": "Budget approved."}}' +``` + ```json { "message": "Response sent to workflow.", @@ -154,12 +205,23 @@ curl -X POST http://localhost:7071/api/workflows/ExpenseReimbursement/respond/{r ### Step 5b: Send Compliance Approval Response +Bash (Linux/macOS/WSL): + ```bash curl -X POST http://localhost:7071/api/workflows/ExpenseReimbursement/respond/{runId} \ -H "Content-Type: application/json" \ -d '{"eventName": "ComplianceApproval", "response": {"Approved": true, "Comments": "Compliance approved."}}' ``` +PowerShell: + +```powershell +Invoke-RestMethod -Method Post ` + -Uri http://localhost:7071/api/workflows/ExpenseReimbursement/respond/{runId} ` + -ContentType application/json ` + -Body '{"eventName": "ComplianceApproval", "response": {"Approved": true, "Comments": "Compliance approved."}}' +``` + ```json { "message": "Response sent to workflow.", @@ -173,10 +235,18 @@ curl -X POST http://localhost:7071/api/workflows/ExpenseReimbursement/respond/{r After all approvals, the workflow completes and the expense is reimbursed: +Bash (Linux/macOS/WSL): + ```bash curl http://localhost:7071/api/workflows/ExpenseReimbursement/status/{runId} ``` +PowerShell: + +```powershell +Invoke-RestMethod -Uri http://localhost:7071/api/workflows/ExpenseReimbursement/status/{runId} +``` + ```json { "runId": "{runId}",