Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion dotnet/agent-framework-dotnet.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,12 @@
<Project Path="samples/Durable/Workflow/ConsoleApps/05_WorkflowEvents/05_WorkflowEvents.csproj" />
<Project Path="samples/Durable/Workflow/ConsoleApps/06_WorkflowSharedState/06_WorkflowSharedState.csproj" />
<Project Path="samples/Durable/Workflow/ConsoleApps/07_SubWorkflows/07_SubWorkflows.csproj" />
<Project Path="samples/Durable/Workflow/ConsoleApps/08_WorkflowHITL/08_WorkflowHITL.csproj" />
</Folder>
<Folder Name="/Samples/Durable/Workflows/AzureFunctions/">
<Project Path="samples/Durable/Workflow/AzureFunctions/01_SequentialWorkflow/01_SequentialWorkflow.csproj" />
<Project Path="samples/Durable/Workflow/AzureFunctions/02_ConcurrentWorkflow/02_ConcurrentWorkflow.csproj" />
<Project Path="samples/Durable/Workflow/AzureFunctions/03_WorkflowHITL/03_WorkflowHITL.csproj" />
</Folder>
<Folder Name="/Samples/GettingStarted/">
<File Path="samples/GettingStarted/README.md" />
Expand Down Expand Up @@ -475,4 +477,4 @@
<Project Path="tests/Microsoft.Agents.AI.Workflows.Generators.UnitTests/Microsoft.Agents.AI.Workflows.Generators.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Workflows.UnitTests/Microsoft.Agents.AI.Workflows.UnitTests.csproj" />
</Folder>
</Solution>
</Solution>
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
<AzureFunctionsVersion>v4</AzureFunctionsVersion>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<!-- The Functions build tools don't like namespaces that start with a number -->
<AssemblyName>WorkflowHITLFunctions</AssemblyName>
<RootNamespace>WorkflowHITLFunctions</RootNamespace>
</PropertyGroup>

<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>

<ItemGroup>
<None Include="local.settings.json" />
</ItemGroup>

<!-- Azure Functions packages -->
<ItemGroup>
<PackageReference Include="Microsoft.Azure.Functions.Worker" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask.AzureManaged" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Azure.Identity" />
</ItemGroup>

<!-- Local projects that should be switched to package references when using the sample outside of this MAF repo -->
<!--
<ItemGroup>
<PackageReference Include="Microsoft.Agents.AI.Hosting.AzureFunctions" />
</ItemGroup>
-->
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AzureFunctions\Microsoft.Agents.AI.Hosting.AzureFunctions.csproj" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// Copyright (c) Microsoft. All rights reserved.

using Microsoft.Agents.AI.Workflows;

namespace WorkflowHITLFunctions;

/// <summary>Expense approval request passed to the RequestPort.</summary>
public record ApprovalRequest(string ExpenseId, decimal Amount, string EmployeeName);

/// <summary>Approval response received from the RequestPort.</summary>
public record ApprovalResponse(bool Approved, string? Comments);

/// <summary>Looks up expense details and creates an approval request.</summary>
internal sealed class CreateApprovalRequest() : Executor<string, ApprovalRequest>("RetrieveRequest")
{
public override ValueTask<ApprovalRequest> HandleAsync(
string message,
IWorkflowContext context,
CancellationToken cancellationToken = default)
{
// In a real scenario, this would look up expense details from a database
return new ValueTask<ApprovalRequest>(new ApprovalRequest(message, 1500.00m, "Jerry"));
}
}

/// <summary>Prepares the approval request for finance review after manager approval.</summary>
internal sealed class PrepareFinanceReview() : Executor<ApprovalResponse, ApprovalRequest>("PrepareFinanceReview")
{
public override ValueTask<ApprovalRequest> 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<ApprovalRequest>(new ApprovalRequest("EXP-2025-001", 1500.00m, "Jerry"));
}
}

/// <summary>Processes the expense reimbursement based on the parallel approval responses.</summary>
internal sealed class ExpenseReimburse() : Executor<ApprovalResponse[], string>("Reimburse")
{
public override async ValueTask<string> HandleAsync(
ApprovalResponse[] message,
IWorkflowContext context,
CancellationToken cancellationToken = default)
{
// Check that all parallel approvals passed
ApprovalResponse? denied = Array.Find(message, r => !r.Approved);
if (denied is not null)
{
return $"Expense reimbursement denied. Comments: {denied.Comments}";
}

// Simulate payment processing
await Task.Delay(1000, cancellationToken);
return $"Expense reimbursed at {DateTime.UtcNow:O}";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// Copyright (c) Microsoft. All rights reserved.

// This sample demonstrates a Human-in-the-Loop (HITL) workflow hosted in Azure Functions.
//
// ┌──────────────────────┐ ┌────────────────┐ ┌─────────────────────┐ ┌────────────────────┐
// │ 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
// 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 RequestPorts for the three HITL pause points
CreateApprovalRequest createRequest = new();
RequestPort<ApprovalRequest, ApprovalResponse> managerApproval = RequestPort.Create<ApprovalRequest, ApprovalResponse>("ManagerApproval");
PrepareFinanceReview prepareFinanceReview = new();
RequestPort<ApprovalRequest, ApprovalResponse> budgetApproval = RequestPort.Create<ApprovalRequest, ApprovalResponse>("BudgetApproval");
RequestPort<ApprovalRequest, ApprovalResponse> complianceApproval = RequestPort.Create<ApprovalRequest, ApprovalResponse>("ComplianceApproval");
ExpenseReimburse reimburse = new();

// Build the workflow: CreateApprovalRequest -> ManagerApproval -> PrepareFinanceReview -> [BudgetApproval AND ComplianceApproval] -> ExpenseReimburse
Workflow expenseApproval = new WorkflowBuilder(createRequest)
.WithName("ExpenseReimbursement")
.WithDescription("Expense reimbursement with manager and parallel finance approvals")
.AddEdge(createRequest, managerApproval)
.AddEdge(managerApproval, prepareFinanceReview)
.AddFanOutEdge(prepareFinanceReview, [budgetApproval, complianceApproval])
.AddFanInEdge([budgetApproval, complianceApproval], reimburse)
.Build();

using IHost app = FunctionsApplication
.CreateBuilder(args)
.ConfigureFunctionsWebApplication()
.ConfigureDurableWorkflows(workflows => workflows.AddWorkflow(expenseApproval, exposeStatusEndpoint: true))
.Build();
app.Run();
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
# 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 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 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

This sample implements the following workflow:

```
┌──────────────────────┐ ┌────────────────┐ ┌─────────────────────┐ ┌────────────────────┐
│ CreateApprovalRequest│──►│ManagerApproval │──►│PrepareFinanceReview │──┬►│ BudgetApproval │──┐
└──────────────────────┘ │ (RequestPort) │ └─────────────────────┘ │ │ (RequestPort) │ │
└────────────────┘ │ └────────────────────┘ │ ┌─────────────────┐
│ ├─►│ExpenseReimburse │
│ ┌────────────────────┐ │ └─────────────────┘
└►│ComplianceApproval │──┘
│ (RequestPort) │
└────────────────────┘
```

## 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 inputs the workflow is waiting for |
| 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

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 \
-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. Query the status endpoint to see what input it is waiting for:

```bash
curl http://localhost:7071/api/workflows/ExpenseReimbursement/status/{runId}
```

```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/{runId} \
-H "Content-Type: application/json" \
-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 both the `BudgetApproval` and `ComplianceApproval` RequestPorts in parallel:

```bash
curl http://localhost:7071/api/workflows/ExpenseReimbursement/status/{runId}
```

```json
{
"runId": "{runId}",
"status": "Running",
"waitingForInput": [
{ "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 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": "ComplianceApproval", "response": {"Approved": true, "Comments": "Compliance approved."}}'
```

```json
{
"message": "Response sent to workflow.",
"runId": "{runId}",
"eventName": "ComplianceApproval",
"validated": true
}
```

### Step 6: Check Final Status

After all 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`.

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 `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.
Loading