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
1 change: 1 addition & 0 deletions dotnet/agent-framework-dotnet.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@
<Project Path="samples/04-hosting/DurableWorkflows/ConsoleApps/06_WorkflowSharedState/06_WorkflowSharedState.csproj" />
<Project Path="samples/04-hosting/DurableWorkflows/ConsoleApps/07_SubWorkflows/07_SubWorkflows.csproj" />
<Project Path="samples/04-hosting/DurableWorkflows/ConsoleApps/08_WorkflowHITL/08_WorkflowHITL.csproj" />
<Project Path="samples/04-hosting/DurableWorkflows/ConsoleApps/09_SwitchRouting/09_SwitchRouting.csproj" />
</Folder>
<Folder Name="/Samples/04-hosting/DurableWorkflows/AzureFunctions/">
<Project Path="samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/01_SequentialWorkflow.csproj" />
Expand Down
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>SwitchRouting</AssemblyName>
<RootNamespace>SwitchRouting</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,36 @@
// Copyright (c) Microsoft. All rights reserved.

using Microsoft.Agents.AI.Workflows;

namespace SwitchRouting;

internal sealed record Expense(string Id, decimal Amount);

internal sealed class ExpenseParser() : Executor<string, Expense>("ExpenseParser")
{
public override ValueTask<Expense> HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
// The input is the expense amount (e.g., "50" or "4500"). A real workflow would
// look the expense up from a store; here we just attach a generated id.
decimal amount = decimal.TryParse(message, out decimal parsed) ? parsed : 0m;
return ValueTask.FromResult(new Expense($"EXP-{Guid.NewGuid().ToString()[..4]}", amount));
}
}

internal sealed class AutoApprove() : Executor<Expense, string>("AutoApprove")
{
public override ValueTask<string> HandleAsync(Expense message, IWorkflowContext context, CancellationToken cancellationToken = default)
=> ValueTask.FromResult($"Expense {message.Id} for {message.Amount:C} was auto-approved.");
}

internal sealed class ManagerApproval() : Executor<Expense, string>("ManagerApproval")
{
public override ValueTask<string> HandleAsync(Expense message, IWorkflowContext context, CancellationToken cancellationToken = default)
=> ValueTask.FromResult($"Expense {message.Id} for {message.Amount:C} was routed to a manager for approval.");
}

internal sealed class DirectorApproval() : Executor<Expense, string>("DirectorApproval")
{
public override ValueTask<string> HandleAsync(Expense message, IWorkflowContext context, CancellationToken cancellationToken = default)
=> ValueTask.FromResult($"Expense {message.Id} for {message.Amount:C} was routed to a director for approval.");
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// Copyright (c) Microsoft. All rights reserved.

// This sample demonstrates multi-way routing in a workflow using AddSwitch.
// An expense is routed to a different approval path based on its amount:
// - Amount < 100 -> AutoApprove
// - Amount < 1000 -> ManagerApproval
// - Otherwise -> DirectorApproval (default)
//
// Unlike AddEdge(..., condition:), which adds an independent boolean condition per edge,
// AddSwitch evaluates its cases in order and routes to the FIRST matching branch (or the
// default when none match), making it a natural fit for multi-way routing.

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 SwitchRouting;

string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING")
?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None";

// Create executor instances
ExpenseParser expenseParser = new();
AutoApprove autoApprove = new();
ManagerApproval managerApproval = new();
DirectorApproval directorApproval = new();

// Build a workflow that switches on the parsed expense amount. Cases are evaluated in order;
// the first matching case wins, and WithDefault handles everything else.
WorkflowBuilder builder = new(expenseParser);
builder.AddSwitch(expenseParser, switchBuilder =>
switchBuilder
.AddCase<Expense>(expense => expense!.Amount < 100m, autoApprove)
.AddCase<Expense>(expense => expense!.Amount < 1000m, managerApproval)
.WithDefault(directorApproval));

Workflow approveExpense = builder.WithName("ApproveExpense").Build();

IHost host = Host.CreateDefaultBuilder(args)
.ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Warning))
.ConfigureServices(services =>
{
services.ConfigureDurableWorkflows(
workflowOptions => workflowOptions.AddWorkflow(approveExpense),
workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString),
clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString));
})
.Build();

await host.StartAsync();

IWorkflowClient workflowClient = host.Services.GetRequiredService<IWorkflowClient>();

Console.WriteLine("Enter an expense amount (or 'exit'):");
Console.WriteLine("Tip: try 50, 450, and 5000 to see each branch.\n");

while (true)
{
Console.Write("> ");
string? input = Console.ReadLine();
if (string.IsNullOrWhiteSpace(input) || input.Equals("exit", StringComparison.OrdinalIgnoreCase))
{
break;
}

try
{
await StartNewWorkflowAsync(input, approveExpense, workflowClient);
}
catch (Exception ex)
{
Console.WriteLine($"Error: {ex.Message}");
}

Console.WriteLine();
}

await host.StopAsync();

// Start a new workflow and wait for completion
static async Task StartNewWorkflowAsync(string amount, Workflow workflow, IWorkflowClient client)
{
Console.WriteLine($"Starting workflow for expense amount '{amount}'...");

// Cast to IAwaitableWorkflowRun to access WaitForCompletionAsync
IAwaitableWorkflowRun run = (IAwaitableWorkflowRun)await client.RunAsync(workflow, amount);
Console.WriteLine($"Run ID: {run.RunId}");

try
{
Console.WriteLine("Waiting for workflow to complete...");
string? result = await run.WaitForCompletionAsync<string>();
Console.WriteLine($"Workflow completed. {result}");
}
catch (InvalidOperationException ex)
{
Console.WriteLine($"Failed: {ex.Message}");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# Switch Routing Workflow Sample

This sample demonstrates how to build a workflow with **multi-way routing** using `AddSwitch`. A switch evaluates a set of ordered cases against the output of an executor and routes execution to the **first matching** branch — or to a **default** branch when no case matches.

> **Related sample:** [`03_ConditionalEdges`](../03_ConditionalEdges/README.md) solves a similar branching problem with a different API — `AddEdge(..., condition:)` for per-edge boolean conditions. See [`AddSwitch` vs. conditional edges](#addswitch-vs-conditional-edges) below for when to use which.

## Key Concepts Demonstrated

- Building workflows with **multi-way routing** using `AddSwitch` and `AddCase` / `WithDefault`
- Ordered, first-match-wins case evaluation
- Falling back to a default branch when no case matches
- Using `ConfigureDurableWorkflows` to register workflows with dependency injection

## Overview

The sample implements an expense approval workflow that routes each expense to a different approval path based on its amount:

```
ExpenseParser --[amount < 100]---> AutoApprove
|--[amount < 1000]--> ManagerApproval
+--[default]--------> DirectorApproval
```

| Executor | Description |
|----------|-------------|
| ExpenseParser | Parses the entered amount into an `Expense` |
| AutoApprove | Auto-approves small expenses (`< 100`) |
| ManagerApproval | Routes mid-range expenses (`< 1000`) to a manager |
| DirectorApproval | Default branch for everything else (`>= 1000`) |

## How `AddSwitch` Works

`AddSwitch` configures a switch on the output of a source executor. Cases are evaluated **in order**, and the **first** matching case wins; `WithDefault` handles anything that matches no case:

```csharp
builder.AddSwitch(expenseParser, switchBuilder =>
switchBuilder
.AddCase<Expense>(expense => expense!.Amount < 100m, autoApprove)
.AddCase<Expense>(expense => expense!.Amount < 1000m, managerApproval)
.WithDefault(directorApproval));
```

Each case predicate receives the output of the source executor and returns a boolean.

### `AddSwitch` vs. conditional edges

The [`03_ConditionalEdges`](../03_ConditionalEdges/README.md) sample uses `AddEdge(..., condition:)`, where each edge carries its own **independent** boolean condition (an edge is traversed whenever its condition is true). `AddSwitch` instead models **mutually exclusive, first-match-wins** routing with a single default — a better fit when exactly one of several branches should run.

## 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/04-hosting/DurableWorkflows/ConsoleApps/09_SwitchRouting
dotnet run --framework net10.0
```

### Sample Output

```text
Enter an expense amount (or 'exit'):
Tip: try 50, 450, and 5000 to see each branch.

> 50
Starting workflow for expense amount '50'...
Run ID: abc123...
Waiting for workflow to complete...
Workflow completed. Expense EXP-1a2b for $50.00 was auto-approved.

> 450
Starting workflow for expense amount '450'...
Run ID: def456...
Waiting for workflow to complete...
Workflow completed. Expense EXP-3c4d for $450.00 was routed to a manager for approval.

> 5000
Starting workflow for expense amount '5000'...
Run ID: ghi789...
Waiting for workflow to complete...
Workflow completed. Expense EXP-5e6f for $5,000.00 was routed to a director for approval.
```
1 change: 1 addition & 0 deletions dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
- Fix issue with resuming checkpoint after package version upgrade ([#6670](https://github.com/microsoft/agent-framework/pull/6670))
- Bind MCP threadId to the current agent and guard cross-agent session dispatch ([#6531](https://github.com/microsoft/agent-framework/pull/6531))
- Added support for durable workflows ([#4436](https://github.com/microsoft/agent-framework/pull/4436))
- Added support for `AddSwitch` and target-selecting fan-out edges in the durable workflow runner ([#6749](https://github.com/microsoft/agent-framework/pull/6749))

## v1.0.0-preview.260219.1

Expand Down
38 changes: 38 additions & 0 deletions dotnet/src/Microsoft.Agents.AI.DurableTask/Logs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -227,4 +227,42 @@ public static partial void LogWaitingForExternalEvent(
public static partial void LogReceivedExternalEvent(
this ILogger logger,
string requestPortId);

[LoggerMessage(
EventId = 114,
Level = LogLevel.Debug,
Message = "Fan-out from {Source}: selector matched {SelectedCount} of {TotalCount} target(s)")]
public static partial void LogFanOutSelectorMatched(
this ILogger logger,
string source,
int selectedCount,
int totalCount);

[LoggerMessage(
EventId = 115,
Level = LogLevel.Warning,
Message = "Failed to evaluate fan-out selector for source {Source}, skipping")]
public static partial void LogFanOutSelectorEvaluationFailed(
this ILogger logger,
Exception ex,
string source);

[LoggerMessage(
EventId = 116,
Level = LogLevel.Debug,
Message = "Fan-out from {Source}: routing to {Count} target(s)")]
public static partial void LogFanOutRouting(
this ILogger logger,
string source,
int count);

[LoggerMessage(
EventId = 117,
Level = LogLevel.Warning,
Message = "Fan-out from {Source}: selector returned out-of-range index {Index} (target count {Count}), skipping")]
public static partial void LogFanOutSelectorIndexOutOfRange(
this ILogger logger,
string source,
int index,
int count);
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.

using System.Diagnostics.CodeAnalysis;
using System.Text.Json;

namespace Microsoft.Agents.AI.DurableTask.Workflows;
Expand All @@ -19,4 +20,27 @@ internal static class DurableSerialization
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};

/// <summary>
/// Deserializes a workflow message's JSON to the source executor's output type so an edge condition or
/// fan-out selector can evaluate it as a strongly-typed value. Falls back to a generic object when the
/// type is unknown, and returns <c>null</c> for empty input.
/// </summary>
/// <param name="json">The serialized message.</param>
/// <param name="targetType">The source executor's output type, or <c>null</c> if unknown.</param>
/// <returns>The deserialized object, or <c>null</c> if the JSON is empty.</returns>
/// <exception cref="JsonException">Thrown when the JSON is invalid or cannot be deserialized to the target type.</exception>
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow types registered at startup.")]
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow types registered at startup.")]
internal static object? DeserializeMessage(string json, Type? targetType)
{
if (string.IsNullOrEmpty(json))
{
return null;
}

return targetType is null
? JsonSerializer.Deserialize<object>(json, Options)
: JsonSerializer.Deserialize(json, targetType, Options);
}
}
Loading
Loading