// HumanInTheLoopDemo.cs
// Demonstrates Microsoft Agent Framework Workflows - Human-in-the-Loop (HITL):
// A workflow is split into two automated phases with a human decision gate in between.
// Phase 1 runs an automated audit; the human reviews the result and decides;
// Phase 2 processes the human's decision.
//
// Scenario: HR Expense Claim Approval
// 1. ExpenseAuditorExecutor checks a claim against company policy rules.
// 2. [HUMAN STEP] Finance manager reviews the audit report and approves or rejects.
// 3. ExpenseProcessorExecutor records the decision and generates the final outcome.
using Microsoft.Agents.AI.Workflows;
namespace MicrosoftAgentFrameworkLesson.ConsoleApp.HumanInTheLoop;
// ── Data Models ───────────────────────────────────────────────────────────────
/// <summary>An expense claim submitted by an employee.</summary>
public sealed class ExpenseClaim
{
public string EmployeeName { get; set; } = string.Empty;
public string Category { get; set; } = string.Empty;
public decimal Amount { get; set; }
public string Description { get; set; } = string.Empty;
public bool HasReceipts { get; set; }
}
/// <summary>Audit report produced by automated policy checks.</summary>
public sealed class ExpenseAuditReport
{
public string EmployeeName { get; set; } = string.Empty;
public string Category { get; set; } = string.Empty;
public decimal Amount { get; set; }
public string PolicyStatus { get; set; } = string.Empty;
public string AuditNotes { get; set; } = string.Empty;
public bool RequiresManagerApproval { get; set; }
}
/// <summary>Human manager's decision bundled with the original audit report.</summary>
public sealed class ApprovalDecision
{
public ExpenseAuditReport Report { get; set; } = new();
public bool Approved { get; set; }
public string ManagerNotes { get; set; } = string.Empty;
}
/// <summary>Final processing outcome after the decision is recorded.</summary>
public sealed class ExpenseOutcome
{
public string EmployeeName { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
public string Message { get; set; } = string.Empty;
}
// ── Executor 1: ExpenseClaim → ExpenseAuditReport ────────────────────────────
/// <summary>
/// Audits an expense claim against company policy rules.
/// Flags claims that exceed the $500 manager-approval threshold or lack receipts.
/// </summary>
internal sealed class ExpenseAuditorExecutor()
: Executor<ExpenseClaim, ExpenseAuditReport>("ExpenseAuditor")
{
private const decimal ManagerApprovalThreshold = 500m;
public override ValueTask<ExpenseAuditReport> HandleAsync(
ExpenseClaim claim, IWorkflowContext context, CancellationToken ct = default)
{
var notes = new List<string>();
string policyStatus = "Within Policy";
if (claim.Amount > ManagerApprovalThreshold)
{
policyStatus = "Exceeds Limit";
notes.Add($"Amount ${claim.Amount:N2} exceeds the ${ManagerApprovalThreshold:N0} threshold.");
}
if (!claim.HasReceipts)
{
policyStatus = "Missing Receipts";
notes.Add("No receipts attached — receipts are mandatory for all claims.");
}
if (claim.Category == "Entertainment" && claim.Amount > 200m)
notes.Add("Entertainment expenses over $200 require a written business justification.");
bool requiresApproval = claim.Amount > ManagerApprovalThreshold || !claim.HasReceipts;
return ValueTask.FromResult(new ExpenseAuditReport
{
EmployeeName = claim.EmployeeName,
Category = claim.Category,
Amount = claim.Amount,
PolicyStatus = policyStatus,
AuditNotes = notes.Count > 0 ? string.Join(" | ", notes) : "All policy checks passed.",
RequiresManagerApproval = requiresApproval
});
}
}
// ── Executor 2: ApprovalDecision → ExpenseOutcome ────────────────────────────
/// <summary>
/// Records the manager's approval or rejection and generates the final outcome
/// message that will be sent to payroll or returned to the employee.
/// </summary>
internal sealed class ExpenseProcessorExecutor()
: Executor<ApprovalDecision, ExpenseOutcome>("ExpenseProcessor")
{
public override ValueTask<ExpenseOutcome> HandleAsync(
ApprovalDecision decision, IWorkflowContext context, CancellationToken ct = default)
{
var outcome = new ExpenseOutcome { EmployeeName = decision.Report.EmployeeName };
if (decision.Approved)
{
outcome.Status = "APPROVED";
outcome.Message = $"${decision.Report.Amount:N2} ({decision.Report.Category}) approved. " +
$"Reimbursement will be processed in the next payroll cycle. " +
$"Manager note: \"{decision.ManagerNotes}\"";
}
else
{
outcome.Status = "REJECTED";
outcome.Message = $"${decision.Report.Amount:N2} ({decision.Report.Category}) rejected. " +
$"Please correct the issues and resubmit. " +
$"Manager note: \"{decision.ManagerNotes}\"";
}
return ValueTask.FromResult(outcome);
}
}
// ── Demo Runner ───────────────────────────────────────────────────────────────
public static class HumanInTheLoopDemo
{
public static async Task RunAsync()
{
Console.WriteLine("====== Human-in-the-Loop — HR Expense Approval ======\n");
// Demo 1: Small travel expense — within policy, auto-approved
await ProcessExpenseAsync(
new ExpenseClaim
{
EmployeeName = "Maria Svensson",
Category = "Travel",
Amount = 118.50m,
Description = "Train tickets to regional office",
HasReceipts = true
},
simulatedDecision: "approve",
simulatedNotes: "Auto-approved — within policy.");
// Demo 2: Large conference expense — requires manager approval, approved
await ProcessExpenseAsync(
new ExpenseClaim
{
EmployeeName = "James Okafor",
Category = "Conference",
Amount = 1_350.00m,
Description = "Annual tech summit registration + hotel",
HasReceipts = true
},
simulatedDecision: "approve",
simulatedNotes: "Conference attendance pre-approved by department head.");
// Demo 3: Entertainment without receipts — rejected by manager
await ProcessExpenseAsync(
new ExpenseClaim
{
EmployeeName = "Lena Fischer",
Category = "Entertainment",
Amount = 420.00m,
Description = "Client dinner",
HasReceipts = false
},
simulatedDecision: "reject",
simulatedNotes: "Cannot process without receipts and business justification. Resubmit.");
}
// ── Core HITL flow ────────────────────────────────────────────────────────
private static async Task ProcessExpenseAsync(
ExpenseClaim claim,
string simulatedDecision,
string simulatedNotes)
{
Console.WriteLine($"--- {claim.EmployeeName} | {claim.Category} | ${claim.Amount:N2} | Receipts: {(claim.HasReceipts ? "Yes" : "No")} ---");
// ── PHASE 1: Automated audit ──────────────────────────────────────────
var auditor = new ExpenseAuditorExecutor().BindExecutor();
var phase1 = new WorkflowBuilder(auditor).WithOutputFrom(auditor).Build();
Run auditRun = await InProcessExecution.RunAsync(phase1, claim);
ExpenseAuditReport? report = null;
foreach (WorkflowEvent evt in auditRun.OutgoingEvents)
{
if (evt is WorkflowOutputEvent o && o.Data is ExpenseAuditReport r)
report = r;
}
if (report is null)
{
Console.WriteLine(" ERROR: Audit produced no report.\n");
return;
}
Console.WriteLine($" [AUDIT] Status : {report.PolicyStatus}");
Console.WriteLine($" [AUDIT] Notes : {report.AuditNotes}");
// ── HUMAN-IN-THE-LOOP: Collect manager decision ───────────────────────
bool approved;
string managerNotes;
if (!report.RequiresManagerApproval)
{
// Expense is within policy — no human step needed.
Console.WriteLine(" [AUTO] Expense is within policy. Automatically approved.");
approved = true;
managerNotes = "Auto-approved — within policy.";
}
else
{
// Pause and wait for human reviewer.
// In a real application replace the two simulated lines with:
// Console.Write(" Manager decision (approve/reject): ");
// simulatedDecision = Console.ReadLine() ?? "reject";
// Console.Write(" Manager notes: ");
// simulatedNotes = Console.ReadLine() ?? "";
Console.WriteLine(" [HITL] Waiting for manager review...");
Console.WriteLine($" [HITL] Policy status: {report.PolicyStatus}");
Console.Write(" Manager decision (approve/reject): ");
Console.WriteLine(simulatedDecision); // simulated human input
Console.Write(" Manager notes: ");
Console.WriteLine(simulatedNotes); // simulated human input
approved = simulatedDecision.Equals("approve", StringComparison.OrdinalIgnoreCase);
managerNotes = simulatedNotes;
}
// ── PHASE 2: Process the decision ─────────────────────────────────────
var processor = new ExpenseProcessorExecutor().BindExecutor();
var phase2 = new WorkflowBuilder(processor).WithOutputFrom(processor).Build();
Run processRun = await InProcessExecution.RunAsync(
phase2,
new ApprovalDecision { Report = report, Approved = approved, ManagerNotes = managerNotes });
foreach (WorkflowEvent evt in processRun.OutgoingEvents)
{
if (evt is WorkflowOutputEvent o && o.Data is ExpenseOutcome outcome)
Console.WriteLine($" [RESULT] {outcome.Status} — {outcome.Message}");
}
Console.WriteLine();
}
}