Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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,43 @@
// 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>Processes the expense reimbursement based on the approval decision.</summary>
internal sealed class ExpenseReimburse() : Executor<ApprovalResponse, string>("Reimburse")
{
public override async ValueTask<string> 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}";
}
}
Original file line number Diff line number Diff line change
@@ -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<ApprovalRequest, ApprovalResponse> managerApproval = RequestPort.Create<ApprovalRequest, ApprovalResponse>("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();
Original file line number Diff line number Diff line change
@@ -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
```
Comment thread
kshyju marked this conversation as resolved.

### 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.
Comment thread
kshyju marked this conversation as resolved.
Outdated
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>WorkflowHITL</AssemblyName>
<RootNamespace>WorkflowHITL</RootNamespace>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.DurableTask.Client.AzureManaged" />
<PackageReference Include="Microsoft.DurableTask.Worker.AzureManaged" />
<PackageReference Include="Microsoft.Extensions.Hosting" />
</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.DurableTask" />
<PackageReference Include="Microsoft.Agents.AI.Workflows" />
</ItemGroup>
-->
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.DurableTask\Microsoft.Agents.AI.DurableTask.csproj" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// Copyright (c) Microsoft. All rights reserved.

using Microsoft.Agents.AI.Workflows;

namespace WorkflowHITL;

/// <summary>
/// Represents an expense approval request.
/// </summary>
/// <param name="ExpenseId">The unique identifier of the expense.</param>
/// <param name="Amount">The amount of the expense.</param>
/// <param name="EmployeeName">The name of the employee submitting the expense.</param>
public record ApprovalRequest(string ExpenseId, decimal Amount, string EmployeeName);

/// <summary>
/// Represents the response to an approval request.
/// </summary>
/// <param name="Approved">Whether the expense was approved.</param>
/// <param name="Comments">Optional comments from the approver.</param>
public record ApprovalResponse(bool Approved, string? Comments);

/// <summary>
/// Retrieves expense details and creates an approval request.
/// </summary>
internal sealed class CreateApprovalRequest() : Executor<string, ApprovalRequest>("RetrieveRequest")
{
/// <inheritdoc/>
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")
{
/// <inheritdoc/>
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 final approval response.
/// </summary>
internal sealed class ExpenseReimburse() : Executor<ApprovalResponse, string>("Reimburse")
{
/// <inheritdoc/>
public override async ValueTask<string> 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}";
}
}
Loading