Skip to content

Instantly share code, notes, and snippets.

@LadyNaggaga
Created October 31, 2025 17:02
Show Gist options
  • Select an option

  • Save LadyNaggaga/06bea7d2af566021d4521e747f41aa6d to your computer and use it in GitHub Desktop.

Select an option

Save LadyNaggaga/06bea7d2af566021d4521e747f41aa6d to your computer and use it in GitHub Desktop.

AG-UI Multi-Agent Subgraph Implementation Guide

Overview

This guide shows how to adapt the AccedeSimple travel expense application to use AG-UI's multi-agent subgraph pattern, inspired by the LangGraph subgraphs demo.

Requirements: .NET 10 SDK, Python 3.12+, Node.js

Current Architecture vs. Target Pattern

Current: Linear Workflow

UserMessage β†’ TravelPlanning β†’ UserSelection β†’ TripRequestCreation β†’ AdminApproval β†’ ApprovalResponse

Target: Multi-Agent Subgraph Pattern

SupervisorAgent
    β”œβ”€β”€ PolicyCheckAgent (checks expense policies)
    β”œβ”€β”€ ReceiptAnalysisAgent (validates receipts)
    └── ApprovalRoutingAgent (routes to appropriate approver)

Implementation Plan

1. Create Specialized Agent Executors

Create three new executors for expense approval workflow:

PolicyCheckExecutor.cs

namespace AccedeSimple.Service.Executors;

public class PolicyCheckExecutor(
    ILogger<PolicyCheckExecutor> logger,
    IChatClient chatClient,
    SearchService searchService,
    MessageService messageService,
    IOptions<UserSettings> userSettings) 
    : Executor<ExpenseSubmission, PolicyCheckResult>("PolicyCheckExecutor")
{
    public override async ValueTask<PolicyCheckResult> HandleAsync(
        ExpenseSubmission input,
        IWorkflowContext context,
        CancellationToken cancellationToken)
    {
        // Emit AG-UI event: Agent started
        await messageService.AddMessageAsync(
            new AgentStatusUpdate("Policy Check Agent", "active", "Checking expense against company policies..."),
            userSettings.Value.UserId
        );

        // Search policy documents
        var policyQuery = $"expense policy for {input.Category} amount {input.Amount}";
        var policyResults = await searchService.SearchAsync(policyQuery);

        // Use LLM to analyze against policy
        var prompt = $$$"""
            Analyze this expense submission against company policies:
            
            Expense Details:
            - Category: {{{input.Category}}}
            - Amount: ${{{input.Amount}}}
            - Date: {{{input.Date}}}
            - Description: {{{input.Description}}}
            
            Policy Documents:
            {{{string.Join("\n", policyResults)}}}
            
            Determine:
            1. Is this expense within policy limits?
            2. Are there any policy violations?
            3. What's the risk level (low/medium/high)?
            """;

        var response = await chatClient.GetResponseAsync<PolicyCheckResult>(
            prompt, 
            cancellationToken: cancellationToken
        );

        response.TryGetResult(out var result);

        // Emit AG-UI event: Policy check complete
        await messageService.AddMessageAsync(
            new PolicyCheckUpdate(result),
            userSettings.Value.UserId
        );

        // Store in workflow state
        await context.QueueStateUpdateAsync("policy-check", result, "expense-approval", cancellationToken);

        return result;
    }
}

ReceiptAnalysisExecutor.cs

public class ReceiptAnalysisExecutor(
    ILogger<ReceiptAnalysisExecutor> logger,
    IChatClient chatClient,
    MessageService messageService,
    IOptions<UserSettings> userSettings)
    : Executor<ExpenseSubmission, ReceiptAnalysisResult>("ReceiptAnalysisExecutor")
{
    public override async ValueTask<ReceiptAnalysisResult> HandleAsync(
        ExpenseSubmission input,
        IWorkflowContext context,
        CancellationToken cancellationToken)
    {
        await messageService.AddMessageAsync(
            new AgentStatusUpdate("Receipt Analysis Agent", "active", "Validating receipt data..."),
            userSettings.Value.UserId
        );

        // Analyze receipt attachments
        var receiptData = input.Attachments.FirstOrDefault();
        if (receiptData == null)
        {
            return new ReceiptAnalysisResult { IsValid = false, Issues = ["No receipt attached"] };
        }

        var prompt = $$$"""
            Analyze this receipt and verify it matches the expense claim:
            
            Claimed Amount: ${{{input.Amount}}}
            Claimed Date: {{{input.Date}}}
            Claimed Vendor: {{{input.Vendor}}}
            
            Extract from the receipt:
            - Actual amount charged
            - Transaction date
            - Merchant name
            - Line items
            
            Check for:
            - Discrepancies between claimed and actual amounts
            - Date mismatches
            - Signs of alteration or fraud
            """;

        var messages = new List<ChatMessage>
        {
            new ChatMessage(ChatRole.User, [
                new TextContent(prompt),
                new UriContent(receiptData.Uri, receiptData.ContentType)
            ])
        };

        var response = await chatClient.GetResponseAsync<ReceiptAnalysisResult>(
            messages,
            cancellationToken: cancellationToken
        );

        response.TryGetResult(out var result);

        await messageService.AddMessageAsync(
            new ReceiptAnalysisUpdate(result),
            userSettings.Value.UserId
        );

        await context.QueueStateUpdateAsync("receipt-analysis", result, "expense-approval", cancellationToken);

        return result;
    }
}

ApprovalRoutingExecutor.cs

public class ApprovalRoutingExecutor(
    ILogger<ApprovalRoutingExecutor> logger,
    IChatClient chatClient,
    MessageService messageService,
    IOptions<UserSettings> userSettings)
    : Executor<ExpenseApprovalContext, ApprovalRoute>("ApprovalRoutingExecutor")
{
    public override async ValueTask<ApprovalRoute> HandleAsync(
        ExpenseApprovalContext input,
        IWorkflowContext context,
        CancellationToken cancellationToken)
    {
        await messageService.AddMessageAsync(
            new AgentStatusUpdate("Routing Agent", "active", "Determining approval path..."),
            userSettings.Value.UserId
        );

        // Determine routing based on policy check and receipt analysis
        var route = new ApprovalRoute
        {
            RequiresManagerApproval = input.PolicyCheck.RiskLevel != "low" || input.Amount > 500,
            RequiresFinanceApproval = input.Amount > 1000,
            RequiresCEOApproval = input.Amount > 5000,
            AutoApprove = input.PolicyCheck.IsCompliant && 
                         input.ReceiptAnalysis.IsValid && 
                         input.Amount < 100
        };

        await messageService.AddMessageAsync(
            new ApprovalRouteUpdate(route),
            userSettings.Value.UserId
        );

        return route;
    }
}

2. Create Supervisor Executor

ExpenseApprovalSupervisorExecutor.cs

public class ExpenseApprovalSupervisorExecutor(
    ILogger<ExpenseApprovalSupervisorExecutor> logger,
    PolicyCheckExecutor policyCheckExecutor,
    ReceiptAnalysisExecutor receiptAnalysisExecutor,
    ApprovalRoutingExecutor approvalRoutingExecutor,
    MessageService messageService,
    IOptions<UserSettings> userSettings)
    : Executor<ExpenseSubmission, ExpenseApprovalDecision>("SupervisorExecutor")
{
    public override async ValueTask<ExpenseApprovalDecision> HandleAsync(
        ExpenseSubmission input,
        IWorkflowContext context,
        CancellationToken cancellationToken)
    {
        // Emit supervisor status
        await messageService.AddMessageAsync(
            new AgentStatusUpdate("Supervisor", "active", "Coordinating expense approval workflow..."),
            userSettings.Value.UserId
        );

        // Run policy check and receipt analysis in parallel
        var policyTask = policyCheckExecutor.HandleAsync(input, context, cancellationToken);
        var receiptTask = receiptAnalysisExecutor.HandleAsync(input, context, cancellationToken);

        await Task.WhenAll(policyTask.AsTask(), receiptTask.AsTask());

        var policyResult = await policyTask;
        var receiptResult = await receiptTask;

        // Determine routing
        var approvalContext = new ExpenseApprovalContext
        {
            Expense = input,
            PolicyCheck = policyResult,
            ReceiptAnalysis = receiptResult,
            Amount = input.Amount
        };

        var route = await approvalRoutingExecutor.HandleAsync(approvalContext, context, cancellationToken);

        // Build final decision
        var decision = new ExpenseApprovalDecision
        {
            ExpenseId = input.Id,
            AutoApproved = route.AutoApprove,
            RequiresHumanApproval = !route.AutoApprove,
            PolicyCheckResult = policyResult,
            ReceiptAnalysisResult = receiptResult,
            ApprovalRoute = route,
            Timestamp = DateTime.UtcNow
        };

        // Emit final supervisor update
        await messageService.AddMessageAsync(
            new SupervisorDecisionUpdate(decision),
            userSettings.Value.UserId
        );

        return decision;
    }
}

3. Update Extensions.cs with Subgraph Workflow

public static IServiceCollection AddExpenseApprovalWorkflow(
    this IServiceCollection services)
{
    // Register all executors
    services.AddTransient<PolicyCheckExecutor>();
    services.AddTransient<ReceiptAnalysisExecutor>();
    services.AddTransient<ApprovalRoutingExecutor>();
    services.AddTransient<ExpenseApprovalSupervisorExecutor>();
    services.AddTransient<HumanApprovalExecutor>(); // For HITL

    services.AddTransient<Microsoft.Agents.AI.Workflows.Workflow>(sp =>
    {
        var supervisor = sp.GetRequiredService<ExpenseApprovalSupervisorExecutor>();
        var humanApproval = sp.GetRequiredService<HumanApprovalExecutor>();

        // Create HITL port for cases requiring human approval
        var humanApprovalPort = RequestPort.Create<ExpenseApprovalDecision, ApprovalResult>("HumanApproval");

        // Build workflow with conditional routing
        var workflow = new WorkflowBuilder(supervisor)
            .AddEdge(supervisor, humanApprovalPort, 
                decision => decision.RequiresHumanApproval) // Only if needs approval
            .AddEdge(humanApprovalPort, humanApproval)
            .WithOutputFrom(humanApproval)
            .Build();

        return workflow;
    });

    return services;
}

4. Create AG-UI Domain Models

// Domain/AgentStatus.cs
public record AgentStatusUpdate(
    string AgentName,
    string Status, // "active", "complete", "error"
    string Message
) : ChatItem
{
    public override string Type => "agent-status";
    public override string Role => "system";
    public override bool IsUserVisible => true;
}

// Domain/PolicyCheckResult.cs
public record PolicyCheckResult
{
    public bool IsCompliant { get; init; }
    public string RiskLevel { get; init; } = "low"; // low, medium, high
    public List<string> Violations { get; init; } = [];
    public List<string> Warnings { get; init; } = [];
    public string PolicySummary { get; init; } = "";
}

public record PolicyCheckUpdate(PolicyCheckResult Result) : ChatItem
{
    public override string Type => "policy-check-result";
    public override string Role => "assistant";
    public override bool IsUserVisible => true;
    public override string Text => $"Policy check complete: {Result.IsCompliant ? "βœ… Compliant" : "❌ Issues found"}";
}

// Domain/ReceiptAnalysisResult.cs
public record ReceiptAnalysisResult
{
    public bool IsValid { get; init; }
    public decimal ExtractedAmount { get; init; }
    public DateTime ExtractedDate { get; init; }
    public string ExtractedVendor { get; init; } = "";
    public List<string> Issues { get; init; } = [];
    public double ConfidenceScore { get; init; }
}

public record ReceiptAnalysisUpdate(ReceiptAnalysisResult Result) : ChatItem
{
    public override string Type => "receipt-analysis-result";
    public override string Role => "assistant";
    public override bool IsUserVisible => true;
}

// Domain/ApprovalRoute.cs
public record ApprovalRoute
{
    public bool AutoApprove { get; init; }
    public bool RequiresManagerApproval { get; init; }
    public bool RequiresFinanceApproval { get; init; }
    public bool RequiresCEOApproval { get; init; }
    public List<string> ApprovalChain { get; init; } = [];
}

public record ApprovalRouteUpdate(ApprovalRoute Route) : ChatItem
{
    public override string Type => "approval-route";
    public override string Role => "assistant";
    public override bool IsUserVisible => true;
}

// Domain/ExpenseSubmission.cs
public record ExpenseSubmission
{
    public string Id { get; init; } = Guid.NewGuid().ToString();
    public string UserId { get; init; } = "";
    public ExpenseCategory Category { get; init; }
    public decimal Amount { get; init; }
    public DateTime Date { get; init; }
    public string Description { get; init; } = "";
    public string Vendor { get; init; } = "";
    public List<UriAttachment> Attachments { get; init; } = [];
}

public record ExpenseApprovalDecision
{
    public string ExpenseId { get; init; } = "";
    public bool AutoApproved { get; init; }
    public bool RequiresHumanApproval { get; init; }
    public PolicyCheckResult PolicyCheckResult { get; init; }
    public ReceiptAnalysisResult ReceiptAnalysisResult { get; init; }
    public ApprovalRoute ApprovalRoute { get; init; }
    public DateTime Timestamp { get; init; }
}

public record SupervisorDecisionUpdate(ExpenseApprovalDecision Decision) : ChatItem
{
    public override string Type => "supervisor-decision";
    public override string Role => "assistant";
    public override bool IsUserVisible => true;
}

5. Update Frontend to Display Multi-Agent Status

ExpenseApprovalPanel.tsx

import React from 'react';

interface Agent {
    name: string;
    status: 'idle' | 'active' | 'complete' | 'error';
    message?: string;
}

interface ExpenseApprovalPanelProps {
    currentAgent: string;
    policyCheck?: PolicyCheckResult;
    receiptAnalysis?: ReceiptAnalysisResult;
    approvalRoute?: ApprovalRoute;
}

export const ExpenseApprovalPanel: React.FC<ExpenseApprovalPanelProps> = ({
    currentAgent,
    policyCheck,
    receiptAnalysis,
    approvalRoute
}) => {
    const agents: Agent[] = [
        {
            name: 'πŸ‘¨β€πŸ’Ό Supervisor',
            status: currentAgent === 'Supervisor' ? 'active' : 'complete'
        },
        {
            name: 'πŸ“‹ Policy Check',
            status: policyCheck 
                ? 'complete' 
                : (currentAgent === 'Policy Check Agent' ? 'active' : 'idle')
        },
        {
            name: '🧾 Receipt Analysis',
            status: receiptAnalysis
                ? 'complete'
                : (currentAgent === 'Receipt Analysis Agent' ? 'active' : 'idle')
        },
        {
            name: '🎯 Routing',
            status: approvalRoute
                ? 'complete'
                : (currentAgent === 'Routing Agent' ? 'active' : 'idle')
        }
    ];

    return (
        <div className="expense-approval-panel">
            <h3>Expense Approval Process</h3>
            
            <div className="agent-pipeline">
                {agents.map(agent => (
                    <div key={agent.name} className={`agent-card agent-${agent.status}`}>
                        <div className="agent-name">{agent.name}</div>
                        <div className="agent-status">{agent.status}</div>
                    </div>
                ))}
            </div>

            {policyCheck && (
                <div className="policy-results">
                    <h4>πŸ“‹ Policy Check Results</h4>
                    <div className={policyCheck.isCompliant ? 'compliant' : 'non-compliant'}>
                        {policyCheck.isCompliant ? 'βœ… Compliant' : '❌ Policy Issues'}
                    </div>
                    {policyCheck.violations.length > 0 && (
                        <ul>
                            {policyCheck.violations.map((v, i) => (
                                <li key={i} className="violation">{v}</li>
                            ))}
                        </ul>
                    )}
                </div>
            )}

            {receiptAnalysis && (
                <div className="receipt-results">
                    <h4>🧾 Receipt Analysis</h4>
                    <div className={receiptAnalysis.isValid ? 'valid' : 'invalid'}>
                        {receiptAnalysis.isValid ? 'βœ… Valid Receipt' : '⚠️ Issues Found'}
                    </div>
                    <div className="receipt-details">
                        <p>Extracted Amount: ${receiptAnalysis.extractedAmount}</p>
                        <p>Vendor: {receiptAnalysis.extractedVendor}</p>
                        <p>Confidence: {(receiptAnalysis.confidenceScore * 100).toFixed(1)}%</p>
                    </div>
                </div>
            )}

            {approvalRoute && (
                <div className="approval-route">
                    <h4>🎯 Approval Path</h4>
                    {approvalRoute.autoApprove ? (
                        <div className="auto-approved">βœ… Auto-Approved</div>
                    ) : (
                        <div className="approval-chain">
                            <p>Requires approval from:</p>
                            <ul>
                                {approvalRoute.requiresManagerApproval && <li>πŸ‘€ Manager</li>}
                                {approvalRoute.requiresFinanceApproval && <li>πŸ’° Finance</li>}
                                {approvalRoute.requiresCEOApproval && <li>🏒 CEO</li>}
                            </ul>
                        </div>
                    )}
                </div>
            )}
        </div>
    );
};

Update VirtualizedChatList.tsx

// Add handler for new message types
const renderMessage = (message: Message) => {
    switch (message.type) {
        case 'agent-status':
            return <AgentStatusMessage message={message as AgentStatusUpdate} />;
        case 'policy-check-result':
            return <PolicyCheckDisplay message={message as PolicyCheckUpdate} />;
        case 'receipt-analysis-result':
            return <ReceiptAnalysisDisplay message={message as ReceiptAnalysisUpdate} />;
        case 'approval-route':
            return <ApprovalRouteDisplay message={message as ApprovalRouteUpdate} />;
        case 'supervisor-decision':
            return <SupervisorDecisionDisplay message={message as SupervisorDecisionUpdate} />;
        default:
            return <StandardMessage message={message} />;
    }
};

6. Update Endpoints to Handle New Message Types

// In Endpoints.cs HandleMessageAsync method, add cases for new types:

else if (chatItem is AgentStatusUpdate agentStatus)
{
    var serializedMessage = JsonSerializer.Serialize(agentStatus, JsonSerializerOptions.Web);
    await response.WriteAsync($"data: {serializedMessage}\n\n", cancellationToken);
    await response.Body.FlushAsync(cancellationToken);
}
else if (chatItem is PolicyCheckUpdate policyCheck)
{
    var serializedMessage = JsonSerializer.Serialize(policyCheck, JsonSerializerOptions.Web);
    await response.WriteAsync($"data: {serializedMessage}\n\n", cancellationToken);
    await response.Body.FlushAsync(cancellationToken);
}
// ... add other message types

Key Benefits of This Approach

  1. Parallel Processing: Policy checks and receipt analysis run simultaneously
  2. Real-time Updates: Each agent emits status updates as it works
  3. Transparent Process: Users see exactly what's being checked
  4. Flexible Routing: Approval paths determined dynamically based on results
  5. Auto-approval: Low-risk expenses can be auto-approved instantly
  6. Better UX: Visual feedback shows which agent is active

Testing the Workflow

// Create a test expense submission
var expense = new ExpenseSubmission
{
    UserId = "test-user",
    Category = ExpenseCategory.Meals,
    Amount = 75.50m,
    Date = DateTime.Today,
    Description = "Client lunch meeting",
    Vendor = "Restaurant ABC",
    Attachments = [new UriAttachment("https://...", "image/jpeg")]
};

// Submit through ProcessService
await processService.ActAsync(UserIntent.SubmitExpense, expense);

// Watch the SSE stream for:
// 1. agent-status: Supervisor active
// 2. agent-status: Policy Check Agent active
// 3. agent-status: Receipt Analysis Agent active
// 4. policy-check-result
// 5. receipt-analysis-result
// 6. agent-status: Routing Agent active
// 7. approval-route
// 8. supervisor-decision

AG-UI Event Types Emitted

Following the AG-UI protocol, these events are emitted:

  • agent-status - Real-time agent activity updates
  • policy-check-result - Generative UI showing policy compliance
  • receipt-analysis-result - Structured receipt validation data
  • approval-route - Dynamic routing decision
  • supervisor-decision - Final orchestration result
  • human-approval-request - HITL when needed (via RequestPort)

This mirrors the LangGraph subgraphs demo pattern while leveraging your existing .NET workflow infrastructure!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment