mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
37
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4a49c60cf | ||
|
|
5519533c60 | ||
|
|
d8a1a3977f | ||
|
|
d0ad92af7a | ||
|
|
45050a2b08 | ||
|
|
e64fb4a435 | ||
|
|
c82bab4d0e | ||
|
|
8add9748ef | ||
|
|
6b55de8438 | ||
|
|
06b921cc47 | ||
|
|
35caf28239 | ||
|
|
420e7b6038 | ||
|
|
cf6e0070b4 | ||
|
|
d98520d0ca | ||
|
|
ffb2468945 | ||
|
|
6224bce853 | ||
|
|
774a83cf53 | ||
|
|
d4d03def47 | ||
|
|
eec89baaec | ||
|
|
63a2283033 | ||
|
|
61e37cdc6e | ||
|
|
97ac897dff | ||
|
|
ce96daf05a | ||
|
|
261f050bad | ||
|
|
ebeeeeb421 | ||
|
|
fcd7aa0b77 | ||
|
|
56314f96f5 | ||
|
|
0fb395f5a1 | ||
|
|
1b3c67df96 | ||
|
|
5dfcfedf1e | ||
|
|
cf1c61760d | ||
|
|
6f69299f4a | ||
|
|
8b21180cf2 | ||
|
|
2fd9a5916a | ||
|
|
8b7aef63a6 | ||
|
|
5090ec393e | ||
|
|
536364131d |
@@ -112,14 +112,14 @@
|
||||
<PackageVersion Include="Microsoft.Bot.ObjectModel.PowerFx" Version="1.2025.1106.1" />
|
||||
<PackageVersion Include="Microsoft.PowerFx.Interpreter" Version="1.5.0-build.20251008-1002" />
|
||||
<!-- Durable Task -->
|
||||
<PackageVersion Include="Microsoft.DurableTask.Client" Version="1.18.0" />
|
||||
<PackageVersion Include="Microsoft.DurableTask.Client.AzureManaged" Version="1.18.0" />
|
||||
<PackageVersion Include="Microsoft.DurableTask.Worker" Version="1.18.0" />
|
||||
<PackageVersion Include="Microsoft.DurableTask.Worker.AzureManaged" Version="1.18.0" />
|
||||
<PackageVersion Include="Microsoft.DurableTask.Client" Version="1.19.1" />
|
||||
<PackageVersion Include="Microsoft.DurableTask.Client.AzureManaged" Version="1.19.0" />
|
||||
<PackageVersion Include="Microsoft.DurableTask.Worker" Version="1.19.0" />
|
||||
<PackageVersion Include="Microsoft.DurableTask.Worker.AzureManaged" Version="1.19.0" />
|
||||
<!-- Azure Functions -->
|
||||
<PackageVersion Include="Microsoft.Azure.Functions.Worker" Version="2.50.0" />
|
||||
<PackageVersion Include="Microsoft.Azure.Functions.Worker.ApplicationInsights" Version="2.50.0" />
|
||||
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask" Version="1.11.0" />
|
||||
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask" Version="1.13.1" />
|
||||
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask.AzureManaged" Version="1.0.1" />
|
||||
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.Http" Version="3.3.0" />
|
||||
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" Version="2.1.0" />
|
||||
|
||||
@@ -34,6 +34,10 @@
|
||||
<Project Path="samples/AzureFunctions/06_LongRunningTools/06_LongRunningTools.csproj" />
|
||||
<Project Path="samples/AzureFunctions/07_AgentAsMcpTool/07_AgentAsMcpTool.csproj" />
|
||||
<Project Path="samples/AzureFunctions/08_ReliableStreaming/08_ReliableStreaming.csproj" />
|
||||
<Project Path="samples/AzureFunctions/09_Workflow/09_Workflow.csproj" />
|
||||
<Project Path="samples/AzureFunctions/10_WorkflowConcurrent/10_WorkflowConcurrent.csproj" />
|
||||
<Project Path="samples/AzureFunctions/11_WorkflowSharedState/11_WorkflowSharedState.csproj" />
|
||||
<Project Path="samples/AzureFunctions/12_ConditionalEdges/12_ConditionalEdges.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/DurableAgents/">
|
||||
<File Path="samples/DurableAgents/ConsoleApps/README.md" />
|
||||
@@ -46,6 +50,12 @@
|
||||
<Project Path="samples/DurableAgents/ConsoleApps/05_AgentOrchestration_HITL/05_AgentOrchestration_HITL.csproj" />
|
||||
<Project Path="samples/DurableAgents/ConsoleApps/06_LongRunningTools/06_LongRunningTools.csproj" />
|
||||
<Project Path="samples/DurableAgents/ConsoleApps/07_ReliableStreaming/07_ReliableStreaming.csproj" />
|
||||
<Project Path="samples/DurableAgents/ConsoleApps/08_SingleWorkflow/08_SingleWorkflow.csproj" />
|
||||
<Project Path="samples/DurableAgents/ConsoleApps/09_SubWorkflows/09_SubWorkflows.csproj" />
|
||||
<Project Path="samples/DurableAgents/ConsoleApps/09_Workflow_Concurrency/09_Workflow_Concurrency.csproj" />
|
||||
<Project Path="samples/DurableAgents/ConsoleApps/10_Workflow_HITL/10_Workflow_HITL.csproj" />
|
||||
<Project Path="samples/DurableAgents/ConsoleApps/11_WorkflowEvents/11_WorkflowEvents.csproj" />
|
||||
<Project Path="samples/DurableAgents/ConsoleApps/12_WorkflowLoop/12_WorkflowLoop.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/GettingStarted/">
|
||||
<File Path="samples/GettingStarted/README.md" />
|
||||
@@ -413,8 +423,8 @@
|
||||
<Project Path="src/Microsoft.Agents.AI.Purview/Microsoft.Agents.AI.Purview.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Workflows.Declarative.AzureAI/Microsoft.Agents.AI.Workflows.Declarative.AzureAI.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Workflows.Declarative/Microsoft.Agents.AI.Workflows.Declarative.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Workflows/Microsoft.Agents.AI.Workflows.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Workflows.Generators/Microsoft.Agents.AI.Workflows.Generators.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Workflows/Microsoft.Agents.AI.Workflows.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Tests/" />
|
||||
|
||||
@@ -3,10 +3,14 @@
|
||||
<packageSources>
|
||||
<clear />
|
||||
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
|
||||
<add key="LocalNugetSource" value="C:\LocalNugetSource" />
|
||||
</packageSources>
|
||||
<packageSourceMapping>
|
||||
<packageSource key="nuget.org">
|
||||
<package pattern="*" />
|
||||
</packageSource>
|
||||
<packageSource key="LocalNugetSource">
|
||||
<package pattern="*" />
|
||||
</packageSource>
|
||||
</packageSourceMapping>
|
||||
</configuration>
|
||||
@@ -8,3 +8,6 @@ dotnet_diagnostic.DURABLE0003.severity = none
|
||||
dotnet_diagnostic.DURABLE0004.severity = none
|
||||
dotnet_diagnostic.DURABLE0005.severity = none
|
||||
dotnet_diagnostic.DURABLE0006.severity = none
|
||||
|
||||
# CA1812: Internal classes are instantiated via dependency injection or reflection in samples
|
||||
dotnet_diagnostic.CA1812.severity = none
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<!-- The Functions build tools don't like namespaces that start with a number -->
|
||||
<AssemblyName>SingleAgent</AssemblyName>
|
||||
<RootNamespace>SingleAgent</RootNamespace>
|
||||
<AssemblyName>Workflow</AssemblyName>
|
||||
<RootNamespace>Workflow</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -5,4 +5,4 @@
|
||||
POST {{authority}}/api/agents/Joker/run
|
||||
Content-Type: text/plain
|
||||
|
||||
Tell me a joke about a pirate.
|
||||
Hello world
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<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>SingleAgent</AssemblyName>
|
||||
<RootNamespace>SingleAgent</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</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" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<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" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Hosting.AzureFunctions\Microsoft.Agents.AI.Hosting.AzureFunctions.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,67 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
namespace SingleAgent;
|
||||
|
||||
/// <summary>
|
||||
/// Parses an Order ID from a string input and returns an Order object populated.
|
||||
/// </summary>
|
||||
internal sealed class OrderLookup() : Executor<string, Order>("OrderLookup")
|
||||
{
|
||||
public override async ValueTask<Order> HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Populate Order information from OrderId.
|
||||
return new Order(message, 100.0m);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enriches an Order object with additional information.
|
||||
/// </summary>
|
||||
internal sealed class OrderEnrich() : Executor<Order, Order>("EnrichOrder")
|
||||
{
|
||||
public override async ValueTask<Order> HandleAsync(Order message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (message.Customer is null)
|
||||
{
|
||||
// populate customer information for the order from database.
|
||||
message.Customer = new Customer(1, "Jerry");
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class PaymentProcessor() : Executor<Order, Order>("ProcessPayment")
|
||||
{
|
||||
public override async ValueTask<Order> HandleAsync(Order message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
message.PaymentReferenceNumber = Guid.NewGuid().ToString()[^4..];
|
||||
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class OrderCancel() : Executor<Order, string>("OrderCancel")
|
||||
{
|
||||
public override async ValueTask<string> HandleAsync(Order message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return $"Order {message.Id} cancelled at {DateTime.UtcNow:g} UTC.";
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class Order
|
||||
{
|
||||
public Order(string id, decimal amount)
|
||||
{
|
||||
this.Id = id;
|
||||
this.Amount = amount;
|
||||
}
|
||||
public string Id { get; }
|
||||
public decimal Amount { get; }
|
||||
public Customer? Customer { get; set; }
|
||||
public string? PaymentReferenceNumber { get; set; }
|
||||
}
|
||||
|
||||
public sealed record Customer(int Id, string Name);
|
||||
@@ -0,0 +1,48 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Hosting.AzureFunctions;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Azure.Functions.Worker.Builder;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using SingleAgent;
|
||||
|
||||
Func<string, string> orderParserFunc = input =>
|
||||
{
|
||||
// We accept both short ordereId(Ex:12345) and long order reference number(MSFT12345)
|
||||
// OrderId is the last 5 digigs of order reference number.
|
||||
const int OrderIdPartLength = 5;
|
||||
if (input.Length > OrderIdPartLength)
|
||||
{
|
||||
return input[^OrderIdPartLength..];
|
||||
}
|
||||
|
||||
return input;
|
||||
};
|
||||
var orderParserExecutor = orderParserFunc.BindAsExecutor("ParseOrderId");
|
||||
|
||||
OrderLookup orderLookupExecutor = new();
|
||||
OrderEnrich orderEnricherExeecutor = new();
|
||||
PaymentProcessor paymentProcessorExecutor = new();
|
||||
|
||||
Workflow fulfillOrder = new WorkflowBuilder(orderParserExecutor)
|
||||
.WithName("FulfillOrder")
|
||||
.WithDescription("Looks up an order by ID and run payment processing")
|
||||
.AddEdge(orderParserExecutor, orderLookupExecutor)
|
||||
.AddEdge(orderLookupExecutor, orderEnricherExeecutor)
|
||||
.AddEdge(orderEnricherExeecutor, paymentProcessorExecutor)
|
||||
.Build();
|
||||
|
||||
//OrderCancel orderArchiverExecutor = new();
|
||||
//Workflow cancelOrder = new WorkflowBuilder(orderParserExecutor)
|
||||
// .WithName("CancelOrder")
|
||||
// .WithDescription("Cancel an order")
|
||||
// .AddEdge(orderParserExecutor, orderLookupExecutor)
|
||||
// .AddEdge(orderLookupExecutor, orderArchiverExecutor)
|
||||
// .Build();
|
||||
|
||||
var host = FunctionsApplication.CreateBuilder(args)
|
||||
.ConfigureFunctionsWebApplication()
|
||||
.ConfigureDurableOptions(options => options.Workflows.AddWorkflow(fulfillOrder))
|
||||
.Build();
|
||||
|
||||
host.Run();
|
||||
@@ -0,0 +1,89 @@
|
||||
# Single Agent Sample
|
||||
|
||||
This sample demonstrates how to use the Durable Agent Framework (DAFx) to create a simple Azure Functions app that hosts a single AI agent and provides direct HTTP API access for interactive conversations.
|
||||
|
||||
## Key Concepts Demonstrated
|
||||
|
||||
- Using the Microsoft Agent Framework to define a simple AI agent with a name and instructions.
|
||||
- Registering agents with the Function app and running them using HTTP.
|
||||
- Conversation management (via session IDs) for isolated interactions.
|
||||
|
||||
## Environment Setup
|
||||
|
||||
See the [README.md](../README.md) file in the parent directory for more information on how to configure the environment, including how to install and run common sample dependencies.
|
||||
|
||||
## Running the Sample
|
||||
|
||||
With the environment setup and function app running, you can test the sample by sending an HTTP request to the agent endpoint.
|
||||
|
||||
You can use the `demo.http` file to send a message to the agent, or a command line tool like `curl` as shown below:
|
||||
|
||||
Bash (Linux/macOS/WSL):
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:7071/api/agents/Joker/run \
|
||||
-H "Content-Type: text/plain" \
|
||||
-d "Tell me a joke about a pirate."
|
||||
```
|
||||
|
||||
PowerShell:
|
||||
|
||||
```powershell
|
||||
Invoke-RestMethod -Method Post `
|
||||
-Uri http://localhost:7071/api/agents/Joker/run `
|
||||
-ContentType text/plain `
|
||||
-Body "Tell me a joke about a pirate."
|
||||
```
|
||||
|
||||
You can also send JSON requests:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:7071/api/agents/Joker/run \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Accept: application/json" \
|
||||
-d '{"message": "Tell me a joke about a pirate."}'
|
||||
```
|
||||
|
||||
To continue a conversation, include the `thread_id` in the query string or JSON body:
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:7071/api/agents/Joker/run?thread_id=your-thread-id" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Accept: application/json" \
|
||||
-d '{"message": "Tell me another one."}'
|
||||
```
|
||||
|
||||
The response from the agent will be displayed in the terminal where you ran `func start`. The expected `text/plain` output will look something like:
|
||||
|
||||
```text
|
||||
Why don't pirates ever learn the alphabet? Because they always get stuck at "C"!
|
||||
```
|
||||
|
||||
The expected `application/json` output will look something like:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": 200,
|
||||
"thread_id": "ee6e47a0-f24b-40b1-ade8-16fcebb9eb40",
|
||||
"response": {
|
||||
"Messages": [
|
||||
{
|
||||
"AuthorName": "Joker",
|
||||
"CreatedAt": "2025-11-11T12:00:00.0000000Z",
|
||||
"Role": "assistant",
|
||||
"Contents": [
|
||||
{
|
||||
"Type": "text",
|
||||
"Text": "Why don't pirates ever learn the alphabet? Because they always get stuck at 'C'!"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Usage": {
|
||||
"InputTokenCount": 78,
|
||||
"OutputTokenCount": 36,
|
||||
"TotalTokenCount": 114
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,14 @@
|
||||
# Default endpoint address for local testing
|
||||
@authority=http://localhost:7071
|
||||
|
||||
### Look up a long order reference id
|
||||
POST {{authority}}/api/workflows/FulfillOrder/run
|
||||
Content-Type: text/plain
|
||||
|
||||
QWERTY80853
|
||||
|
||||
### Look up a short order id
|
||||
POST {{authority}}/api/workflows/CancelOrder/run
|
||||
Content-Type: text/plain
|
||||
|
||||
12345
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<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>SingleAgent</AssemblyName>
|
||||
<RootNamespace>SingleAgent</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" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<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" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Hosting.AzureFunctions\Microsoft.Agents.AI.Hosting.AzureFunctions.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,38 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
namespace SingleAgent;
|
||||
|
||||
internal sealed class ConcurrentStartExecutor() : Executor<string, string>("ConcurrentStartExecutor")
|
||||
{
|
||||
public override ValueTask<string> HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// do some initial parsing and validation of the message.
|
||||
// Return a polished version ith additional metadta.
|
||||
if (!message.StartsWith("Query for the agent:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
message = "Query for the agent: " + message;
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(message);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class ResultAggregationExecutor() : Executor<string[], string>("ResultAggregationExecutor")
|
||||
{
|
||||
/// <summary>
|
||||
/// Handles incoming messages from the agents and aggregates their responses.
|
||||
/// </summary>
|
||||
/// <param name="message">The messages from the parallel agents.</param>
|
||||
/// <param name="context">Workflow context for accessing workflow services and adding events.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.
|
||||
/// The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>A task representing the asynchronous operation.</returns>
|
||||
public override ValueTask<string> HandleAsync(string[] message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Aggregate all responses from parallel executors
|
||||
string aggregatedResponse = string.Join("\n---\n", message);
|
||||
return ValueTask.FromResult($"Aggregated {message.Length} responses:\n{aggregatedResponse}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Azure;
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Hosting.AzureFunctions;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Azure.Functions.Worker.Builder;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using OpenAI.Chat;
|
||||
using SingleAgent;
|
||||
|
||||
// Get the Azure OpenAI endpoint and deployment name from environment variables.
|
||||
string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT")
|
||||
?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT")
|
||||
?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT is not set.");
|
||||
|
||||
// Use Azure Key Credential if provided, otherwise use Azure CLI Credential.
|
||||
string? azureOpenAiKey = System.Environment.GetEnvironmentVariable("AZURE_OPENAI_KEY");
|
||||
AzureOpenAIClient client = !string.IsNullOrEmpty(azureOpenAiKey)
|
||||
? new AzureOpenAIClient(new Uri(endpoint), new AzureKeyCredential(azureOpenAiKey))
|
||||
: new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential());
|
||||
|
||||
AIAgent physicist = client.GetChatClient(deploymentName).AsAIAgent("You are an expert in physics. You answer questions from a physics perspective.", "Physicist");
|
||||
AIAgent chemist = client.GetChatClient(deploymentName).AsAIAgent("You are an expert in chemistry. You answer questions from a chemistry perspective.", "Chemist");
|
||||
|
||||
var startExecutor = new ConcurrentStartExecutor();
|
||||
var aggregationExecutor = new ResultAggregationExecutor();
|
||||
|
||||
var workflow = new WorkflowBuilder(startExecutor)
|
||||
.WithName("ExpertReview")
|
||||
.AddFanOutEdge(startExecutor, [physicist, chemist])
|
||||
.AddFanInEdge([physicist, chemist], aggregationExecutor)
|
||||
.Build();
|
||||
|
||||
var host = FunctionsApplication.CreateBuilder(args)
|
||||
.ConfigureFunctionsWebApplication()
|
||||
.ConfigureDurableOptions(options =>
|
||||
{
|
||||
// Configure workflows
|
||||
options.Workflows.AddWorkflow(workflow);
|
||||
})
|
||||
.Build();
|
||||
|
||||
host.Run();
|
||||
@@ -0,0 +1,89 @@
|
||||
# Single Agent Sample
|
||||
|
||||
This sample demonstrates how to use the Durable Agent Framework (DAFx) to create a simple Azure Functions app that hosts a single AI agent and provides direct HTTP API access for interactive conversations.
|
||||
|
||||
## Key Concepts Demonstrated
|
||||
|
||||
- Using the Microsoft Agent Framework to define a simple AI agent with a name and instructions.
|
||||
- Registering agents with the Function app and running them using HTTP.
|
||||
- Conversation management (via session IDs) for isolated interactions.
|
||||
|
||||
## Environment Setup
|
||||
|
||||
See the [README.md](../README.md) file in the parent directory for more information on how to configure the environment, including how to install and run common sample dependencies.
|
||||
|
||||
## Running the Sample
|
||||
|
||||
With the environment setup and function app running, you can test the sample by sending an HTTP request to the agent endpoint.
|
||||
|
||||
You can use the `demo.http` file to send a message to the agent, or a command line tool like `curl` as shown below:
|
||||
|
||||
Bash (Linux/macOS/WSL):
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:7071/api/agents/Joker/run \
|
||||
-H "Content-Type: text/plain" \
|
||||
-d "Tell me a joke about a pirate."
|
||||
```
|
||||
|
||||
PowerShell:
|
||||
|
||||
```powershell
|
||||
Invoke-RestMethod -Method Post `
|
||||
-Uri http://localhost:7071/api/agents/Joker/run `
|
||||
-ContentType text/plain `
|
||||
-Body "Tell me a joke about a pirate."
|
||||
```
|
||||
|
||||
You can also send JSON requests:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:7071/api/agents/Joker/run \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Accept: application/json" \
|
||||
-d '{"message": "Tell me a joke about a pirate."}'
|
||||
```
|
||||
|
||||
To continue a conversation, include the `thread_id` in the query string or JSON body:
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:7071/api/agents/Joker/run?thread_id=your-thread-id" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Accept: application/json" \
|
||||
-d '{"message": "Tell me another one."}'
|
||||
```
|
||||
|
||||
The response from the agent will be displayed in the terminal where you ran `func start`. The expected `text/plain` output will look something like:
|
||||
|
||||
```text
|
||||
Why don't pirates ever learn the alphabet? Because they always get stuck at "C"!
|
||||
```
|
||||
|
||||
The expected `application/json` output will look something like:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": 200,
|
||||
"thread_id": "ee6e47a0-f24b-40b1-ade8-16fcebb9eb40",
|
||||
"response": {
|
||||
"Messages": [
|
||||
{
|
||||
"AuthorName": "Joker",
|
||||
"CreatedAt": "2025-11-11T12:00:00.0000000Z",
|
||||
"Role": "assistant",
|
||||
"Contents": [
|
||||
{
|
||||
"Type": "text",
|
||||
"Text": "Why don't pirates ever learn the alphabet? Because they always get stuck at 'C'!"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Usage": {
|
||||
"InputTokenCount": 78,
|
||||
"OutputTokenCount": 36,
|
||||
"TotalTokenCount": 114
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,27 @@
|
||||
//// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
//using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
//namespace SingleAgent;
|
||||
|
||||
///// <summary>
|
||||
///// Routes survey responses to appropriate teams based on rating and category.
|
||||
///// </summary>
|
||||
//public sealed class ResponseRouterExecutor() : Executor<string, string>("ResponseRouterExecutor")
|
||||
//{
|
||||
// public override ValueTask<string> HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
// {
|
||||
// if (message.Contains("billing", StringComparison.OrdinalIgnoreCase))
|
||||
// {
|
||||
// return ValueTask.FromResult("Routed to Billing Team");
|
||||
// }
|
||||
// else if (message.Contains("technical", StringComparison.OrdinalIgnoreCase))
|
||||
// {
|
||||
// return ValueTask.FromResult("Routed to Technical Support Team");
|
||||
// }
|
||||
// else
|
||||
// {
|
||||
// return ValueTask.FromResult("Routed to General Support Team");
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
@@ -0,0 +1,82 @@
|
||||
//// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
//using System.Text.Json;
|
||||
//using System.Text.RegularExpressions;
|
||||
//using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
//namespace SingleAgent;
|
||||
|
||||
///// <summary>
|
||||
///// This executor parses survey responses and produces structured output.
|
||||
///// Example input: "Rating: 8. The app is good but checkout process is confusing."
|
||||
///// </summary>
|
||||
//[System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated by workflow framework")]
|
||||
//internal sealed partial class SurveyResponseParserExecutor() : Executor<string, string>("SurveyResponseParserExecutor")
|
||||
//{
|
||||
// private static readonly JsonSerializerOptions s_jsonOptions = new()
|
||||
// {
|
||||
// WriteIndented = true
|
||||
// };
|
||||
|
||||
// [GeneratedRegex(@"Rating:\s*(\d+)", RegexOptions.IgnoreCase)]
|
||||
// private static partial Regex RatingRegex();
|
||||
|
||||
// public override ValueTask<string> HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
// {
|
||||
// SurveyResponse response = this.ParseSurveyResponse(message);
|
||||
// string jsonResult = JsonSerializer.Serialize(response, s_jsonOptions);
|
||||
// return ValueTask.FromResult(jsonResult);
|
||||
// }
|
||||
|
||||
// private SurveyResponse ParseSurveyResponse(string message)
|
||||
// {
|
||||
// // Parse the message to extract rating and comment
|
||||
// int? rating = null;
|
||||
// string comment = message;
|
||||
|
||||
// // Try to extract rating using pattern "Rating: {number}"
|
||||
// Match ratingMatch = RatingRegex().Match(message);
|
||||
// if (ratingMatch.Success && int.TryParse(ratingMatch.Groups[1].Value, out int parsedRating))
|
||||
// {
|
||||
// rating = parsedRating;
|
||||
|
||||
// // Remove the rating part from the message to get the comment
|
||||
// // Find the position after the rating number
|
||||
// int ratingEndIndex = ratingMatch.Index + ratingMatch.Length;
|
||||
|
||||
// // Skip any separators (period, comma, dash, etc.) and whitespace
|
||||
// while (ratingEndIndex < message.Length &&
|
||||
// (char.IsWhiteSpace(message[ratingEndIndex]) ||
|
||||
// message[ratingEndIndex] == '.' ||
|
||||
// message[ratingEndIndex] == ',' ||
|
||||
// message[ratingEndIndex] == '-'))
|
||||
// {
|
||||
// ratingEndIndex++;
|
||||
// }
|
||||
|
||||
// if (ratingEndIndex < message.Length)
|
||||
// {
|
||||
// comment = message[ratingEndIndex..].Trim();
|
||||
// }
|
||||
// else
|
||||
// {
|
||||
// comment = string.Empty;
|
||||
// }
|
||||
// }
|
||||
|
||||
// // Create and return the structured response
|
||||
// return new SurveyResponse
|
||||
// {
|
||||
// Rating = rating,
|
||||
// Comment = comment,
|
||||
// OriginalMessage = message
|
||||
// };
|
||||
// }
|
||||
|
||||
// private sealed class SurveyResponse
|
||||
// {
|
||||
// public int? Rating { get; set; }
|
||||
// public string Comment { get; set; } = string.Empty;
|
||||
// public string OriginalMessage { get; set; } = string.Empty;
|
||||
// }
|
||||
//}
|
||||
@@ -0,0 +1,8 @@
|
||||
# Default endpoint address for local testing
|
||||
@authority=http://localhost:7071
|
||||
|
||||
### Start the workflow
|
||||
POST {{authority}}/api/workflows/ExpertReview/run
|
||||
Content-Type: text/plain
|
||||
|
||||
What is temperature?
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<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>SingleAgent</AssemblyName>
|
||||
<RootNamespace>SingleAgent</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" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<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" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Hosting.AzureFunctions\Microsoft.Agents.AI.Hosting.AzureFunctions.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,87 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates how to use durable state management in Azure Functions workflows.
|
||||
// The OrderIdParserExecutor writes a value to shared state, and the EmailSenderExecutor reads it back.
|
||||
// The state is persisted durably using Durable Entities behind the scenes.
|
||||
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
namespace SingleAgent;
|
||||
|
||||
/// <summary>
|
||||
/// Constants for shared state scopes used across executors.
|
||||
/// </summary>
|
||||
internal static class SharedStateConstants
|
||||
{
|
||||
public const string MessageScope = "MessageState";
|
||||
public const string ProcessedMessageKey = "ProcessedMessage";
|
||||
}
|
||||
|
||||
public sealed class Order
|
||||
{
|
||||
public Order(string id, decimal amount)
|
||||
{
|
||||
this.Id = id;
|
||||
this.Amount = amount;
|
||||
}
|
||||
public string Id { get; }
|
||||
public decimal Amount { get; }
|
||||
public string? PaymentReferenceNumber { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// First executor that processes a message and stores the result in shared state.
|
||||
/// </summary>
|
||||
internal sealed class OrderIdParserExecutor() : Executor<string, Order>("OrderIdParserExecutor")
|
||||
{
|
||||
public override async ValueTask<Order> HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Process the message
|
||||
string processedMessage = $"Processed: {message}";
|
||||
|
||||
// Store the processed message in shared state for the next executor
|
||||
await context.QueueStateUpdateAsync(
|
||||
SharedStateConstants.ProcessedMessageKey,
|
||||
processedMessage,
|
||||
SharedStateConstants.MessageScope,
|
||||
cancellationToken);
|
||||
|
||||
return GetOrder(message);
|
||||
}
|
||||
|
||||
private static Order GetOrder(string id)
|
||||
{
|
||||
// Simulate fetching order details
|
||||
return new Order(id, 100.0m);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Second executor that reads the shared state and appends to the message.
|
||||
/// </summary>
|
||||
internal sealed class EmailSenderExecutor() : Executor<Order, string>("EmailSenderExecutor")
|
||||
{
|
||||
public override async ValueTask<string> HandleAsync(Order message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Read the processed message from shared state (written by OrderIdParserExecutor)
|
||||
string? storedMessage = await context.ReadStateAsync<string>(
|
||||
SharedStateConstants.ProcessedMessageKey,
|
||||
SharedStateConstants.MessageScope,
|
||||
cancellationToken);
|
||||
|
||||
// Combine with the input message
|
||||
return storedMessage is not null
|
||||
? $"From state: [{storedMessage}] | Input: [{message.Id}]"
|
||||
: $"No state found | Input: [{message.Id}]";
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class PaymentProcesserExecutor() : Executor<Order, Order>("PaymentProcesserExecutor")
|
||||
{
|
||||
public override async ValueTask<Order> HandleAsync(Order message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Call payment gateway.
|
||||
message.PaymentReferenceNumber = Guid.NewGuid().ToString().Substring(0, 4);
|
||||
return message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Hosting.AzureFunctions;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Azure.Functions.Worker.Builder;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using SingleAgent;
|
||||
|
||||
// Set up an AI agent following the standard Microsoft Agent Framework pattern.
|
||||
|
||||
OrderIdParserExecutor orderParser = new();
|
||||
PaymentProcesserExecutor paymentProcessor = new();
|
||||
EmailSenderExecutor emailSender = new();
|
||||
|
||||
WorkflowBuilder builder = new(orderParser);
|
||||
builder.AddEdge(orderParser, paymentProcessor);
|
||||
builder.AddEdge(paymentProcessor, emailSender).WithOutputFrom(emailSender);
|
||||
var workflow = builder.WithName("ProcessOrder").Build();
|
||||
|
||||
FunctionsApplication.CreateBuilder(args)
|
||||
.ConfigureFunctionsWebApplication()
|
||||
.ConfigureDurableOptions(options => options.Workflows.AddWorkflow(workflow))
|
||||
.Build().Run();
|
||||
@@ -0,0 +1,89 @@
|
||||
# Single Agent Sample
|
||||
|
||||
This sample demonstrates how to use the Durable Agent Framework (DAFx) to create a simple Azure Functions app that hosts a single AI agent and provides direct HTTP API access for interactive conversations.
|
||||
|
||||
## Key Concepts Demonstrated
|
||||
|
||||
- Using the Microsoft Agent Framework to define a simple AI agent with a name and instructions.
|
||||
- Registering agents with the Function app and running them using HTTP.
|
||||
- Conversation management (via session IDs) for isolated interactions.
|
||||
|
||||
## Environment Setup
|
||||
|
||||
See the [README.md](../README.md) file in the parent directory for more information on how to configure the environment, including how to install and run common sample dependencies.
|
||||
|
||||
## Running the Sample
|
||||
|
||||
With the environment setup and function app running, you can test the sample by sending an HTTP request to the agent endpoint.
|
||||
|
||||
You can use the `demo.http` file to send a message to the agent, or a command line tool like `curl` as shown below:
|
||||
|
||||
Bash (Linux/macOS/WSL):
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:7071/api/agents/Joker/run \
|
||||
-H "Content-Type: text/plain" \
|
||||
-d "Tell me a joke about a pirate."
|
||||
```
|
||||
|
||||
PowerShell:
|
||||
|
||||
```powershell
|
||||
Invoke-RestMethod -Method Post `
|
||||
-Uri http://localhost:7071/api/agents/Joker/run `
|
||||
-ContentType text/plain `
|
||||
-Body "Tell me a joke about a pirate."
|
||||
```
|
||||
|
||||
You can also send JSON requests:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:7071/api/agents/Joker/run \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Accept: application/json" \
|
||||
-d '{"message": "Tell me a joke about a pirate."}'
|
||||
```
|
||||
|
||||
To continue a conversation, include the `thread_id` in the query string or JSON body:
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:7071/api/agents/Joker/run?thread_id=your-thread-id" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Accept: application/json" \
|
||||
-d '{"message": "Tell me another one."}'
|
||||
```
|
||||
|
||||
The response from the agent will be displayed in the terminal where you ran `func start`. The expected `text/plain` output will look something like:
|
||||
|
||||
```text
|
||||
Why don't pirates ever learn the alphabet? Because they always get stuck at "C"!
|
||||
```
|
||||
|
||||
The expected `application/json` output will look something like:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": 200,
|
||||
"thread_id": "ee6e47a0-f24b-40b1-ade8-16fcebb9eb40",
|
||||
"response": {
|
||||
"Messages": [
|
||||
{
|
||||
"AuthorName": "Joker",
|
||||
"CreatedAt": "2025-11-11T12:00:00.0000000Z",
|
||||
"Role": "assistant",
|
||||
"Contents": [
|
||||
{
|
||||
"Type": "text",
|
||||
"Text": "Why don't pirates ever learn the alphabet? Because they always get stuck at 'C'!"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Usage": {
|
||||
"InputTokenCount": 78,
|
||||
"OutputTokenCount": 36,
|
||||
"TotalTokenCount": 114
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,14 @@
|
||||
# Default endpoint address for local testing
|
||||
@authority=http://localhost:7071
|
||||
|
||||
### Start the workflow
|
||||
POST {{authority}}/api/workflows/ProcessOrder/run
|
||||
Content-Type: text/plain
|
||||
|
||||
123
|
||||
|
||||
### Start second workflow
|
||||
POST {{authority}}/api/workflows/ProcessOrder/run
|
||||
Content-Type: text/plain
|
||||
|
||||
456
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<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>SingleAgent</AssemblyName>
|
||||
<RootNamespace>SingleAgent</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="local.settings.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
<CopyToPublishDirectory>Never</CopyToPublishDirectory>
|
||||
</None>
|
||||
</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" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<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" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Hosting.AzureFunctions\Microsoft.Agents.AI.Hosting.AzureFunctions.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,98 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates how to use durable state management in Azure Functions workflows.
|
||||
// The OrderIdParserExecutor writes a value to shared state, and the FraudValidation reads it back.
|
||||
// The state is persisted durably using Durable Entities behind the scenes.
|
||||
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
namespace SingleAgent;
|
||||
|
||||
/// <summary>
|
||||
/// Constants for shared state scopes used across executors.
|
||||
/// </summary>
|
||||
internal static class SharedStateConstants
|
||||
{
|
||||
public const string MessageScope = "MessageState";
|
||||
public const string ProcessedMessageKey = "ProcessedMessage";
|
||||
}
|
||||
|
||||
internal sealed class Order
|
||||
{
|
||||
public Order(string id, decimal amount)
|
||||
{
|
||||
this.Id = id;
|
||||
this.Amount = amount;
|
||||
}
|
||||
public string Id { get; }
|
||||
public decimal Amount { get; }
|
||||
public Customer? Customer { get; set; }
|
||||
public string? PaymentReferenceNumber { get; set; }
|
||||
}
|
||||
|
||||
public sealed record Customer(int Id, string Name, bool IsBlocked);
|
||||
|
||||
internal sealed class OrderIdParserExecutor() : Executor<string, Order>("OrderIdParserExecutor")
|
||||
{
|
||||
public override async ValueTask<Order> HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return GetOrder(message);
|
||||
}
|
||||
|
||||
private static Order GetOrder(string id)
|
||||
{
|
||||
// Simulate fetching order details
|
||||
return new Order(id, 100.0m);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class OrderEnrich() : Executor<Order, Order>("EnrichOrder")
|
||||
{
|
||||
public override async ValueTask<Order> HandleAsync(Order message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
message.Customer = GetCustomerForOrder(message.Id);
|
||||
return message;
|
||||
}
|
||||
|
||||
private static Customer GetCustomerForOrder(string orderId)
|
||||
{
|
||||
if (orderId.Contains('B'))
|
||||
{
|
||||
return new Customer(101, "George", true);
|
||||
}
|
||||
|
||||
return new Customer(201, "Jerry", false);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class PaymentProcesserExecutor() : Executor<Order, Order>("PaymentProcesserExecutor")
|
||||
{
|
||||
public override async ValueTask<Order> HandleAsync(Order message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Call payment gateway.
|
||||
message.PaymentReferenceNumber = Guid.NewGuid().ToString().Substring(0, 4);
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class NotifyFraudExecutor() : Executor<Order, string>("NotifyFraud")
|
||||
{
|
||||
public override async ValueTask<string> HandleAsync(Order message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Notify fraud team.
|
||||
return $"Order {message.Id} flagged as fraudulent for customer {message.Customer?.Name}.";
|
||||
}
|
||||
}
|
||||
|
||||
internal static class OrderRouteConditions
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns a condition that evaluates to true when the customer is blocked.
|
||||
/// </summary>
|
||||
internal static Func<Order?, bool> WhenBlocked() => order => order?.Customer?.IsBlocked == true;
|
||||
|
||||
/// <summary>
|
||||
/// Returns a condition that evaluates to true when the customer is not blocked.
|
||||
/// </summary>
|
||||
internal static Func<Order?, bool> WhenNotBlocked() => order => order?.Customer?.IsBlocked == false;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Hosting.AzureFunctions;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Azure.Functions.Worker.Builder;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using SingleAgent;
|
||||
|
||||
OrderIdParserExecutor orderParser = new();
|
||||
OrderEnrich orderEnrich = new();
|
||||
PaymentProcesserExecutor paymentProcessor = new();
|
||||
NotifyFraudExecutor notifyFraud = new();
|
||||
|
||||
WorkflowBuilder builder = new(orderParser);
|
||||
builder
|
||||
.AddEdge(orderParser, orderEnrich)
|
||||
.AddEdge(orderEnrich, notifyFraud, condition: OrderRouteConditions.WhenBlocked())
|
||||
.AddEdge(orderEnrich, paymentProcessor, condition: OrderRouteConditions.WhenNotBlocked());
|
||||
|
||||
var workflow = builder.WithName("AuditOrder").Build();
|
||||
|
||||
FunctionsApplication.CreateBuilder(args)
|
||||
.ConfigureFunctionsWebApplication()
|
||||
.ConfigureDurableOptions(options => options.Workflows.AddWorkflow(workflow))
|
||||
.Build()
|
||||
.Run();
|
||||
@@ -0,0 +1,89 @@
|
||||
# Single Agent Sample
|
||||
|
||||
This sample demonstrates how to use the Durable Agent Framework (DAFx) to create a simple Azure Functions app that hosts a single AI agent and provides direct HTTP API access for interactive conversations.
|
||||
|
||||
## Key Concepts Demonstrated
|
||||
|
||||
- Using the Microsoft Agent Framework to define a simple AI agent with a name and instructions.
|
||||
- Registering agents with the Function app and running them using HTTP.
|
||||
- Conversation management (via session IDs) for isolated interactions.
|
||||
|
||||
## Environment Setup
|
||||
|
||||
See the [README.md](../README.md) file in the parent directory for more information on how to configure the environment, including how to install and run common sample dependencies.
|
||||
|
||||
## Running the Sample
|
||||
|
||||
With the environment setup and function app running, you can test the sample by sending an HTTP request to the agent endpoint.
|
||||
|
||||
You can use the `demo.http` file to send a message to the agent, or a command line tool like `curl` as shown below:
|
||||
|
||||
Bash (Linux/macOS/WSL):
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:7071/api/agents/Joker/run \
|
||||
-H "Content-Type: text/plain" \
|
||||
-d "Tell me a joke about a pirate."
|
||||
```
|
||||
|
||||
PowerShell:
|
||||
|
||||
```powershell
|
||||
Invoke-RestMethod -Method Post `
|
||||
-Uri http://localhost:7071/api/agents/Joker/run `
|
||||
-ContentType text/plain `
|
||||
-Body "Tell me a joke about a pirate."
|
||||
```
|
||||
|
||||
You can also send JSON requests:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:7071/api/agents/Joker/run \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Accept: application/json" \
|
||||
-d '{"message": "Tell me a joke about a pirate."}'
|
||||
```
|
||||
|
||||
To continue a conversation, include the `thread_id` in the query string or JSON body:
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:7071/api/agents/Joker/run?thread_id=your-thread-id" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Accept: application/json" \
|
||||
-d '{"message": "Tell me another one."}'
|
||||
```
|
||||
|
||||
The response from the agent will be displayed in the terminal where you ran `func start`. The expected `text/plain` output will look something like:
|
||||
|
||||
```text
|
||||
Why don't pirates ever learn the alphabet? Because they always get stuck at "C"!
|
||||
```
|
||||
|
||||
The expected `application/json` output will look something like:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": 200,
|
||||
"thread_id": "ee6e47a0-f24b-40b1-ade8-16fcebb9eb40",
|
||||
"response": {
|
||||
"Messages": [
|
||||
{
|
||||
"AuthorName": "Joker",
|
||||
"CreatedAt": "2025-11-11T12:00:00.0000000Z",
|
||||
"Role": "assistant",
|
||||
"Contents": [
|
||||
{
|
||||
"Type": "text",
|
||||
"Text": "Why don't pirates ever learn the alphabet? Because they always get stuck at 'C'!"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Usage": {
|
||||
"InputTokenCount": 78,
|
||||
"OutputTokenCount": 36,
|
||||
"TotalTokenCount": 114
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,14 @@
|
||||
# Default endpoint address for local testing
|
||||
@authority=http://localhost:7071
|
||||
|
||||
### Start the workflow
|
||||
POST {{authority}}/api/workflows/ProcessOrder/run
|
||||
Content-Type: text/plain
|
||||
|
||||
B123
|
||||
|
||||
### Start second workflow
|
||||
POST {{authority}}/api/workflows/ProcessOrder/run
|
||||
Content-Type: text/plain
|
||||
|
||||
456
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<OutputType>Exe</OutputType>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AssemblyName>SingleWorkflow</AssemblyName>
|
||||
<RootNamespace>SingleAgent</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" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,128 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
namespace SingleAgent;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an order in the system.
|
||||
/// </summary>
|
||||
internal sealed class Order
|
||||
{
|
||||
public required string Id { get; set; }
|
||||
public DateTime OrderDate { get; set; }
|
||||
public bool IsCancelled { get; set; }
|
||||
public required Customer Customer { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a customer associated with an order.
|
||||
/// </summary>
|
||||
internal sealed class Customer
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Email { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up an order by its ID.
|
||||
/// This activity simulates a database lookup with a 2 second delay.
|
||||
/// </summary>
|
||||
internal sealed class OrderLookup() : Executor<string, Order>("OrderLookup")
|
||||
{
|
||||
public override async ValueTask<Order> HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Log that this activity is executing (not replaying)
|
||||
Console.WriteLine();
|
||||
Console.ForegroundColor = ConsoleColor.Magenta;
|
||||
Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐");
|
||||
Console.WriteLine($"│ [Activity] OrderLookup: Starting lookup for order '{message}'");
|
||||
Console.ResetColor();
|
||||
|
||||
// Simulate database lookup with delay
|
||||
await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken);
|
||||
|
||||
Order order = new()
|
||||
{
|
||||
Id = message,
|
||||
OrderDate = DateTime.UtcNow.AddDays(-1),
|
||||
IsCancelled = false,
|
||||
Customer = new Customer { Name = "Jerry", Email = "jerry@example.com" }
|
||||
};
|
||||
|
||||
Console.ForegroundColor = ConsoleColor.Magenta;
|
||||
Console.WriteLine($"│ [Activity] OrderLookup: Found order '{message}' for customer '{order.Customer.Name}'");
|
||||
Console.WriteLine("└─────────────────────────────────────────────────────────────────┘");
|
||||
Console.ResetColor();
|
||||
|
||||
return order;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cancels an order.
|
||||
/// This activity simulates a slow cancellation process with a 5 second delay.
|
||||
/// Try pressing Ctrl+C during this activity to see durability in action!
|
||||
/// </summary>
|
||||
internal sealed class OrderCancel() : Executor<Order, Order>("OrderCancel")
|
||||
{
|
||||
public override async ValueTask<Order> HandleAsync(Order message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Log that this activity is executing (not replaying)
|
||||
Console.WriteLine();
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐");
|
||||
Console.WriteLine($"│ [Activity] OrderCancel: Starting cancellation for order '{message.Id}'");
|
||||
Console.WriteLine("│ [Activity] OrderCancel: ⚠️ This takes 5 seconds - try Ctrl+C!");
|
||||
Console.ResetColor();
|
||||
|
||||
// Simulate a slow cancellation process (e.g., calling external payment system)
|
||||
// This is where you can kill the process to test durability
|
||||
for (int i = 1; i <= 10; i++)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
|
||||
Console.ForegroundColor = ConsoleColor.DarkYellow;
|
||||
Console.WriteLine($"│ [Activity] OrderCancel: Processing... {i}/10 seconds");
|
||||
Console.ResetColor();
|
||||
}
|
||||
|
||||
// Mark the order as cancelled
|
||||
message.IsCancelled = true;
|
||||
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.WriteLine($"│ [Activity] OrderCancel: ✓ Order '{message.Id}' has been cancelled");
|
||||
Console.WriteLine("└─────────────────────────────────────────────────────────────────┘");
|
||||
Console.ResetColor();
|
||||
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a cancellation confirmation email to the customer.
|
||||
/// This activity simulates sending an email with a 1 second delay.
|
||||
/// </summary>
|
||||
internal sealed class SendEmail() : Executor<Order, string>("SendEmail")
|
||||
{
|
||||
public override async ValueTask<string> HandleAsync(Order message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Log that this activity is executing (not replaying)
|
||||
Console.WriteLine();
|
||||
Console.ForegroundColor = ConsoleColor.Cyan;
|
||||
Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐");
|
||||
Console.WriteLine($"│ [Activity] SendEmail: Sending email to '{message.Customer.Email}'...");
|
||||
Console.ResetColor();
|
||||
|
||||
// Simulate email sending delay
|
||||
await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
|
||||
|
||||
string result = $"Cancellation email sent to {message.Customer.Email} for order {message.Id}.";
|
||||
|
||||
Console.ForegroundColor = ConsoleColor.Cyan;
|
||||
Console.WriteLine("│ [Activity] SendEmail: ✓ Email sent successfully!");
|
||||
Console.WriteLine("└─────────────────────────────────────────────────────────────────┘");
|
||||
Console.ResetColor();
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates how to run a workflow as a durable orchestration from a console application.
|
||||
// The workflow consists of three executors: OrderLookup -> OrderCancel -> SendEmail.
|
||||
// It uses the DurableExecutionEnvironment which is injected via DI.
|
||||
//
|
||||
// DURABILITY DEMONSTRATION:
|
||||
// - Each activity has artificial delays to simulate real-world operations
|
||||
// - Stop the app (Ctrl+C or stop debugging) during the OrderCancel activity (5 seconds)
|
||||
// - Restart the application - the workflow will automatically resume!
|
||||
// - The Durable Task Framework will skip already-completed activities (OrderLookup)
|
||||
// and continue from where it left off (OrderCancel)
|
||||
|
||||
using Microsoft.Agents.AI.DurableTask;
|
||||
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 SingleAgent;
|
||||
|
||||
// Get DTS connection string from environment variable
|
||||
string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING")
|
||||
?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None";
|
||||
|
||||
// Define executors for the workflow
|
||||
OrderLookup orderLookup = new();
|
||||
OrderCancel orderCancel = new();
|
||||
SendEmail sendEmail = new();
|
||||
|
||||
// Build the CancelOrder workflow: OrderLookup -> OrderCancel -> SendEmail
|
||||
Workflow cancelOrder = new WorkflowBuilder(orderLookup)
|
||||
.WithName("CancelOrder")
|
||||
.WithDescription("Cancel an order and notify the customer")
|
||||
.AddEdge(orderLookup, orderCancel)
|
||||
.AddEdge(orderCancel, sendEmail)
|
||||
.Build();
|
||||
|
||||
IHost host = Host.CreateDefaultBuilder(args)
|
||||
.ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Warning))
|
||||
.ConfigureServices(services =>
|
||||
{
|
||||
services.ConfigureDurableWorkflows(
|
||||
options => options.Workflows.AddWorkflow(cancelOrder),
|
||||
workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString),
|
||||
clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString));
|
||||
})
|
||||
.Build();
|
||||
|
||||
await host.StartAsync();
|
||||
|
||||
// Get the IWorkflowClient from DI - no need to manually resolve DurableTaskClient
|
||||
IWorkflowClient workflowClient = host.Services.GetRequiredService<IWorkflowClient>();
|
||||
|
||||
Console.WriteLine("Durable Workflow Sample");
|
||||
Console.WriteLine("Workflow: OrderLookup (2s) -> OrderCancel (5s) -> SendEmail (1s)");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("TIP: Stop the app during OrderCancel to test durability.");
|
||||
Console.WriteLine(" Restart - it will resume from where it left off.");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Checking for pending workflows...");
|
||||
await Task.Delay(TimeSpan.FromSeconds(2));
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Enter an order ID (or 'exit'):");
|
||||
|
||||
while (true)
|
||||
{
|
||||
Console.Write("> ");
|
||||
string? input = Console.ReadLine();
|
||||
if (string.IsNullOrWhiteSpace(input) || input.Equals("exit", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await StartNewWorkflowAsync(input, cancelOrder, workflowClient);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error: {ex.Message}");
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
await host.StopAsync();
|
||||
|
||||
// Start a new workflow using IWorkflowClient (no DurableTaskClient needed)
|
||||
async Task StartNewWorkflowAsync(string orderId, Workflow workflow, IWorkflowClient client)
|
||||
{
|
||||
Console.WriteLine($"Starting workflow for order '{orderId}'...");
|
||||
|
||||
// RunAsync returns IRun, cast to DurableRun for durable-specific features like WaitForCompletionAsync
|
||||
await using DurableRun run = (DurableRun)await client.RunAsync(workflow, orderId);
|
||||
Console.WriteLine($"Instance ID: {run.InstanceId}");
|
||||
|
||||
try
|
||||
{
|
||||
string? result = await run.WaitForCompletionAsync();
|
||||
Console.WriteLine($"Completed: {result}");
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
Console.WriteLine($"Failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
# Single Workflow Console Sample
|
||||
|
||||
This sample demonstrates how to run a workflow as a durable orchestration from a console application using the Durable Task Framework. It showcases the **durability** aspect - if the process crashes mid-execution, the workflow can be resumed without re-executing completed activities.
|
||||
|
||||
## Overview
|
||||
|
||||
The sample implements an order cancellation workflow with three executors, each with artificial delays to simulate real-world operations:
|
||||
|
||||
1. **OrderLookup** (2 seconds) - Looks up an order by its ID
|
||||
2. **OrderCancel** (5 seconds) - Marks the order as cancelled
|
||||
3. **SendEmail** (1 second) - Sends a cancellation confirmation email
|
||||
|
||||
## Durability Demonstration
|
||||
|
||||
The key feature of Durable Task Framework is **durability**:
|
||||
|
||||
|
||||
- **Activity results are persisted**: When an activity completes, its result is saved
|
||||
- **Orchestrations are replayed**: On restart, the orchestration replays from the beginning
|
||||
- **Completed activities are skipped**: The framework uses cached results for completed activities
|
||||
- **Failed activities are retried**: If an activity was interrupted, it runs again
|
||||
- **Automatic resume**: When the worker starts, it automatically picks up any pending work!
|
||||
|
||||
### Try It Yourself
|
||||
|
||||
1. Start the application and enter an order ID (e.g., `12345`)
|
||||
2. Stop the app (Ctrl+C or stop debugging) during the `OrderCancel` activity (5 seconds)
|
||||
3. Restart the application
|
||||
4. **Watch for automatic resume!** The worker automatically picks up the interrupted workflow
|
||||
5. Observe that `OrderLookup` is NOT re-executed (its result was cached)
|
||||
6. `OrderCancel` restarts from the beginning (it didn't complete)
|
||||
7. `SendEmail` runs after `OrderCancel` completes
|
||||
|
||||
The durability is completely automatic - no manual intervention needed!
|
||||
|
||||
## Workflow Flow
|
||||
|
||||
```
|
||||
User Input (Order ID)
|
||||
?
|
||||
?
|
||||
???????????????????
|
||||
? OrderLookup ? ? 2 second delay (database lookup)
|
||||
? (2 seconds) ?
|
||||
???????????????????
|
||||
?
|
||||
?
|
||||
???????????????????
|
||||
? OrderCancel ? ? 5 second delay - TRY INTERRUPTING HERE!
|
||||
? (5 seconds) ?
|
||||
???????????????????
|
||||
?
|
||||
?
|
||||
???????????????????
|
||||
? SendEmail ? ? 1 second delay (email sending)
|
||||
? (1 second) ?
|
||||
???????????????????
|
||||
?
|
||||
?
|
||||
Result
|
||||
```
|
||||
|
||||
## Key Concepts Demonstrated
|
||||
|
||||
- **ConfigureDurableWorkflows** - Simplified API for registering workflows
|
||||
- **DurableExecution.RunAsync** - Start a new workflow (similar to InProcessExecution)
|
||||
- **DurableRun** - Handle to monitor and interact with a running workflow
|
||||
- **Automatic Resume** - Interrupted workflows continue automatically on restart
|
||||
|
||||
## Environment Setup
|
||||
|
||||
See the [README.md](../README.md) file in the parent directory for more information on how to configure the environment, including how to install and run common sample dependencies.
|
||||
|
||||
## Running the Sample
|
||||
|
||||
With the environment setup, you can run the sample:
|
||||
|
||||
```bash
|
||||
cd dotnet/samples/DurableAgents/ConsoleApps/08_SingleWorkflow
|
||||
dotnet run --framework net10.0
|
||||
```
|
||||
|
||||
### Sample Session
|
||||
|
||||
```text
|
||||
??????????????????????????????????????????????????????????????????????
|
||||
? Durable Workflow Console Sample ?
|
||||
??????????????????????????????????????????????????????????????????????
|
||||
? This sample demonstrates durability in workflows. ?
|
||||
? Workflow: OrderLookup (2s) -> OrderCancel (5s) -> SendEmail (1s) ?
|
||||
??????????????????????????????????????????????????????????????????????
|
||||
|
||||
?? TIP: Stop the app during OrderCancel (5 seconds) to test durability!
|
||||
Restart the app - it will automatically resume from where it left off.
|
||||
|
||||
? Checking for pending workflows...
|
||||
|
||||
Enter an order ID to start a new workflow (or 'exit' to quit):
|
||||
|
||||
Order ID: 12345
|
||||
|
||||
Starting workflow for order '12345'...
|
||||
Instance ID: abc123-def456-...
|
||||
|
||||
???????????????????????????????????????????????????????????????????
|
||||
? [Activity] OrderLookup: Starting lookup for order '12345'
|
||||
? [Activity] OrderLookup: Found order '12345' for customer 'Jerry'
|
||||
???????????????????????????????????????????????????????????????????
|
||||
|
||||
???????????????????????????????????????????????????????????????????
|
||||
? [Activity] OrderCancel: Starting cancellation for order '12345'
|
||||
? [Activity] OrderCancel: ?? This takes 5 seconds - try Ctrl+C!
|
||||
? [Activity] OrderCancel: Processing... 1/5 seconds
|
||||
? [Activity] OrderCancel: Processing... 2/5 seconds
|
||||
^C <-- User stops the app here
|
||||
|
||||
[After restart...]
|
||||
|
||||
? Checking for pending workflows...
|
||||
|
||||
???????????????????????????????????????????????????????????????????
|
||||
? [Activity] OrderCancel: Starting cancellation for order '12345' <-- Auto-resumed!
|
||||
? [Activity] OrderCancel: ?? This takes 5 seconds - try Ctrl+C!
|
||||
? [Activity] OrderCancel: Processing... 1/5 seconds
|
||||
...
|
||||
? [Activity] OrderCancel: ? Order '12345' has been cancelled
|
||||
???????????????????????????????????????????????????????????????????
|
||||
|
||||
???????????????????????????????????????????????????????????????????
|
||||
? [Activity] SendEmail: Sending email to 'jerry@example.com'...
|
||||
? [Activity] SendEmail: ? Email sent successfully!
|
||||
???????????????????????????????????????????????????????????????????
|
||||
|
||||
Enter an order ID to start a new workflow (or 'exit' to quit):
|
||||
|
||||
Order ID: _
|
||||
```
|
||||
|
||||
Notice that when resumed:
|
||||
- `OrderLookup` was **NOT re-executed** (result was cached by Durable Task)
|
||||
- `OrderCancel` **restarted automatically** (it was interrupted before completing)
|
||||
- `SendEmail` ran normally after `OrderCancel` completed
|
||||
|
||||
## Viewing Workflow State
|
||||
|
||||
You can view the state of the workflow in the Durable Task Scheduler dashboard:
|
||||
|
||||
1. Open your browser and navigate to `http://localhost:8082`
|
||||
2. In the dashboard, you can view the state of the orchestration, including activity history and current state
|
||||
|
||||
## Related Samples
|
||||
|
||||
- [01_SingleAgent](../01_SingleAgent) - Single agent console sample
|
||||
- [02_AgentOrchestration_Chaining](../02_AgentOrchestration_Chaining) - Agent chaining with durable orchestration
|
||||
- [05_AgentOrchestration_HITL](../05_AgentOrchestration_HITL) - Human-in-the-loop orchestration
|
||||
- [09_Workflow](../../AzureFunctions/09_Workflow) - Azure Functions version of workflow hosting
|
||||
@@ -0,0 +1,29 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<OutputType>Exe</OutputType>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AssemblyName>SubWorkflows</AssemblyName>
|
||||
<RootNamespace>SubWorkflows</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" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,266 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
namespace SubWorkflows;
|
||||
|
||||
// ============================================
|
||||
// Order Processing Models
|
||||
// ============================================
|
||||
|
||||
/// <summary>
|
||||
/// Represents an order being processed.
|
||||
/// </summary>
|
||||
internal sealed class OrderInfo
|
||||
{
|
||||
public required string OrderId { get; set; }
|
||||
public decimal Amount { get; set; }
|
||||
public string? PaymentTransactionId { get; set; }
|
||||
public string? InventoryReservationId { get; set; }
|
||||
public string? TrackingNumber { get; set; }
|
||||
public string? Carrier { get; set; }
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Main Workflow Executors
|
||||
// ============================================
|
||||
|
||||
/// <summary>
|
||||
/// Entry point executor that receives the order ID and creates an OrderInfo object.
|
||||
/// </summary>
|
||||
internal sealed class OrderReceived() : Executor<string, OrderInfo>("OrderReceived")
|
||||
{
|
||||
public override ValueTask<OrderInfo> HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.ForegroundColor = ConsoleColor.Cyan;
|
||||
Console.WriteLine($"[OrderReceived] Processing order '{message}'");
|
||||
Console.ResetColor();
|
||||
|
||||
OrderInfo order = new()
|
||||
{
|
||||
OrderId = message,
|
||||
Amount = 99.99m // Simulated order amount
|
||||
};
|
||||
|
||||
return ValueTask.FromResult(order);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Final executor that outputs the completed order summary.
|
||||
/// </summary>
|
||||
internal sealed class OrderCompleted() : Executor<OrderInfo, string>("OrderCompleted")
|
||||
{
|
||||
public override ValueTask<string> HandleAsync(OrderInfo message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.ForegroundColor = ConsoleColor.Green;
|
||||
Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐");
|
||||
Console.WriteLine($"│ [OrderCompleted] Order '{message.OrderId}' successfully processed!");
|
||||
Console.WriteLine($"│ Payment: {message.PaymentTransactionId}");
|
||||
Console.WriteLine($"│ Inventory: {message.InventoryReservationId}");
|
||||
Console.WriteLine($"│ Shipping: {message.Carrier} - {message.TrackingNumber}");
|
||||
Console.WriteLine("└─────────────────────────────────────────────────────────────────┘");
|
||||
Console.ResetColor();
|
||||
|
||||
return ValueTask.FromResult($"Order {message.OrderId} completed. Tracking: {message.TrackingNumber}");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Payment Sub-Workflow Executors
|
||||
// ============================================
|
||||
|
||||
/// <summary>
|
||||
/// Validates payment information for an order.
|
||||
/// </summary>
|
||||
internal sealed class ValidatePayment() : Executor<OrderInfo, OrderInfo>("ValidatePayment")
|
||||
{
|
||||
public override async ValueTask<OrderInfo> HandleAsync(OrderInfo message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.WriteLine($" [Payment/ValidatePayment] Validating payment for order '{message.OrderId}'...");
|
||||
Console.ResetColor();
|
||||
|
||||
await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
|
||||
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.WriteLine($" [Payment/ValidatePayment] Payment validated for ${message.Amount}");
|
||||
Console.ResetColor();
|
||||
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Fraud Check Sub-Sub-Workflow Executors (Level 2 nesting)
|
||||
// ============================================
|
||||
|
||||
/// <summary>
|
||||
/// Analyzes transaction patterns for potential fraud.
|
||||
/// </summary>
|
||||
internal sealed class AnalyzePatterns() : Executor<OrderInfo, OrderInfo>("AnalyzePatterns")
|
||||
{
|
||||
public override async ValueTask<OrderInfo> HandleAsync(OrderInfo message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.DarkYellow;
|
||||
Console.WriteLine($" [Payment/FraudCheck/AnalyzePatterns] Analyzing patterns for order '{message.OrderId}'...");
|
||||
Console.ResetColor();
|
||||
|
||||
await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
|
||||
|
||||
Console.ForegroundColor = ConsoleColor.DarkYellow;
|
||||
Console.WriteLine(" [Payment/FraudCheck/AnalyzePatterns] ✓ Pattern analysis complete");
|
||||
Console.ResetColor();
|
||||
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates a risk score for the transaction.
|
||||
/// </summary>
|
||||
internal sealed class CalculateRiskScore() : Executor<OrderInfo, OrderInfo>("CalculateRiskScore")
|
||||
{
|
||||
public override async ValueTask<OrderInfo> HandleAsync(OrderInfo message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.DarkYellow;
|
||||
Console.WriteLine($" [Payment/FraudCheck/CalculateRiskScore] Calculating risk score for order '{message.OrderId}'...");
|
||||
Console.ResetColor();
|
||||
|
||||
await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
|
||||
|
||||
int riskScore = new Random().Next(1, 100);
|
||||
|
||||
Console.ForegroundColor = ConsoleColor.DarkYellow;
|
||||
Console.WriteLine($" [Payment/FraudCheck/CalculateRiskScore] ✓ Risk score: {riskScore}/100 (Low risk)");
|
||||
Console.ResetColor();
|
||||
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Charges the payment for an order.
|
||||
/// </summary>
|
||||
internal sealed class ChargePayment() : Executor<OrderInfo, OrderInfo>("ChargePayment")
|
||||
{
|
||||
public override async ValueTask<OrderInfo> HandleAsync(OrderInfo message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.WriteLine($" [Payment/ChargePayment] Charging ${message.Amount} for order '{message.OrderId}'...");
|
||||
Console.ResetColor();
|
||||
|
||||
await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken);
|
||||
|
||||
message.PaymentTransactionId = $"TXN-{Guid.NewGuid().ToString("N")[..8].ToUpperInvariant()}";
|
||||
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.WriteLine($" [Payment/ChargePayment] ✓ Payment processed: {message.PaymentTransactionId}");
|
||||
Console.ResetColor();
|
||||
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Inventory Sub-Workflow Executors
|
||||
// ============================================
|
||||
|
||||
/// <summary>
|
||||
/// Checks inventory availability for an order.
|
||||
/// </summary>
|
||||
internal sealed class CheckInventory() : Executor<OrderInfo, OrderInfo>("CheckInventory")
|
||||
{
|
||||
public override async ValueTask<OrderInfo> HandleAsync(OrderInfo message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.ForegroundColor = ConsoleColor.Magenta;
|
||||
Console.WriteLine($" [Inventory/CheckInventory] Checking inventory for order '{message.OrderId}'...");
|
||||
Console.ResetColor();
|
||||
|
||||
await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
|
||||
|
||||
Console.ForegroundColor = ConsoleColor.Magenta;
|
||||
Console.WriteLine(" [Inventory/CheckInventory] ✓ Items available in stock");
|
||||
Console.ResetColor();
|
||||
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reserves inventory for an order.
|
||||
/// </summary>
|
||||
internal sealed class ReserveInventory() : Executor<OrderInfo, OrderInfo>("ReserveInventory")
|
||||
{
|
||||
public override async ValueTask<OrderInfo> HandleAsync(OrderInfo message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Magenta;
|
||||
Console.WriteLine($" [Inventory/ReserveInventory] Reserving items for order '{message.OrderId}'...");
|
||||
Console.ResetColor();
|
||||
|
||||
await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken);
|
||||
|
||||
message.InventoryReservationId = $"RES-{Guid.NewGuid().ToString("N")[..8].ToUpperInvariant()}";
|
||||
|
||||
Console.ForegroundColor = ConsoleColor.Magenta;
|
||||
Console.WriteLine($" [Inventory/ReserveInventory] ✓ Reserved: {message.InventoryReservationId}");
|
||||
Console.ResetColor();
|
||||
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Shipping Sub-Workflow Executors
|
||||
// ============================================
|
||||
|
||||
/// <summary>
|
||||
/// Selects a shipping carrier for an order.
|
||||
/// </summary>
|
||||
internal sealed class SelectCarrier() : Executor<OrderInfo, OrderInfo>("SelectCarrier")
|
||||
{
|
||||
public override async ValueTask<OrderInfo> HandleAsync(OrderInfo message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.ForegroundColor = ConsoleColor.Blue;
|
||||
Console.WriteLine($" [Shipping/SelectCarrier] Selecting carrier for order '{message.OrderId}'...");
|
||||
Console.ResetColor();
|
||||
|
||||
await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
|
||||
|
||||
message.Carrier = message.Amount > 50 ? "Express" : "Standard";
|
||||
|
||||
Console.ForegroundColor = ConsoleColor.Blue;
|
||||
Console.WriteLine($" [Shipping/SelectCarrier] ✓ Selected carrier: {message.Carrier}");
|
||||
Console.ResetColor();
|
||||
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates shipment and generates tracking number.
|
||||
/// </summary>
|
||||
internal sealed class CreateShipment() : Executor<OrderInfo, OrderInfo>("CreateShipment")
|
||||
{
|
||||
public override async ValueTask<OrderInfo> HandleAsync(OrderInfo message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Blue;
|
||||
Console.WriteLine($" [Shipping/CreateShipment] Creating shipment for order '{message.OrderId}'...");
|
||||
Console.ResetColor();
|
||||
|
||||
await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken);
|
||||
|
||||
message.TrackingNumber = $"TRACK-{Guid.NewGuid().ToString("N")[..10].ToUpperInvariant()}";
|
||||
|
||||
Console.ForegroundColor = ConsoleColor.Blue;
|
||||
Console.WriteLine($" [Shipping/CreateShipment] ✓ Shipment created: {message.TrackingNumber}");
|
||||
Console.ResetColor();
|
||||
|
||||
return message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates how to use sub-workflows within a durable orchestration.
|
||||
// Sub-workflows allow you to compose complex workflows from simpler, reusable components.
|
||||
//
|
||||
// The sample implements an order processing workflow with three sub-workflows:
|
||||
// 1. PaymentProcessing - Validates and processes payment
|
||||
// - Contains a nested FraudCheck sub-workflow (Level 2 nesting)
|
||||
// 2. InventoryManagement - Checks and reserves inventory
|
||||
// 3. ShippingArrangement - Arranges shipping and generates tracking
|
||||
//
|
||||
// The FraudCheck sub-workflow demonstrates Level 2 nesting (sub-workflow within sub-workflow):
|
||||
// - AnalyzePatterns - Analyzes transaction patterns
|
||||
// - CalculateRiskScore - Calculates fraud risk score
|
||||
//
|
||||
// Each sub-workflow runs as a separate orchestration instance, visible in the DTS dashboard.
|
||||
// This provides:
|
||||
// - Modular, reusable workflow components
|
||||
// - Independent checkpointing and replay
|
||||
// - Hierarchical visualization in the dashboard (including nested sub-workflows)
|
||||
// - Failure isolation between parent and child workflows
|
||||
|
||||
using Microsoft.Agents.AI.DurableTask;
|
||||
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 SubWorkflows;
|
||||
|
||||
// Get DTS connection string from environment variable
|
||||
string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING")
|
||||
?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None";
|
||||
|
||||
// ============================================
|
||||
// Step 1: Build the Fraud Check sub-sub-workflow (Level 2 nesting)
|
||||
// ============================================
|
||||
AnalyzePatterns analyzePatterns = new();
|
||||
CalculateRiskScore calculateRiskScore = new();
|
||||
|
||||
Workflow fraudCheckWorkflow = new WorkflowBuilder(analyzePatterns)
|
||||
.WithName("SubFraudCheck")
|
||||
.WithDescription("Analyzes transaction patterns and calculates risk score")
|
||||
.AddEdge(analyzePatterns, calculateRiskScore)
|
||||
.Build();
|
||||
|
||||
// ============================================
|
||||
// Step 2: Build the Payment Processing sub-workflow (now with nested sub-workflow)
|
||||
// ============================================
|
||||
ValidatePayment validatePayment = new();
|
||||
ExecutorBinding fraudCheckExecutor = fraudCheckWorkflow.BindAsExecutor("FraudCheck");
|
||||
ChargePayment chargePayment = new();
|
||||
|
||||
Workflow paymentWorkflow = new WorkflowBuilder(validatePayment)
|
||||
.WithName("SubPaymentProcessing")
|
||||
.WithDescription("Validates and processes payment for an order")
|
||||
.AddEdge(validatePayment, fraudCheckExecutor)
|
||||
.AddEdge(fraudCheckExecutor, chargePayment)
|
||||
.Build();
|
||||
|
||||
// ============================================
|
||||
// Step 3: Build the Inventory Management sub-workflow
|
||||
// ============================================
|
||||
CheckInventory checkInventory = new();
|
||||
ReserveInventory reserveInventory = new();
|
||||
|
||||
Workflow inventoryWorkflow = new WorkflowBuilder(checkInventory)
|
||||
.WithName("SubInventoryManagement")
|
||||
.WithDescription("Checks availability and reserves inventory")
|
||||
.AddEdge(checkInventory, reserveInventory)
|
||||
.Build();
|
||||
|
||||
// ============================================
|
||||
// Step 4: Build the Shipping Arrangement sub-workflow
|
||||
// ============================================
|
||||
SelectCarrier selectCarrier = new();
|
||||
CreateShipment createShipment = new();
|
||||
|
||||
Workflow shippingWorkflow = new WorkflowBuilder(selectCarrier)
|
||||
.WithName("SubShippingArrangement")
|
||||
.WithDescription("Selects carrier and creates shipment")
|
||||
.AddEdge(selectCarrier, createShipment)
|
||||
.Build();
|
||||
|
||||
// ============================================
|
||||
// Step 5: Build the Main Order Processing workflow using sub-workflows
|
||||
// ============================================
|
||||
// Bind sub-workflows as executors for use in the main workflow
|
||||
ExecutorBinding paymentExecutor = paymentWorkflow.BindAsExecutor("Payment");
|
||||
ExecutorBinding inventoryExecutor = inventoryWorkflow.BindAsExecutor("Inventory");
|
||||
ExecutorBinding shippingExecutor = shippingWorkflow.BindAsExecutor("Shipping");
|
||||
|
||||
// Create entry and exit executors for the main workflow
|
||||
OrderReceived orderReceived = new();
|
||||
OrderCompleted orderCompleted = new();
|
||||
|
||||
// Build the main workflow: OrderReceived -> Payment -> Inventory -> Shipping -> OrderCompleted
|
||||
Workflow orderProcessingWorkflow = new WorkflowBuilder(orderReceived)
|
||||
.WithName("OrderProcessing")
|
||||
.WithDescription("Processes an order through payment, inventory, and shipping")
|
||||
.AddEdge(orderReceived, paymentExecutor)
|
||||
.AddEdge(paymentExecutor, inventoryExecutor)
|
||||
.AddEdge(inventoryExecutor, shippingExecutor)
|
||||
.AddEdge(shippingExecutor, orderCompleted)
|
||||
.Build();
|
||||
|
||||
// ============================================
|
||||
// Step 6: Configure and start the host
|
||||
// ============================================
|
||||
IHost host = Host.CreateDefaultBuilder(args)
|
||||
.ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Warning))
|
||||
.ConfigureServices(services =>
|
||||
{
|
||||
// Register only the main workflow - sub-workflows are discovered automatically!
|
||||
services.ConfigureDurableWorkflows(
|
||||
options => options.Workflows.AddWorkflow(orderProcessingWorkflow),
|
||||
workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString),
|
||||
clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString));
|
||||
})
|
||||
.Build();
|
||||
|
||||
await host.StartAsync();
|
||||
|
||||
// Get the IWorkflowClient from DI
|
||||
IWorkflowClient workflowClient = host.Services.GetRequiredService<IWorkflowClient>();
|
||||
|
||||
Console.WriteLine("╔══════════════════════════════════════════════════════════════════╗");
|
||||
Console.WriteLine("║ Durable Sub-Workflows Sample ║");
|
||||
Console.WriteLine("╠══════════════════════════════════════════════════════════════════╣");
|
||||
Console.WriteLine("║ Main Workflow: OrderProcessing ║");
|
||||
Console.WriteLine("║ ├── Payment (sub-workflow) ║");
|
||||
Console.WriteLine("║ │ ├── ValidatePayment (1s) ║");
|
||||
Console.WriteLine("║ │ ├── FraudCheck (sub-sub-workflow) ← Level 2 nesting! ║");
|
||||
Console.WriteLine("║ │ │ ├── AnalyzePatterns (1s) ║");
|
||||
Console.WriteLine("║ │ │ └── CalculateRiskScore (1s) ║");
|
||||
Console.WriteLine("║ │ └── ChargePayment (2s) ║");
|
||||
Console.WriteLine("║ ├── Inventory (sub-workflow) ║");
|
||||
Console.WriteLine("║ │ ├── CheckInventory (1s) ║");
|
||||
Console.WriteLine("║ │ └── ReserveInventory (2s) ║");
|
||||
Console.WriteLine("║ └── Shipping (sub-workflow) ║");
|
||||
Console.WriteLine("║ ├── SelectCarrier (1s) ║");
|
||||
Console.WriteLine("║ └── CreateShipment (2s) ║");
|
||||
Console.WriteLine("╚══════════════════════════════════════════════════════════════════╝");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Open the DTS dashboard at http://localhost:8080 to see the");
|
||||
Console.WriteLine("parent-child orchestration hierarchy in the Timeline view!");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Enter an order ID (or 'exit'):");
|
||||
|
||||
while (true)
|
||||
{
|
||||
Console.Write("> ");
|
||||
string? input = Console.ReadLine();
|
||||
if (string.IsNullOrWhiteSpace(input) || input.Equals("exit", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await StartNewWorkflowAsync(input, orderProcessingWorkflow, workflowClient);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error: {ex.Message}");
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
await host.StopAsync();
|
||||
|
||||
// Start a new workflow using IWorkflowClient
|
||||
async Task StartNewWorkflowAsync(string orderId, Workflow workflow, IWorkflowClient client)
|
||||
{
|
||||
Console.WriteLine($"\nStarting order processing for '{orderId}'...");
|
||||
|
||||
await using DurableRun run = (DurableRun)await client.RunAsync(workflow, orderId);
|
||||
Console.WriteLine($"Instance ID: {run.InstanceId}");
|
||||
Console.WriteLine("Check the DTS dashboard Timeline tab to see sub-orchestrations!");
|
||||
Console.WriteLine();
|
||||
|
||||
try
|
||||
{
|
||||
string? result = await run.WaitForCompletionAsync();
|
||||
Console.ForegroundColor = ConsoleColor.Green;
|
||||
Console.WriteLine($"✓ Order completed: {result}");
|
||||
Console.ResetColor();
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Red;
|
||||
Console.WriteLine($"✗ Failed: {ex.Message}");
|
||||
Console.ResetColor();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
# Sub-Workflows Console Sample
|
||||
|
||||
This sample demonstrates how to compose workflows hierarchically by using sub-workflows within a durable orchestration. Sub-workflows are executed as separate orchestration instances, providing modularity, reusability, and excellent visibility in the Durable Task dashboard.
|
||||
|
||||
## Overview
|
||||
|
||||
The sample implements an order processing system with three sub-workflows:
|
||||
|
||||
```
|
||||
OrderProcessing (Main Workflow)
|
||||
??? OrderReceived
|
||||
??? Payment (Sub-Workflow)
|
||||
? ??? ValidatePayment (1s)
|
||||
? ??? ChargePayment (2s)
|
||||
??? Inventory (Sub-Workflow)
|
||||
? ??? CheckInventory (1s)
|
||||
? ??? ReserveInventory (2s)
|
||||
??? Shipping (Sub-Workflow)
|
||||
? ??? SelectCarrier (1s)
|
||||
? ??? CreateShipment (2s)
|
||||
??? OrderCompleted
|
||||
```
|
||||
|
||||
## Key Concepts
|
||||
|
||||
### Sub-Workflow Benefits
|
||||
|
||||
1. **Modularity**: Each sub-workflow encapsulates related logic (payment, inventory, shipping)
|
||||
2. **Reusability**: Sub-workflows can be used in multiple parent workflows
|
||||
3. **Independent Execution**: Each sub-workflow runs as a separate orchestration instance
|
||||
4. **Dashboard Visibility**: Sub-workflows appear in the Timeline view with parent-child relationships
|
||||
5. **Failure Isolation**: A failure in a sub-workflow doesn't corrupt the parent's state
|
||||
|
||||
### How Sub-Workflows Work
|
||||
|
||||
```csharp
|
||||
// Step 1: Build a sub-workflow
|
||||
Workflow paymentWorkflow = new WorkflowBuilder(validatePayment)
|
||||
.WithName("PaymentProcessing")
|
||||
.AddEdge(validatePayment, chargePayment)
|
||||
.Build();
|
||||
|
||||
// Step 2: Bind it as an executor for use in a parent workflow
|
||||
ExecutorBinding paymentExecutor = paymentWorkflow.BindAsExecutor("Payment");
|
||||
|
||||
// Step 3: Use the sub-workflow executor in the main workflow
|
||||
Workflow mainWorkflow = new WorkflowBuilder(orderReceived)
|
||||
.AddEdge(orderReceived, paymentExecutor) // Sub-workflow as an edge target
|
||||
.AddEdge(paymentExecutor, inventoryExecutor)
|
||||
.Build();
|
||||
|
||||
// Step 4: Register only the main workflow - sub-workflows are discovered automatically!
|
||||
services.ConfigureDurableWorkflows(
|
||||
options => options.Workflows.AddWorkflow(mainWorkflow),
|
||||
...);
|
||||
```
|
||||
|
||||
### Dashboard Visualization
|
||||
|
||||
Open the DTS dashboard at `http://localhost:8080` after running a workflow:
|
||||
|
||||
1. Click on the main orchestration instance
|
||||
2. Switch to the **Timeline** tab
|
||||
3. You'll see a hierarchical view showing:
|
||||
- `OrderProcessing` (parent orchestration)
|
||||
- `PaymentProcessing` (sub-orchestration)
|
||||
- `InventoryManagement` (sub-orchestration)
|
||||
- `ShippingArrangement` (sub-orchestration)
|
||||
|
||||
Each sub-orchestration has its own instance ID and can be inspected independently.
|
||||
|
||||
## Environment Setup
|
||||
|
||||
See the [README.md](../README.md) file in the parent directory for information on:
|
||||
- Installing prerequisites (.NET 10+, Docker)
|
||||
- Starting the Durable Task Scheduler emulator
|
||||
- Configuring environment variables
|
||||
|
||||
## Running the Sample
|
||||
|
||||
```bash
|
||||
# Start the DTS emulator (if not already running)
|
||||
docker run -d --name dts-emulator -p 8080:8080 -p 8082:8082 mcr.microsoft.com/dts/dts-emulator:latest
|
||||
|
||||
# Run the sample
|
||||
cd dotnet/samples/DurableAgents/ConsoleApps/09_SubWorkflows
|
||||
dotnet run --framework net10.0
|
||||
```
|
||||
|
||||
### Sample Session
|
||||
|
||||
```text
|
||||
????????????????????????????????????????????????????????????????????
|
||||
? Durable Sub-Workflows Sample ?
|
||||
????????????????????????????????????????????????????????????????????
|
||||
? Main Workflow: OrderProcessing ?
|
||||
? ??? Payment (sub-workflow) ?
|
||||
? ? ??? ValidatePayment (1s) ?
|
||||
? ? ??? ChargePayment (2s) ?
|
||||
? ??? Inventory (sub-workflow) ?
|
||||
? ? ??? CheckInventory (1s) ?
|
||||
? ? ??? ReserveInventory (2s) ?
|
||||
? ??? Shipping (sub-workflow) ?
|
||||
? ??? SelectCarrier (1s) ?
|
||||
? ??? CreateShipment (2s) ?
|
||||
????????????????????????????????????????????????????????????????????
|
||||
|
||||
Open the DTS dashboard at http://localhost:8080 to see the
|
||||
parent-child orchestration hierarchy in the Timeline view!
|
||||
|
||||
Enter an order ID (or 'exit'):
|
||||
> ORD-12345
|
||||
|
||||
Starting order processing for 'ORD-12345'...
|
||||
Instance ID: abc123def456
|
||||
Check the DTS dashboard Timeline tab to see sub-orchestrations!
|
||||
|
||||
[OrderReceived] Processing order 'ORD-12345'
|
||||
[Payment/ValidatePayment] Validating payment for order 'ORD-12345'...
|
||||
[Payment/ValidatePayment] Payment validated for $99.99
|
||||
[Payment/ChargePayment] Charging $99.99 for order 'ORD-12345'...
|
||||
[Payment/ChargePayment] ? Payment processed: TXN-A1B2C3D4
|
||||
[Inventory/CheckInventory] Checking inventory for order 'ORD-12345'...
|
||||
[Inventory/CheckInventory] ? Items available in stock
|
||||
[Inventory/ReserveInventory] Reserving items for order 'ORD-12345'...
|
||||
[Inventory/ReserveInventory] ? Reserved: RES-E5F6G7H8
|
||||
[Shipping/SelectCarrier] Selecting carrier for order 'ORD-12345'...
|
||||
[Shipping/SelectCarrier] ? Selected carrier: Express
|
||||
[Shipping/CreateShipment] Creating shipment for order 'ORD-12345'...
|
||||
[Shipping/CreateShipment] ? Shipment created: TRACK-I9J0K1L2M3
|
||||
???????????????????????????????????????????????????????????????????
|
||||
? [OrderCompleted] Order 'ORD-12345' successfully processed!
|
||||
? Payment: TXN-A1B2C3D4
|
||||
? Inventory: RES-E5F6G7H8
|
||||
? Shipping: Express - TRACK-I9J0K1L2M3
|
||||
???????????????????????????????????????????????????????????????????
|
||||
? Order completed. Tracking: TRACK-I9J0K1L2M3
|
||||
```
|
||||
|
||||
## Comparison with In-Process Sub-Workflows
|
||||
|
||||
| Feature | In-Process | Durable |
|
||||
|---------|------------|---------|
|
||||
| Execution | Same process, synchronized supersteps | Separate orchestration instances |
|
||||
| Visibility | Single workflow view | Hierarchical dashboard view |
|
||||
| Checkpointing | Parent checkpoints include child state | Independent checkpoints per sub-workflow |
|
||||
| Failure Recovery | Parent must handle child failures | Automatic retry with state preservation |
|
||||
| Scalability | Single process | Can scale across workers |
|
||||
|
||||
## Related Samples
|
||||
|
||||
- [06_SubWorkflows (In-Process)](../../../GettingStarted/Workflows/_Foundational/06_SubWorkflows) - In-process sub-workflow execution
|
||||
- [08_SingleWorkflow](../08_SingleWorkflow) - Basic durable workflow without sub-workflows
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<OutputType>Exe</OutputType>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AssemblyName>SingleWorkflow</AssemblyName>
|
||||
<RootNamespace>SingleAgent</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.DurableTask.Client.AzureManaged" />
|
||||
<PackageReference Include="Microsoft.DurableTask.Worker.AzureManaged" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
</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" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,67 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
namespace WorkflowConcurrency;
|
||||
|
||||
/// <summary>
|
||||
/// Parses and validates the incoming question before sending to AI agents.
|
||||
/// </summary>
|
||||
internal sealed class ParseQuestionExecutor() : Executor<string, string>("ParseQuestion")
|
||||
{
|
||||
public override ValueTask<string> HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.ForegroundColor = ConsoleColor.Magenta;
|
||||
Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐");
|
||||
Console.WriteLine("│ [ParseQuestion] Preparing question for AI agents...");
|
||||
|
||||
string formattedQuestion = message.Trim();
|
||||
if (!formattedQuestion.EndsWith('?'))
|
||||
{
|
||||
formattedQuestion += "?";
|
||||
}
|
||||
|
||||
Console.WriteLine($"│ [ParseQuestion] Question: \"{formattedQuestion}\"");
|
||||
Console.WriteLine("│ [ParseQuestion] → Sending to Physicist and Chemist in PARALLEL...");
|
||||
Console.WriteLine("└─────────────────────────────────────────────────────────────────┘");
|
||||
Console.ResetColor();
|
||||
|
||||
return ValueTask.FromResult(formattedQuestion);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggregates responses from all AI agents into a comprehensive answer.
|
||||
/// This is the Fan-in point where parallel results are collected.
|
||||
/// </summary>
|
||||
internal sealed class AggregatorExecutor() : Executor<string[], string>("Aggregator")
|
||||
{
|
||||
public override ValueTask<string> HandleAsync(string[] message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.ForegroundColor = ConsoleColor.Cyan;
|
||||
Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐");
|
||||
Console.WriteLine($"│ [Aggregator] 📋 Received {message.Length} AI agent responses");
|
||||
Console.WriteLine("│ [Aggregator] Combining into comprehensive answer...");
|
||||
Console.WriteLine("│ [Aggregator] ✓ Aggregation complete!");
|
||||
Console.WriteLine("└─────────────────────────────────────────────────────────────────┘");
|
||||
Console.ResetColor();
|
||||
|
||||
string aggregatedResult = "═══════════════════════════════════════════════════════════════\n" +
|
||||
" AI EXPERT PANEL RESPONSES\n" +
|
||||
"═══════════════════════════════════════════════════════════════\n\n";
|
||||
|
||||
for (int i = 0; i < message.Length; i++)
|
||||
{
|
||||
string expertLabel = i == 0 ? "⚛️ PHYSICIST" : "🧪 CHEMIST";
|
||||
aggregatedResult += $"{expertLabel}:\n{message[i]}\n\n";
|
||||
}
|
||||
|
||||
aggregatedResult += "═══════════════════════════════════════════════════════════════\n" +
|
||||
$"Summary: Received perspectives from {message.Length} AI experts.\n" +
|
||||
"═══════════════════════════════════════════════════════════════";
|
||||
|
||||
return ValueTask.FromResult(aggregatedResult);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates the Fan-out/Fan-in pattern in a durable workflow.
|
||||
// The workflow uses 4 executors: 2 class-based executors and 2 AI agents.
|
||||
//
|
||||
// WORKFLOW PATTERN (4 Executors):
|
||||
//
|
||||
// ┌──────────────────┐
|
||||
// │ ParseQuestion │ ← Class-based Executor
|
||||
// └────────┬─────────┘
|
||||
// │
|
||||
// ┌────────┴────────┐
|
||||
// ▼ ▼
|
||||
// ┌──────────┐ ┌──────────┐
|
||||
// │ Physicist│ │ Chemist │ ← AI Agents (parallel)
|
||||
// └────┬─────┘ └────┬─────┘
|
||||
// │ │
|
||||
// └──────┬───────┘
|
||||
// ▼
|
||||
// ┌──────────────────┐
|
||||
// │ Aggregator │ ← Class-based Executor
|
||||
// └──────────────────┘
|
||||
|
||||
using Azure;
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.DurableTask;
|
||||
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 OpenAI.Chat;
|
||||
using WorkflowConcurrency;
|
||||
|
||||
// Configuration
|
||||
string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING")
|
||||
?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None";
|
||||
string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT")
|
||||
?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT")
|
||||
?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT is not set.");
|
||||
string? azureOpenAiKey = Environment.GetEnvironmentVariable("AZURE_OPENAI_KEY");
|
||||
|
||||
// Create Azure OpenAI client
|
||||
AzureOpenAIClient openAiClient = !string.IsNullOrEmpty(azureOpenAiKey)
|
||||
? new AzureOpenAIClient(new Uri(endpoint), new AzureKeyCredential(azureOpenAiKey))
|
||||
: new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential());
|
||||
ChatClient chatClient = openAiClient.GetChatClient(deploymentName);
|
||||
|
||||
// Define the 4 executors for the workflow
|
||||
ParseQuestionExecutor parseQuestion = new(); // Executor 1: Class-based
|
||||
AIAgent physicist = chatClient.AsAIAgent("You are a physics expert. Be concise (2-3 sentences).", "Physicist"); // Executor 2: AI Agent
|
||||
AIAgent chemist = chatClient.AsAIAgent("You are a chemistry expert. Be concise (2-3 sentences).", "Chemist"); // Executor 3: AI Agent
|
||||
AggregatorExecutor aggregator = new(); // Executor 4: Class-based
|
||||
|
||||
// Build workflow: ParseQuestion → [Physicist, Chemist] (parallel) → Aggregator
|
||||
Workflow workflow = new WorkflowBuilder(parseQuestion)
|
||||
.WithName("ExpertReview")
|
||||
.AddFanOutEdge(parseQuestion, [physicist, chemist])
|
||||
.AddFanInEdge([physicist, chemist], aggregator)
|
||||
.Build();
|
||||
|
||||
// Configure and start the host
|
||||
IHost host = Host.CreateDefaultBuilder(args)
|
||||
.ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Warning))
|
||||
.ConfigureServices(services =>
|
||||
{
|
||||
services.ConfigureDurableWorkflows(
|
||||
options => options.Workflows.AddWorkflow(workflow),
|
||||
workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString),
|
||||
clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString));
|
||||
})
|
||||
.Build();
|
||||
|
||||
await host.StartAsync();
|
||||
|
||||
// Get the IWorkflowClient from DI - no need to manually resolve DurableTaskClient
|
||||
IWorkflowClient workflowClient = host.Services.GetRequiredService<IWorkflowClient>();
|
||||
|
||||
// Console UI
|
||||
Console.ForegroundColor = ConsoleColor.Cyan;
|
||||
Console.WriteLine("╔═══════════════════════════════════════════════════════════════════════╗");
|
||||
Console.WriteLine("║ Fan-out/Fan-in Workflow Sample (4 Executors) ║");
|
||||
Console.WriteLine("║ ║");
|
||||
Console.WriteLine("║ ParseQuestion → [Physicist, Chemist] → Aggregator ║");
|
||||
Console.WriteLine("║ (class-based) (AI agents, parallel) (class-based) ║");
|
||||
Console.WriteLine("╚═══════════════════════════════════════════════════════════════════════╝");
|
||||
Console.ResetColor();
|
||||
Console.WriteLine();
|
||||
|
||||
await Task.Delay(TimeSpan.FromSeconds(2)); // Allow pending workflows to resume
|
||||
|
||||
Console.WriteLine("Enter a science question (or 'exit' to quit):");
|
||||
Console.WriteLine();
|
||||
|
||||
while (true)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.Write("Question: ");
|
||||
Console.ResetColor();
|
||||
|
||||
string? input = Console.ReadLine();
|
||||
if (string.IsNullOrWhiteSpace(input) || input.Equals("exit", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Cast to DurableRun for durable-specific features like InstanceId and WaitForCompletionAsync
|
||||
await using DurableRun run = (DurableRun)await workflowClient.RunAsync(workflow, input);
|
||||
Console.ForegroundColor = ConsoleColor.Gray;
|
||||
Console.WriteLine($"Instance: {run.InstanceId}");
|
||||
Console.ResetColor();
|
||||
|
||||
string? result = await run.WaitForCompletionAsync();
|
||||
|
||||
Console.ForegroundColor = ConsoleColor.Green;
|
||||
Console.WriteLine("\n✓ Workflow completed!\n");
|
||||
Console.ResetColor();
|
||||
Console.WriteLine(result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Red;
|
||||
Console.WriteLine($"✗ Error: {ex.Message}");
|
||||
Console.ResetColor();
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
await host.StopAsync();
|
||||
@@ -0,0 +1,160 @@
|
||||
# Fan-out/Fan-in Workflow with AI Agents
|
||||
|
||||
This sample demonstrates the **Fan-out/Fan-in pattern** using real AI agents in a durable workflow. A question is sent to multiple AI "expert" agents in parallel, and their responses are aggregated into a final result.
|
||||
|
||||
## Overview
|
||||
|
||||
The sample implements an expert consultation workflow using Azure OpenAI:
|
||||
- A question is parsed and prepared
|
||||
- The question is sent to **2 AI agents in parallel** (Fan-out)
|
||||
- AI agent responses are **collected and aggregated** (Fan-in)
|
||||
|
||||
### Components
|
||||
|
||||
| Component | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| ParseQuestion | Executor | Validates and formats the incoming question |
|
||||
| Physicist | AI Agent | Azure OpenAI agent with physics expertise |
|
||||
| Chemist | AI Agent | Azure OpenAI agent with chemistry expertise |
|
||||
| Aggregator | Executor | Combines all AI agent responses |
|
||||
|
||||
## Workflow Pattern
|
||||
|
||||
```
|
||||
???????????????????
|
||||
? ParseQuestion ?
|
||||
???????????????????
|
||||
?
|
||||
???????????????????????????????
|
||||
? ?
|
||||
? ?
|
||||
??????????????????? ???????????????????
|
||||
? Physicist ? ? Chemist ?
|
||||
? (AI Agent) ? ? (AI Agent) ?
|
||||
??????????????????? ???????????????????
|
||||
? ?
|
||||
? PARALLEL EXECUTION ?
|
||||
? ?
|
||||
???????????????????????????????
|
||||
?
|
||||
?
|
||||
???????????????????
|
||||
? Aggregator ?
|
||||
???????????????????
|
||||
```
|
||||
|
||||
## Key Concepts Demonstrated
|
||||
|
||||
- **Fan-out (AddFanOutEdge)**: One executor sends to multiple AI agents in parallel
|
||||
- **Fan-in (AddFanInEdge)**: Multiple AI agent results are collected into an array
|
||||
- **AI Agents in Workflows**: Using Azure OpenAI agents as workflow executors
|
||||
- **Durability**: If interrupted, completed AI agent responses are preserved on restart
|
||||
|
||||
## Configuration
|
||||
|
||||
When using AI agents in durable workflows, `ConfigureDurableWorkflows` automatically registers any AI agents found in the workflow. You only need a single configuration call:
|
||||
|
||||
```csharp
|
||||
services.ConfigureDurableWorkflows(
|
||||
options => options.Workflows.AddWorkflow(expertReview),
|
||||
workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString),
|
||||
clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString));
|
||||
```
|
||||
|
||||
This is similar to how `ConfigureDurableOptions` works in Azure Functions samples.
|
||||
|
||||
## Environment Setup
|
||||
|
||||
This sample requires:
|
||||
1. **Durable Task Scheduler** - See the [parent README](../README.md) for setup instructions
|
||||
2. **Azure OpenAI** - You need an Azure OpenAI resource with a deployed model
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `DURABLE_TASK_SCHEDULER_CONNECTION_STRING` | Connection string for DTS (defaults to local emulator) |
|
||||
| `AZURE_OPENAI_ENDPOINT` | Your Azure OpenAI endpoint URL |
|
||||
| `AZURE_OPENAI_DEPLOYMENT` | Name of your deployed model (e.g., `gpt-4`) |
|
||||
| `AZURE_OPENAI_KEY` | (Optional) API key - if not set, uses Azure CLI credential |
|
||||
|
||||
## Running the Sample
|
||||
|
||||
```bash
|
||||
cd dotnet/samples/DurableAgents/ConsoleApps/09_Workflow_Concurrency
|
||||
dotnet run --framework net10.0
|
||||
```
|
||||
|
||||
### Sample Session
|
||||
|
||||
```text
|
||||
??????????????????????????????????????????????????????????????????????
|
||||
? Fan-out/Fan-in Workflow with AI Agents ?
|
||||
??????????????????????????????????????????????????????????????????????
|
||||
? This sample demonstrates parallel AI agent consultation. ?
|
||||
? ?
|
||||
? Workflow: Question ? [Physicist, Chemist] ? Aggregator ?
|
||||
? (AI agents run in parallel) ?
|
||||
??????????????????????????????????????????????????????????????????????
|
||||
|
||||
?? TIP: Stop during execution to test durability - completed agent responses are preserved!
|
||||
|
||||
? Checking for pending workflows...
|
||||
|
||||
Enter a science question (or 'exit' to quit):
|
||||
Example: "What is water?"
|
||||
|
||||
Question: What is water
|
||||
|
||||
Starting expert review workflow...
|
||||
Instance ID: abc123-def456-...
|
||||
|
||||
???????????????????????????????????????????????????????????????????
|
||||
? [ParseQuestion] Preparing question for AI agents...
|
||||
? [ParseQuestion] Question: "What is water?"
|
||||
? [ParseQuestion] ? Sending to Physicist and Chemist in PARALLEL...
|
||||
???????????????????????????????????????????????????????????????????
|
||||
|
||||
... (AI agents process in parallel)
|
||||
|
||||
???????????????????????????????????????????????????????????????????
|
||||
? [Aggregator] ?? Received 2 AI agent responses
|
||||
? [Aggregator] Combining into comprehensive answer...
|
||||
? [Aggregator] ? Aggregation complete!
|
||||
???????????????????????????????????????????????????????????????????
|
||||
|
||||
??????????????????????????????????????????????????????????????????????
|
||||
? ? Expert review completed! ?
|
||||
??????????????????????????????????????????????????????????????????????
|
||||
|
||||
???????????????????????????????????????????????????????????????
|
||||
AI EXPERT PANEL RESPONSES
|
||||
???????????????????????????????????????????????????????????????
|
||||
|
||||
?? PHYSICIST:
|
||||
Water is H2O - two hydrogen atoms bonded to one oxygen atom. From a physics
|
||||
perspective, water exhibits unique properties like high specific heat capacity
|
||||
and surface tension due to hydrogen bonding.
|
||||
|
||||
?? CHEMIST:
|
||||
Water (H2O) is a polar molecule formed by covalent bonds. Its bent molecular
|
||||
geometry creates a dipole moment, making it an excellent solvent for ionic
|
||||
and polar compounds.
|
||||
|
||||
???????????????????????????????????????????????????????????????
|
||||
Summary: Received perspectives from 2 AI experts.
|
||||
???????????????????????????????????????????????????????????????
|
||||
```
|
||||
|
||||
## Durability with AI Agents
|
||||
|
||||
If you stop the application while one AI agent is processing:
|
||||
- The completed agent's response is **preserved** (cached by Durable Task)
|
||||
- On restart, only the incomplete agent **re-runs**
|
||||
- The aggregator waits for all agents to complete
|
||||
|
||||
## Related Samples
|
||||
|
||||
- [08_SingleWorkflow](../08_SingleWorkflow) - Sequential workflow with durability demonstration
|
||||
- [10_WorkflowConcurrent](../../AzureFunctions/10_WorkflowConcurrent) - Azure Functions version
|
||||
- [02_AgentOrchestration_Chaining](../02_AgentOrchestration_Chaining) - Agent chaining with durable orchestration
|
||||
@@ -0,0 +1,29 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<OutputType>Exe</OutputType>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AssemblyName>SingleWorkflow</AssemblyName>
|
||||
<RootNamespace>SingleAgent</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" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,27 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
namespace SingleAgent;
|
||||
|
||||
public record ApprovalRequest(string ExpenseId, decimal Amount, string EmployeeName);
|
||||
public record ApprovalResponse(bool Approved, string? Comments);
|
||||
|
||||
internal sealed class CreateApprovalRequest() : Executor<string, ApprovalRequest>("RetrieveRequest")
|
||||
{
|
||||
public override async ValueTask<ApprovalRequest> HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Get request details from db.
|
||||
return new ApprovalRequest(message, 1500.00m, "Jerry");
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class ExpenseReimburse() : Executor<ApprovalResponse, string>("Reimburse")
|
||||
{
|
||||
public override async ValueTask<string> HandleAsync(ApprovalResponse message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Simulate payment processing.
|
||||
await Task.Delay(1000, cancellationToken);
|
||||
return $"Expense reimbursed at {DateTime.Now.ToUniversalTime()}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates a Human-in-the-Loop (HITL) workflow using Durable Tasks.
|
||||
// The workflow creates an expense approval request, waits for manager approval via an external event,
|
||||
// and then processes the expense reimbursement based on the approval response.
|
||||
// This sample mirrors the pattern used in the in-process HumanInTheLoopBasic sample.
|
||||
|
||||
using Microsoft.Agents.AI.DurableTask;
|
||||
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 SingleAgent;
|
||||
|
||||
// Get DTS connection string from environment variable
|
||||
string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING")
|
||||
?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None";
|
||||
|
||||
// Define executors for the workflow
|
||||
CreateApprovalRequest createRequest = new();
|
||||
RequestPort<ApprovalRequest, ApprovalResponse> managerApproval = RequestPort.Create<ApprovalRequest, ApprovalResponse>("ManagerApproval");
|
||||
ExpenseReimburse reimburse = new();
|
||||
|
||||
Workflow expenseApproval = new WorkflowBuilder(createRequest)
|
||||
.WithName("ExpenseReImbursement")
|
||||
.WithDescription("Expense ReImbursement")
|
||||
.AddEdge(createRequest, managerApproval)
|
||||
.AddEdge(managerApproval, reimburse)
|
||||
.Build();
|
||||
|
||||
IHost host = Host.CreateDefaultBuilder(args)
|
||||
.ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Warning))
|
||||
.ConfigureServices(services =>
|
||||
{
|
||||
services.ConfigureDurableWorkflows(
|
||||
options => options.Workflows.AddWorkflow(expenseApproval),
|
||||
workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString),
|
||||
clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString));
|
||||
})
|
||||
.Build();
|
||||
|
||||
await host.StartAsync();
|
||||
|
||||
// Get the IWorkflowClient from DI - no need to manually resolve DurableTaskClient
|
||||
IWorkflowClient workflowClient = host.Services.GetRequiredService<IWorkflowClient>();
|
||||
|
||||
// Start the workflow with an expense ID as input
|
||||
string expenseId = "EXP-2025-001";
|
||||
Console.WriteLine($"Starting expense reimbursement workflow for expense: {expenseId}");
|
||||
|
||||
// Start the workflow and get a streaming handle
|
||||
// Cast to DurableStreamingRun for durable-specific features like InstanceId and SendResponseAsync
|
||||
await using DurableStreamingRun run = (DurableStreamingRun)await workflowClient.StreamAsync(expenseApproval, expenseId);
|
||||
|
||||
Console.WriteLine($"Workflow started with instance ID: {run.InstanceId}");
|
||||
Console.WriteLine("Watching for workflow events...\n");
|
||||
|
||||
// Watch for workflow events - similar pattern to InProcessExecution.StreamAsync
|
||||
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
|
||||
{
|
||||
switch (evt)
|
||||
{
|
||||
case DurableRequestInfoEvent requestEvent:
|
||||
// Handle request for external input (human-in-the-loop)
|
||||
Console.WriteLine($"Workflow is waiting for input at RequestPort: {requestEvent.RequestPortId}");
|
||||
Console.WriteLine($" Input data: {requestEvent.Input}");
|
||||
Console.WriteLine($" Expected response type: {requestEvent.ResponseType}");
|
||||
|
||||
// Simulate manager approval
|
||||
ApprovalResponse response = HandleApprovalRequest(requestEvent);
|
||||
await run.SendResponseAsync(requestEvent, response);
|
||||
Console.WriteLine($" Response sent: Approved={response.Approved}\n");
|
||||
break;
|
||||
|
||||
case DurableWorkflowCompletedEvent completedEvent:
|
||||
// The workflow has completed
|
||||
Console.WriteLine($"Workflow completed with result: {completedEvent.Result}");
|
||||
break;
|
||||
|
||||
case DurableWorkflowFailedEvent failedEvent:
|
||||
// The workflow has failed
|
||||
Console.WriteLine($"Workflow failed: {failedEvent.ErrorMessage}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Console.ReadLine();
|
||||
await host.StopAsync();
|
||||
|
||||
// Handler for approval requests - similar to HandleExternalRequest in the in-process sample
|
||||
static ApprovalResponse HandleApprovalRequest(DurableRequestInfoEvent requestEvent)
|
||||
{
|
||||
// In a real scenario, this would involve human interaction (e.g., a web UI)
|
||||
// For this sample, we simulate automatic approval
|
||||
ApprovalRequest? request = requestEvent.GetInputAs<ApprovalRequest>();
|
||||
|
||||
if (request is not null)
|
||||
{
|
||||
Console.WriteLine($" Approval request for: {request.EmployeeName}, Amount: {request.Amount:C}");
|
||||
}
|
||||
|
||||
return new ApprovalResponse(Approved: true, Comments: "Approved by manager. Looks good!");
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
# Workflow Human-in-the-Loop (HITL) Sample
|
||||
|
||||
This sample demonstrates a **Human-in-the-Loop** pattern in durable workflows using `RequestPort`. The workflow pauses execution to wait for external input (e.g., manager approval) and resumes when the response is provided.
|
||||
|
||||
## Overview
|
||||
|
||||
The sample implements an expense approval workflow:
|
||||
|
||||
1. **CreateApprovalRequest** - Retrieves expense details and creates an approval request
|
||||
2. **ManagerApproval** (RequestPort) - Pauses workflow to wait for manager approval
|
||||
3. **ExpenseReimburse** - Processes the reimbursement based on approval response
|
||||
|
||||
## Workflow Flow
|
||||
|
||||
```
|
||||
User Input (Expense ID)
|
||||
|
|
||||
v
|
||||
+---------------------+
|
||||
| CreateApprovalRequest| Creates ApprovalRequest with expense details
|
||||
+---------------------+
|
||||
|
|
||||
v
|
||||
+---------------------+
|
||||
| ManagerApproval | RequestPort - PAUSES here waiting for external input
|
||||
| (RequestPort) | Workflow is durable while waiting
|
||||
+---------------------+
|
||||
|
|
||||
v (ApprovalResponse)
|
||||
+---------------------+
|
||||
| ExpenseReimburse | Processes reimbursement if approved
|
||||
+---------------------+
|
||||
|
|
||||
v
|
||||
Result
|
||||
```
|
||||
|
||||
## Key Concepts
|
||||
|
||||
- **RequestPort** - A special executor that pauses the workflow and waits for external input
|
||||
- **DurableRequestInfoEvent** - Event emitted when the workflow reaches a RequestPort
|
||||
- **SendResponseAsync** - Method to provide the response and resume the workflow
|
||||
- **Durability** - The workflow can survive process restarts while waiting for human input
|
||||
|
||||
## Code Highlights
|
||||
|
||||
### Defining the RequestPort
|
||||
|
||||
```csharp
|
||||
RequestPort<ApprovalRequest, ApprovalResponse> managerApproval =
|
||||
RequestPort.Create<ApprovalRequest, ApprovalResponse>("ManagerApproval");
|
||||
```
|
||||
|
||||
### Handling the Request Event
|
||||
|
||||
```csharp
|
||||
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
|
||||
{
|
||||
switch (evt)
|
||||
{
|
||||
case DurableRequestInfoEvent requestEvent:
|
||||
// Workflow is waiting for input
|
||||
ApprovalResponse response = HandleApprovalRequest(requestEvent);
|
||||
await run.SendResponseAsync(requestEvent, response);
|
||||
break;
|
||||
// ... other events
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Environment Setup
|
||||
|
||||
See the [README.md](../README.md) file in the parent directory for environment configuration.
|
||||
|
||||
## Running the Sample
|
||||
|
||||
```bash
|
||||
cd dotnet/samples/DurableAgents/ConsoleApps/10_Workflow_HITL
|
||||
dotnet run --framework net10.0
|
||||
```
|
||||
|
||||
### Sample Output
|
||||
|
||||
```
|
||||
Starting expense reimbursement workflow for expense: EXP-2025-001
|
||||
Workflow started with instance ID: abc123...
|
||||
Watching for workflow events...
|
||||
|
||||
Workflow is waiting for input at RequestPort: ManagerApproval
|
||||
Input data: {"ExpenseId":"EXP-2025-001","Amount":1500.00,"EmployeeName":"Jerry"}
|
||||
Expected response type: SingleAgent.ApprovalResponse
|
||||
Approval request for: Jerry, Amount: $1,500.00
|
||||
Response sent: Approved=True
|
||||
|
||||
Workflow completed with result: Expense reimbursed at 1/23/2025 5:30:00 PM
|
||||
```
|
||||
@@ -0,0 +1,29 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<OutputType>Exe</OutputType>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AssemblyName>SingleWorkflow</AssemblyName>
|
||||
<RootNamespace>SingleAgent</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" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
namespace SingleAgent;
|
||||
|
||||
/// <summary>
|
||||
/// Event emitted to report cancellation progress.
|
||||
/// </summary>
|
||||
public sealed class CancellationProgressEvent(string orderId, int percentComplete, string status)
|
||||
: WorkflowEvent($"Cancellation {percentComplete}%: {status}")
|
||||
{
|
||||
public string OrderId { get; } = orderId;
|
||||
public int PercentComplete { get; } = percentComplete;
|
||||
public string Status { get; } = status;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
namespace SingleAgent;
|
||||
|
||||
/// <summary>
|
||||
/// Event emitted when an email is sent.
|
||||
/// </summary>
|
||||
public sealed class EmailSentEvent(string email, string subject) : WorkflowEvent($"Email sent to {email}")
|
||||
{
|
||||
public string Email { get; } = email;
|
||||
public string Subject { get; } = subject;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates how to use IWorkflowContext methods in your executors:
|
||||
// - AddEventAsync: Emit custom events that can be observed by the workflow caller
|
||||
// - YieldOutputAsync: Stream intermediate outputs during execution
|
||||
//
|
||||
// These features enable rich observability and control over workflow execution.
|
||||
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
namespace SingleAgent;
|
||||
|
||||
#region Domain Models
|
||||
|
||||
/// <summary>
|
||||
/// Represents an order in the system.
|
||||
/// </summary>
|
||||
public sealed class Order
|
||||
{
|
||||
public required string Id { get; set; }
|
||||
public DateTime OrderDate { get; set; }
|
||||
public bool IsCancelled { get; set; }
|
||||
public required Customer Customer { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a customer associated with an order.
|
||||
/// </summary>
|
||||
public sealed class Customer
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Email { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
#endregion
|
||||
#region Custom Workflow Events
|
||||
|
||||
#endregion
|
||||
|
||||
#region Executors
|
||||
|
||||
/// <summary>
|
||||
/// Looks up an order by its ID. Demonstrates AddEventAsync for custom events.
|
||||
/// </summary>
|
||||
internal sealed class OrderLookup() : Executor<string, Order>("OrderLookup")
|
||||
{
|
||||
public override async ValueTask<Order> HandleAsync(
|
||||
string message,
|
||||
IWorkflowContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await context.AddEventAsync(new OrderLookupStartedEvent(message), cancellationToken);
|
||||
await Task.Delay(500, cancellationToken);
|
||||
|
||||
Order order = new()
|
||||
{
|
||||
Id = message,
|
||||
OrderDate = DateTime.UtcNow.AddDays(-3),
|
||||
IsCancelled = false,
|
||||
Customer = new Customer { Name = "Jerry", Email = "jerry@example.com" }
|
||||
};
|
||||
|
||||
await context.AddEventAsync(new OrderFoundEvent(order), cancellationToken);
|
||||
return order;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cancels an order with progress reporting.
|
||||
/// Demonstrates AddEventAsync for progress events and YieldOutputAsync for streaming outputs.
|
||||
/// </summary>
|
||||
internal sealed class OrderCancel() : Executor<Order, Order>("OrderCancel")
|
||||
{
|
||||
public override async ValueTask<Order> HandleAsync(
|
||||
Order message,
|
||||
IWorkflowContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Simulate cancellation steps with progress events
|
||||
string[] steps = ["Validating", "Processing refund", "Finalizing"];
|
||||
for (int i = 0; i < steps.Length; i++)
|
||||
{
|
||||
await Task.Delay(500, cancellationToken);
|
||||
int percent = (i + 1) * 33;
|
||||
|
||||
// Emit progress event (callers can observe this in real-time)
|
||||
await context.AddEventAsync(new CancellationProgressEvent(message.Id, percent, steps[i]), cancellationToken);
|
||||
|
||||
// YieldOutputAsync streams intermediate results matching the executor's return type
|
||||
await context.YieldOutputAsync(message, cancellationToken);
|
||||
}
|
||||
|
||||
message.IsCancelled = true;
|
||||
await context.AddEventAsync(new OrderCancelledEvent(message.Id), cancellationToken);
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a cancellation confirmation email. Demonstrates AddEventAsync for completion events.
|
||||
/// </summary>
|
||||
internal sealed class SendEmail() : Executor<Order, string>("SendEmail")
|
||||
{
|
||||
public override async ValueTask<string> HandleAsync(
|
||||
Order message,
|
||||
IWorkflowContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await Task.Delay(500, cancellationToken);
|
||||
|
||||
string email = message.Customer.Email;
|
||||
await context.AddEventAsync(new EmailSentEvent(email, $"Order {message.Id} Cancelled"), cancellationToken);
|
||||
return $"Email sent to {email}";
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -0,0 +1,13 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
namespace SingleAgent;
|
||||
|
||||
/// <summary>
|
||||
/// Event emitted when an order is successfully cancelled.
|
||||
/// </summary>
|
||||
public sealed class OrderCancelledEvent(string orderId) : WorkflowEvent($"Order {orderId} has been cancelled")
|
||||
{
|
||||
public string OrderId { get; } = orderId;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
namespace SingleAgent;
|
||||
|
||||
/// <summary>
|
||||
/// Event emitted when an order is found.
|
||||
/// </summary>
|
||||
public sealed class OrderFoundEvent(Order order) : WorkflowEvent($"Found order {order.Id} for {order.Customer.Name}")
|
||||
{
|
||||
public Order Order { get; } = order;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
namespace SingleAgent;
|
||||
|
||||
/// <summary>
|
||||
/// Event emitted when an order lookup starts.
|
||||
/// </summary>
|
||||
public sealed class OrderLookupStartedEvent(string orderId) : WorkflowEvent($"Looking up order {orderId}")
|
||||
{
|
||||
public string OrderId { get; } = orderId;
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// SAMPLE: Workflow Events and IWorkflowContext Features
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
//
|
||||
// This sample demonstrates how to use IWorkflowContext methods in executors:
|
||||
//
|
||||
// 1. AddEventAsync - Emit custom events that callers can observe in real-time
|
||||
// 2. YieldOutputAsync - Stream intermediate outputs during long-running operations
|
||||
//
|
||||
// The sample uses DurableExecutionEnvironment.StreamAsync to observe events as they occur,
|
||||
// showing how callers can receive real-time updates from the workflow.
|
||||
//
|
||||
// Workflow: OrderLookup -> OrderCancel -> SendEmail
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
using Microsoft.Agents.AI.DurableTask;
|
||||
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 SingleAgent;
|
||||
|
||||
// Get DTS connection string from environment variable
|
||||
string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING")
|
||||
?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None";
|
||||
|
||||
// Define executors and build workflow
|
||||
OrderLookup orderLookup = new();
|
||||
OrderCancel orderCancel = new();
|
||||
SendEmail sendEmail = new();
|
||||
|
||||
Workflow cancelOrder = new WorkflowBuilder(orderLookup)
|
||||
.WithName("CancelOrder")
|
||||
.WithDescription("Cancel an order and notify the customer")
|
||||
.AddEdge(orderLookup, orderCancel)
|
||||
.AddEdge(orderCancel, sendEmail)
|
||||
.Build();
|
||||
|
||||
// Configure host with durable workflow support
|
||||
IHost host = Host.CreateDefaultBuilder(args)
|
||||
.ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Warning))
|
||||
.ConfigureServices(services =>
|
||||
{
|
||||
services.ConfigureDurableWorkflows(
|
||||
options => options.Workflows.AddWorkflow(cancelOrder),
|
||||
workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString),
|
||||
clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString));
|
||||
})
|
||||
.Build();
|
||||
|
||||
await host.StartAsync();
|
||||
|
||||
// Get the IWorkflowClient from DI - no need to manually resolve DurableTaskClient
|
||||
IWorkflowClient workflowClient = host.Services.GetRequiredService<IWorkflowClient>();
|
||||
|
||||
Console.WriteLine("Workflow Events Demo - Enter order ID (or 'exit'):");
|
||||
|
||||
while (true)
|
||||
{
|
||||
Console.Write("> ");
|
||||
string? input = Console.ReadLine();
|
||||
if (string.IsNullOrWhiteSpace(input) || input.Equals("exit", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await RunWorkflowWithStreamingAsync(input, cancelOrder, workflowClient);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error: {ex.Message}");
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
await host.StopAsync();
|
||||
|
||||
// Runs a workflow and streams events as they occur
|
||||
async Task RunWorkflowWithStreamingAsync(string orderId, Workflow workflow, IWorkflowClient client)
|
||||
{
|
||||
// StreamAsync starts the workflow and returns a handle for observing events
|
||||
// Cast to DurableStreamingRun for durable-specific features like InstanceId
|
||||
await using DurableStreamingRun run = (DurableStreamingRun)await client.StreamAsync(workflow, orderId);
|
||||
Console.WriteLine($"Started: {run.InstanceId}");
|
||||
|
||||
// WatchStreamAsync yields events as they're emitted by executors
|
||||
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
|
||||
{
|
||||
// Always print the event type name
|
||||
Console.WriteLine($" Event: {evt.GetType().Name}");
|
||||
|
||||
switch (evt)
|
||||
{
|
||||
// Custom domain events (emitted via AddEventAsync)
|
||||
case OrderLookupStartedEvent e:
|
||||
WriteColored($" [Lookup] Looking up order {e.OrderId}", ConsoleColor.Cyan);
|
||||
break;
|
||||
case OrderFoundEvent e:
|
||||
WriteColored($" [Lookup] Found: {e.Order.Customer.Name}", ConsoleColor.Cyan);
|
||||
break;
|
||||
case CancellationProgressEvent e:
|
||||
WriteColored($" [Cancel] {e.PercentComplete}% - {e.Status}", ConsoleColor.Yellow);
|
||||
break;
|
||||
case OrderCancelledEvent e:
|
||||
WriteColored(" [Cancel] Done", ConsoleColor.Yellow);
|
||||
break;
|
||||
case EmailSentEvent e:
|
||||
WriteColored($" [Email] Sent to {e.Email}", ConsoleColor.Magenta);
|
||||
break;
|
||||
|
||||
// Yielded outputs (emitted via YieldOutputAsync)
|
||||
case DurableYieldedOutputEvent e:
|
||||
WriteColored($" [Output] {e.ExecutorId}", ConsoleColor.DarkGray);
|
||||
break;
|
||||
|
||||
// Workflow completion
|
||||
case DurableWorkflowCompletedEvent e:
|
||||
WriteColored($" Completed: {e.Result}", ConsoleColor.Green);
|
||||
break;
|
||||
case DurableWorkflowFailedEvent e:
|
||||
WriteColored($" Failed: {e.ErrorMessage}", ConsoleColor.Red);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void WriteColored(string message, ConsoleColor color)
|
||||
{
|
||||
Console.ForegroundColor = color;
|
||||
Console.WriteLine(message);
|
||||
Console.ResetColor();
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
# Single Workflow Console Sample
|
||||
|
||||
This sample demonstrates how to run a workflow as a durable orchestration from a console application using the Durable Task Framework. It showcases the **durability** aspect - if the process crashes mid-execution, the workflow can be resumed without re-executing completed activities.
|
||||
|
||||
## Overview
|
||||
|
||||
The sample implements an order cancellation workflow with three executors, each with artificial delays to simulate real-world operations:
|
||||
|
||||
1. **OrderLookup** (2 seconds) - Looks up an order by its ID
|
||||
2. **OrderCancel** (5 seconds) - Marks the order as cancelled
|
||||
3. **SendEmail** (1 second) - Sends a cancellation confirmation email
|
||||
|
||||
## Durability Demonstration
|
||||
|
||||
The key feature of Durable Task Framework is **durability**:
|
||||
|
||||
|
||||
- **Activity results are persisted**: When an activity completes, its result is saved
|
||||
- **Orchestrations are replayed**: On restart, the orchestration replays from the beginning
|
||||
- **Completed activities are skipped**: The framework uses cached results for completed activities
|
||||
- **Failed activities are retried**: If an activity was interrupted, it runs again
|
||||
- **Automatic resume**: When the worker starts, it automatically picks up any pending work!
|
||||
|
||||
### Try It Yourself
|
||||
|
||||
1. Start the application and enter an order ID (e.g., `12345`)
|
||||
2. Stop the app (Ctrl+C or stop debugging) during the `OrderCancel` activity (5 seconds)
|
||||
3. Restart the application
|
||||
4. **Watch for automatic resume!** The worker automatically picks up the interrupted workflow
|
||||
5. Observe that `OrderLookup` is NOT re-executed (its result was cached)
|
||||
6. `OrderCancel` restarts from the beginning (it didn't complete)
|
||||
7. `SendEmail` runs after `OrderCancel` completes
|
||||
|
||||
The durability is completely automatic - no manual intervention needed!
|
||||
|
||||
## Workflow Flow
|
||||
|
||||
```
|
||||
User Input (Order ID)
|
||||
?
|
||||
?
|
||||
???????????????????
|
||||
? OrderLookup ? ? 2 second delay (database lookup)
|
||||
? (2 seconds) ?
|
||||
???????????????????
|
||||
?
|
||||
?
|
||||
???????????????????
|
||||
? OrderCancel ? ? 5 second delay - TRY INTERRUPTING HERE!
|
||||
? (5 seconds) ?
|
||||
???????????????????
|
||||
?
|
||||
?
|
||||
???????????????????
|
||||
? SendEmail ? ? 1 second delay (email sending)
|
||||
? (1 second) ?
|
||||
???????????????????
|
||||
?
|
||||
?
|
||||
Result
|
||||
```
|
||||
|
||||
## Key Concepts Demonstrated
|
||||
|
||||
- **ConfigureDurableWorkflows** - Simplified API for registering workflows
|
||||
- **DurableExecution.RunAsync** - Start a new workflow (similar to InProcessExecution)
|
||||
- **DurableRun** - Handle to monitor and interact with a running workflow
|
||||
- **Automatic Resume** - Interrupted workflows continue automatically on restart
|
||||
|
||||
## Environment Setup
|
||||
|
||||
See the [README.md](../README.md) file in the parent directory for more information on how to configure the environment, including how to install and run common sample dependencies.
|
||||
|
||||
## Running the Sample
|
||||
|
||||
With the environment setup, you can run the sample:
|
||||
|
||||
```bash
|
||||
cd dotnet/samples/DurableAgents/ConsoleApps/08_SingleWorkflow
|
||||
dotnet run --framework net10.0
|
||||
```
|
||||
|
||||
### Sample Session
|
||||
|
||||
```text
|
||||
??????????????????????????????????????????????????????????????????????
|
||||
? Durable Workflow Console Sample ?
|
||||
??????????????????????????????????????????????????????????????????????
|
||||
? This sample demonstrates durability in workflows. ?
|
||||
? Workflow: OrderLookup (2s) -> OrderCancel (5s) -> SendEmail (1s) ?
|
||||
??????????????????????????????????????????????????????????????????????
|
||||
|
||||
?? TIP: Stop the app during OrderCancel (5 seconds) to test durability!
|
||||
Restart the app - it will automatically resume from where it left off.
|
||||
|
||||
? Checking for pending workflows...
|
||||
|
||||
Enter an order ID to start a new workflow (or 'exit' to quit):
|
||||
|
||||
Order ID: 12345
|
||||
|
||||
Starting workflow for order '12345'...
|
||||
Instance ID: abc123-def456-...
|
||||
|
||||
???????????????????????????????????????????????????????????????????
|
||||
? [Activity] OrderLookup: Starting lookup for order '12345'
|
||||
? [Activity] OrderLookup: Found order '12345' for customer 'Jerry'
|
||||
???????????????????????????????????????????????????????????????????
|
||||
|
||||
???????????????????????????????????????????????????????????????????
|
||||
? [Activity] OrderCancel: Starting cancellation for order '12345'
|
||||
? [Activity] OrderCancel: ?? This takes 5 seconds - try Ctrl+C!
|
||||
? [Activity] OrderCancel: Processing... 1/5 seconds
|
||||
? [Activity] OrderCancel: Processing... 2/5 seconds
|
||||
^C <-- User stops the app here
|
||||
|
||||
[After restart...]
|
||||
|
||||
? Checking for pending workflows...
|
||||
|
||||
???????????????????????????????????????????????????????????????????
|
||||
? [Activity] OrderCancel: Starting cancellation for order '12345' <-- Auto-resumed!
|
||||
? [Activity] OrderCancel: ?? This takes 5 seconds - try Ctrl+C!
|
||||
? [Activity] OrderCancel: Processing... 1/5 seconds
|
||||
...
|
||||
? [Activity] OrderCancel: ? Order '12345' has been cancelled
|
||||
???????????????????????????????????????????????????????????????????
|
||||
|
||||
???????????????????????????????????????????????????????????????????
|
||||
? [Activity] SendEmail: Sending email to 'jerry@example.com'...
|
||||
? [Activity] SendEmail: ? Email sent successfully!
|
||||
???????????????????????????????????????????????????????????????????
|
||||
|
||||
Enter an order ID to start a new workflow (or 'exit' to quit):
|
||||
|
||||
Order ID: _
|
||||
```
|
||||
|
||||
Notice that when resumed:
|
||||
- `OrderLookup` was **NOT re-executed** (result was cached by Durable Task)
|
||||
- `OrderCancel` **restarted automatically** (it was interrupted before completing)
|
||||
- `SendEmail` ran normally after `OrderCancel` completed
|
||||
|
||||
## Viewing Workflow State
|
||||
|
||||
You can view the state of the workflow in the Durable Task Scheduler dashboard:
|
||||
|
||||
1. Open your browser and navigate to `http://localhost:8082`
|
||||
2. In the dashboard, you can view the state of the orchestration, including activity history and current state
|
||||
|
||||
## Related Samples
|
||||
|
||||
- [01_SingleAgent](../01_SingleAgent) - Single agent console sample
|
||||
- [02_AgentOrchestration_Chaining](../02_AgentOrchestration_Chaining) - Agent chaining with durable orchestration
|
||||
- [05_AgentOrchestration_HITL](../05_AgentOrchestration_HITL) - Human-in-the-loop orchestration
|
||||
- [09_Workflow](../../AzureFunctions/09_Workflow) - Azure Functions version of workflow hosting
|
||||
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<OutputType>Exe</OutputType>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AssemblyName>SingleWorkflow</AssemblyName>
|
||||
<RootNamespace>SingleAgent</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.DurableTask.Client.AzureManaged" />
|
||||
<PackageReference Include="Microsoft.DurableTask.Worker.AzureManaged" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
</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" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,69 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace SingleAgent;
|
||||
|
||||
internal sealed class FeedbackExecutor : Executor<SloganResult>
|
||||
{
|
||||
private readonly AIAgent _agent;
|
||||
private AgentThread? _thread;
|
||||
|
||||
public int MinimumRating { get; init; } = 9;
|
||||
|
||||
public int MaxAttempts { get; init; } = 3;
|
||||
|
||||
private int _attempts;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FeedbackExecutor"/> class.
|
||||
/// </summary>
|
||||
/// <param name="id">A unique identifier for the executor.</param>
|
||||
/// <param name="chatClient">The chat client to use for the AI agent.</param>
|
||||
public FeedbackExecutor(string id, IChatClient chatClient) : base(id)
|
||||
{
|
||||
ChatClientAgentOptions agentOptions = new()
|
||||
{
|
||||
ChatOptions = new()
|
||||
{
|
||||
Instructions = "You are a professional editor. You will be given a slogan and the task it is meant to accomplish.",
|
||||
ResponseFormat = ChatResponseFormat.ForJsonSchema<FeedbackResult>()
|
||||
}
|
||||
};
|
||||
|
||||
this._agent = new ChatClientAgent(chatClient, agentOptions);
|
||||
}
|
||||
|
||||
public override async ValueTask HandleAsync(SloganResult message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
this._thread ??= await this._agent.GetNewThreadAsync(cancellationToken);
|
||||
|
||||
var sloganMessage = $"""
|
||||
Here is a slogan for the task '{message.Task}':
|
||||
Slogan: {message.Slogan}
|
||||
Please provide feedback on this slogan, including comments, a rating from 1 to 10, and suggested actions for improvement.
|
||||
""";
|
||||
|
||||
var response = await this._agent.RunAsync(sloganMessage, this._thread, cancellationToken: cancellationToken);
|
||||
var feedback = JsonSerializer.Deserialize<FeedbackResult>(response.Text) ?? throw new InvalidOperationException("Failed to deserialize feedback.");
|
||||
|
||||
if (feedback.Rating >= this.MinimumRating)
|
||||
{
|
||||
await context.YieldOutputAsync($"The following slogan was accepted:\n\n{message.Slogan}", cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._attempts >= this.MaxAttempts)
|
||||
{
|
||||
await context.YieldOutputAsync($"The slogan was rejected after {this.MaxAttempts} attempts. Final slogan:\n\n{message.Slogan}", cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
Console.WriteLine("Sending back for refining");
|
||||
await context.SendMessageAsync(feedback, cancellationToken: cancellationToken);
|
||||
this._attempts++;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates how to run a CYCLIC WORKFLOW as a durable orchestration.
|
||||
// The workflow contains a loop: SloganWriter ⟷ FeedbackProvider
|
||||
//
|
||||
// WORKFLOW LOOP PATTERN:
|
||||
// 1. SloganWriter generates a slogan based on user input
|
||||
// 2. FeedbackProvider evaluates the slogan and provides feedback
|
||||
// 3. If the rating is below threshold, FeedbackProvider sends feedback back to SloganWriter
|
||||
// 4. SloganWriter improves the slogan based on feedback
|
||||
// 5. Loop continues until FeedbackProvider accepts the slogan (rating >= threshold)
|
||||
//
|
||||
// This demonstrates:
|
||||
// - Cyclic workflow support (back-edges in the graph)
|
||||
// - Multi-type executor handlers (SloganWriter handles both string and FeedbackResult)
|
||||
// - Message routing via SendMessageAsync for void-returning executors
|
||||
// - YieldOutputAsync for final output when the loop completes
|
||||
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI.DurableTask;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.DurableTask.Client.AzureManaged;
|
||||
using Microsoft.DurableTask.Worker.AzureManaged;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SingleAgent;
|
||||
using Azure.AI.OpenAI;
|
||||
|
||||
// Get DTS connection string from environment variable
|
||||
string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING")
|
||||
?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None";
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT") ?? "gpt-4o-mini";
|
||||
var chatClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetChatClient(deploymentName).AsIChatClient();
|
||||
|
||||
// Define executors for the workflow
|
||||
var sloganWriter = new SloganWriterExecutor("SloganWriter", chatClient);
|
||||
var feedbackProvider = new FeedbackExecutor("FeedbackProvider", chatClient);
|
||||
|
||||
// Build the workflow by adding executors and connecting them
|
||||
var workflow = new WorkflowBuilder(sloganWriter)
|
||||
.WithName("SloganCreationWorkflow")
|
||||
.AddEdge(sloganWriter, feedbackProvider)
|
||||
.AddEdge(feedbackProvider, sloganWriter)
|
||||
.WithOutputFrom(feedbackProvider)
|
||||
.Build();
|
||||
|
||||
IHost host = Host.CreateDefaultBuilder(args)
|
||||
.ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Warning))
|
||||
.ConfigureServices(services =>
|
||||
{
|
||||
services.ConfigureDurableWorkflows(
|
||||
options => options.Workflows.AddWorkflow(workflow),
|
||||
workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString),
|
||||
clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString));
|
||||
})
|
||||
.Build();
|
||||
|
||||
await host.StartAsync();
|
||||
|
||||
// Get the IWorkflowClient from DI - no need to manually resolve DurableTaskClient
|
||||
IWorkflowClient workflowClient = host.Services.GetRequiredService<IWorkflowClient>();
|
||||
|
||||
Console.WriteLine("Workflow Events Demo - Enter input for slogan generation (or 'exit'):");
|
||||
|
||||
while (true)
|
||||
{
|
||||
Console.Write("> ");
|
||||
string? input = Console.ReadLine();
|
||||
if (string.IsNullOrWhiteSpace(input) || input.Equals("exit", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await RunWorkflowWithStreamingAsync(input, workflow, workflowClient);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error: {ex.Message}");
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
await host.StopAsync();
|
||||
|
||||
// Runs a workflow and streams events as they occur
|
||||
async Task RunWorkflowWithStreamingAsync(string orderId, Workflow workflow, IWorkflowClient client)
|
||||
{
|
||||
// StreamAsync starts the workflow and returns a handle for observing events
|
||||
// Cast to DurableStreamingRun for durable-specific features like InstanceId
|
||||
await using DurableStreamingRun run = (DurableStreamingRun)await client.StreamAsync(workflow, orderId);
|
||||
Console.WriteLine($"Started: {run.InstanceId}");
|
||||
|
||||
// WatchStreamAsync yields events as they're emitted by executors
|
||||
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
|
||||
{
|
||||
// Always print the event type name
|
||||
WriteColored($" Event: {evt.GetType().Name}", ConsoleColor.Gray);
|
||||
}
|
||||
}
|
||||
|
||||
void WriteColored(string message, ConsoleColor color)
|
||||
{
|
||||
Console.ForegroundColor = color;
|
||||
Console.WriteLine(message);
|
||||
Console.ResetColor();
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
# Workflow Loop Sample
|
||||
|
||||
This sample demonstrates how to run a cyclic workflow (containing loops) as a durable orchestration.
|
||||
|
||||
## Overview
|
||||
|
||||
The workflow iteratively improves a slogan based on AI feedback until it meets quality criteria.
|
||||
|
||||
### Executors
|
||||
|
||||
- **SloganWriter** - Generates slogans using AI (handles string and FeedbackResult)
|
||||
- **FeedbackProvider** - Evaluates slogans (calls YieldOutput to accept, SendMessage to loop)
|
||||
|
||||
## Key Concepts
|
||||
|
||||
- Cyclic Workflow Support (back-edges)
|
||||
- Multi-Type Executor Handlers
|
||||
- Message Routing via SendMessageAsync
|
||||
- Workflow Termination via YieldOutputAsync
|
||||
|
||||
## Running
|
||||
|
||||
Set AZURE_OPENAI_ENDPOINT and run: dotnet run
|
||||
@@ -0,0 +1,26 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SingleAgent;
|
||||
|
||||
public sealed class SloganResult
|
||||
{
|
||||
[JsonPropertyName("task")]
|
||||
public required string Task { get; set; }
|
||||
|
||||
[JsonPropertyName("slogan")]
|
||||
public required string Slogan { get; set; }
|
||||
}
|
||||
|
||||
public sealed class FeedbackResult
|
||||
{
|
||||
[JsonPropertyName("comments")]
|
||||
public string Comments { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("rating")]
|
||||
public int Rating { get; set; }
|
||||
|
||||
[JsonPropertyName("actions")]
|
||||
public string Actions { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace SingleAgent;
|
||||
|
||||
internal sealed class SloganWriterExecutor : Executor
|
||||
{
|
||||
private readonly AIAgent _agent;
|
||||
private AgentThread? _thread;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SloganWriterExecutor"/> class.
|
||||
/// </summary>
|
||||
/// <param name="id">A unique identifier for the executor.</param>
|
||||
/// <param name="chatClient">The chat client to use for the AI agent.</param>
|
||||
public SloganWriterExecutor(string id, IChatClient chatClient) : base(id)
|
||||
{
|
||||
ChatClientAgentOptions agentOptions = new()
|
||||
{
|
||||
ChatOptions = new()
|
||||
{
|
||||
Instructions = "You are a professional slogan writer. You will be given a task to create a slogan.",
|
||||
ResponseFormat = ChatResponseFormat.ForJsonSchema<SloganResult>()
|
||||
}
|
||||
};
|
||||
|
||||
this._agent = new ChatClientAgent(chatClient, agentOptions);
|
||||
}
|
||||
|
||||
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
|
||||
routeBuilder.AddHandler<string, SloganResult>(this.HandleAsync)
|
||||
.AddHandler<FeedbackResult, SloganResult>(this.HandleAsync);
|
||||
|
||||
public async ValueTask<SloganResult> HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
this._thread ??= await this._agent.GetNewThreadAsync(cancellationToken);
|
||||
|
||||
var result = await this._agent.RunAsync(message, this._thread, cancellationToken: cancellationToken);
|
||||
|
||||
return JsonSerializer.Deserialize<SloganResult>(result.Text) ?? throw new InvalidOperationException("Failed to deserialize slogan result.");
|
||||
}
|
||||
|
||||
public async ValueTask<SloganResult> HandleAsync(FeedbackResult message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var feedbackMessage = $"""
|
||||
Here is the feedback on your previous slogan:
|
||||
Comments: {message.Comments}
|
||||
Rating: {message.Rating}
|
||||
Suggested Actions: {message.Actions}
|
||||
|
||||
Please use this feedback to improve your slogan.
|
||||
""";
|
||||
|
||||
var result = await this._agent.RunAsync(feedbackMessage, this._thread, cancellationToken: cancellationToken);
|
||||
return JsonSerializer.Deserialize<SloganResult>(result.Text) ?? throw new InvalidOperationException("Failed to deserialize slogan result.");
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@ This directory contains samples for console app hosting of durable agents. These
|
||||
- **[05_AgentOrchestration_HITL](05_AgentOrchestration_HITL)**: A sample that demonstrates how to implement a human-in-the-loop workflow using durable orchestration, including interactive approval prompts.
|
||||
- **[06_LongRunningTools](06_LongRunningTools)**: A sample that demonstrates how agents can start and interact with durable orchestrations from tool calls to enable long-running tool scenarios.
|
||||
- **[07_ReliableStreaming](07_ReliableStreaming)**: A sample that demonstrates how to implement reliable streaming for durable agents using Redis Streams, enabling clients to disconnect and reconnect without losing messages.
|
||||
- **[08_SingleWorkflow](08_SingleWorkflow)**: A sample that demonstrates how to run a simple workflow as a durable orchestration, showcasing activity durability and automatic resume on restart.
|
||||
- **[09_SubWorkflows](09_SubWorkflows)**: A sample that demonstrates how to compose workflows hierarchically using sub-workflows, which run as separate orchestration instances visible in the DTS dashboard.
|
||||
|
||||
## Running the Samples
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.DurableTask.Client.AzureManaged" />
|
||||
<PackageReference Include="Microsoft.DurableTask.Worker.AzureManaged" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Hosting.AzureFunctions\Microsoft.Agents.AI.Hosting.AzureFunctions.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,63 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
namespace WorkflowExecutorsAndEdgesSample;
|
||||
|
||||
/// <summary>
|
||||
/// This sample introduces the concepts of executors and edges in a workflow.
|
||||
///
|
||||
/// Workflows are built from executors (processing units) connected by edges (data flow paths).
|
||||
/// In this example, we create a simple text processing pipeline that:
|
||||
/// 1. Takes input text and converts it to uppercase using an UppercaseExecutor
|
||||
/// 2. Takes the uppercase text and reverses it using a ReverseTextExecutor
|
||||
///
|
||||
/// The executors are connected sequentially, so data flows from one to the next in order.
|
||||
/// For input "Hello, World!", the workflow produces "!DLROW ,OLLEH".
|
||||
/// </summary>
|
||||
public static class Program
|
||||
{
|
||||
private static async Task Main()
|
||||
{
|
||||
// Create the executors
|
||||
Func<string, string> uppercaseFunc = s => s.ToUpperInvariant();
|
||||
var uppercase = uppercaseFunc.BindAsExecutor("UppercaseExecutor");
|
||||
|
||||
ReverseTextExecutor reverse = new();
|
||||
|
||||
// Build the workflow by connecting executors sequentially
|
||||
WorkflowBuilder builder = new(uppercase);
|
||||
builder.AddEdge(uppercase, reverse).WithOutputFrom(reverse);
|
||||
var workflow = builder.Build();
|
||||
|
||||
// Execute the workflow with input data
|
||||
await using Run run = await InProcessExecution.RunAsync(workflow, "Hello, World!");
|
||||
foreach (WorkflowEvent evt in run.NewEvents)
|
||||
{
|
||||
if (evt is ExecutorCompletedEvent executorComplete)
|
||||
{
|
||||
Console.WriteLine($"{executorComplete.ExecutorId}: {executorComplete.Data}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Second executor: reverses the input text and completes the workflow.
|
||||
/// </summary>
|
||||
internal sealed class ReverseTextExecutor() : Executor<string, string>("ReverseTextExecutor")
|
||||
{
|
||||
/// <summary>
|
||||
/// Processes the input message by reversing the text.
|
||||
/// </summary>
|
||||
/// <param name="message">The input text to reverse</param>
|
||||
/// <param name="context">Workflow context for accessing workflow services and adding events</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.
|
||||
/// The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>The input text reversed</returns>
|
||||
public override ValueTask<string> HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Because we do not suppress it, the returned result will be yielded as an output from this executor.
|
||||
return ValueTask.FromResult(string.Concat(message.Reverse()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/* Visual Studio Settings File */
|
||||
{
|
||||
"environment.visualExperience.colorTheme": "dark-plus"
|
||||
}
|
||||
@@ -216,8 +216,7 @@ internal class AgentEntity(IServiceProvider services, CancellationToken cancella
|
||||
|
||||
private AIAgent GetAgent(AgentSessionId sessionId)
|
||||
{
|
||||
IReadOnlyDictionary<string, Func<IServiceProvider, AIAgent>> agents =
|
||||
this._services.GetRequiredService<IReadOnlyDictionary<string, Func<IServiceProvider, AIAgent>>>();
|
||||
IReadOnlyDictionary<string, Func<IServiceProvider, AIAgent>> agents = this._options.GetAgentFactories();
|
||||
if (!agents.TryGetValue(sessionId.Name, out Func<IServiceProvider, AIAgent>? agentFactory))
|
||||
{
|
||||
throw new InvalidOperationException($"Agent '{sessionId.Name}' not found");
|
||||
|
||||
@@ -10,8 +10,12 @@ public sealed class DurableAgentsOptions
|
||||
// Agent names are case-insensitive
|
||||
private readonly Dictionary<string, Func<IServiceProvider, AIAgent>> _agentFactories = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, TimeSpan?> _agentTimeToLive = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly HashSet<string> _workflowOnlyAgents = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
internal DurableAgentsOptions()
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DurableAgentsOptions"/> class.
|
||||
/// </summary>
|
||||
public DurableAgentsOptions()
|
||||
{
|
||||
}
|
||||
|
||||
@@ -101,6 +105,22 @@ public sealed class DurableAgentsOptions
|
||||
/// Thrown when <paramref name="agent.Name"/> is null or whitespace or when an agent with the same name has already been registered.
|
||||
/// </exception>
|
||||
public DurableAgentsOptions AddAIAgent(AIAgent agent, TimeSpan? timeToLive = null)
|
||||
{
|
||||
return this.AddAIAgent(agent, workflowOnly: false, timeToLive);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an AI agent to the options with workflow-only configuration.
|
||||
/// </summary>
|
||||
/// <param name="agent">The agent to add.</param>
|
||||
/// <param name="workflowOnly">If true, the agent is only accessible within workflows and won't have HTTP triggers.</param>
|
||||
/// <param name="timeToLive">Optional time-to-live for this agent's entities. If not specified, uses <see cref="DefaultTimeToLive"/>.</param>
|
||||
/// <returns>The options instance.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="agent"/> is null.</exception>
|
||||
/// <exception cref="ArgumentException">
|
||||
/// Thrown when <paramref name="agent.Name"/> is null or whitespace or when an agent with the same name has already been registered.
|
||||
/// </exception>
|
||||
public DurableAgentsOptions AddAIAgent(AIAgent agent, bool workflowOnly, TimeSpan? timeToLive = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(agent);
|
||||
|
||||
@@ -120,6 +140,11 @@ public sealed class DurableAgentsOptions
|
||||
this._agentTimeToLive[agent.Name] = timeToLive;
|
||||
}
|
||||
|
||||
if (workflowOnly)
|
||||
{
|
||||
this._workflowOnlyAgents.Add(agent.Name);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -141,4 +166,24 @@ public sealed class DurableAgentsOptions
|
||||
{
|
||||
return this._agentTimeToLive.TryGetValue(agentName, out TimeSpan? ttl) ? ttl : this.DefaultTimeToLive;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether an agent is configured as workflow-only (no HTTP triggers).
|
||||
/// </summary>
|
||||
/// <param name="agentName">The name of the agent.</param>
|
||||
/// <returns><see langword="true"/> if the agent is workflow-only; otherwise, <see langword="false"/>.</returns>
|
||||
internal bool IsWorkflowOnly(string agentName)
|
||||
{
|
||||
return this._workflowOnlyAgents.Contains(agentName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether an agent with the specified name is already registered.
|
||||
/// </summary>
|
||||
/// <param name="agentName">The name of the agent.</param>
|
||||
/// <returns><see langword="true"/> if an agent with the name is registered; otherwise, <see langword="false"/>.</returns>
|
||||
internal bool ContainsAgent(string agentName)
|
||||
{
|
||||
return this._agentFactories.ContainsKey(agentName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask;
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when an executor requests the workflow to halt via <see cref="IWorkflowContext.RequestHaltAsync"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is the durable equivalent of the internal RequestHaltEvent since that class is not accessible
|
||||
/// from outside the Workflows assembly.
|
||||
/// </remarks>
|
||||
public sealed class DurableHaltRequestedEvent : WorkflowEvent
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DurableHaltRequestedEvent"/> class.
|
||||
/// </summary>
|
||||
/// <param name="executorId">The ID of the executor that requested the halt.</param>
|
||||
public DurableHaltRequestedEvent(string executorId) : base($"Halt requested by {executorId}")
|
||||
{
|
||||
this.ExecutorId = executorId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the ID of the executor that requested the halt.
|
||||
/// </summary>
|
||||
public string ExecutorId { get; }
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask;
|
||||
|
||||
/// <summary>
|
||||
/// Provides configuration options for durable agents and workflows.
|
||||
/// </summary>
|
||||
[DebuggerDisplay("Workflows = {Workflows.Workflows.Count}, Agents = {Agents.AgentCount}")]
|
||||
public sealed class DurableOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the configuration options for durable agents.
|
||||
/// </summary>
|
||||
public DurableAgentsOptions Agents { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the configuration options for durable workflows.
|
||||
/// </summary>
|
||||
public DurableWorkflowOptions Workflows { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DurableOptions"/> class.
|
||||
/// </summary>
|
||||
internal DurableOptions()
|
||||
{
|
||||
this.Workflows = new DurableWorkflowOptions(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask;
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when the durable workflow is waiting for external input at a <see cref="RequestPort"/>.
|
||||
/// </summary>
|
||||
/// <param name="RequestPortId">The ID of the request port waiting for input.</param>
|
||||
/// <param name="Input">The serialized input data that was passed to the RequestPort.</param>
|
||||
/// <param name="RequestType">The full type name of the request type.</param>
|
||||
/// <param name="ResponseType">The full type name of the expected response type.</param>
|
||||
/// <param name="RequestPort">The request port definition, if available.</param>
|
||||
[DebuggerDisplay("RequestPort = {RequestPortId}")]
|
||||
public sealed class DurableRequestInfoEvent(
|
||||
string RequestPortId,
|
||||
string Input,
|
||||
string RequestType,
|
||||
string ResponseType,
|
||||
RequestPort? RequestPort) : WorkflowEvent(Input)
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the ID of the request port waiting for input.
|
||||
/// </summary>
|
||||
public string RequestPortId { get; } = RequestPortId;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the serialized input data that was passed to the RequestPort.
|
||||
/// </summary>
|
||||
public string Input { get; } = Input;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the full type name of the request type.
|
||||
/// </summary>
|
||||
public string RequestType { get; } = RequestType;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the full type name of the expected response type.
|
||||
/// </summary>
|
||||
public string ResponseType { get; } = ResponseType;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the request port definition, if available.
|
||||
/// </summary>
|
||||
public RequestPort? RequestPort { get; } = RequestPort;
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to deserialize the input data to the specified type.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type to deserialize to.</typeparam>
|
||||
/// <returns>The deserialized input, or default if deserialization fails.</returns>
|
||||
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow types provided by the caller.")]
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow types provided by the caller.")]
|
||||
public T? GetInputAs<T>()
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<T>(this.Input);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.DurableTask.Client;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a durable workflow run that tracks execution status and provides access to workflow events.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This class provides a similar API to <see cref="Run"/> but for workflows executed as durable orchestrations.
|
||||
/// Events are received by raising external events to the orchestration and can be streamed to the caller.
|
||||
/// </remarks>
|
||||
[DebuggerDisplay("{WorkflowName} ({InstanceId})")]
|
||||
public sealed class DurableRun : IRun
|
||||
{
|
||||
private readonly DurableTaskClient _client;
|
||||
private readonly List<WorkflowEvent> _eventSink = [];
|
||||
private int _lastBookmark;
|
||||
|
||||
internal DurableRun(DurableTaskClient client, string instanceId, string workflowName)
|
||||
{
|
||||
this._client = client;
|
||||
this.InstanceId = instanceId;
|
||||
this.WorkflowName = workflowName;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the unique instance ID for this orchestration run.
|
||||
/// </summary>
|
||||
public string InstanceId { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string RunId => this.InstanceId;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the name of the workflow being executed.
|
||||
/// </summary>
|
||||
public string WorkflowName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current execution status of the workflow run.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">A cancellation token to observe.</param>
|
||||
/// <returns>The current status of the durable run.</returns>
|
||||
public async ValueTask<DurableRunStatus> GetStatusAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
OrchestrationMetadata? metadata = await this._client.GetInstanceAsync(
|
||||
this.InstanceId,
|
||||
getInputsAndOutputs: false,
|
||||
cancellation: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (metadata is null)
|
||||
{
|
||||
return DurableRunStatus.NotFound;
|
||||
}
|
||||
|
||||
return metadata.RuntimeStatus switch
|
||||
{
|
||||
OrchestrationRuntimeStatus.Pending => DurableRunStatus.Pending,
|
||||
OrchestrationRuntimeStatus.Running => DurableRunStatus.Running,
|
||||
OrchestrationRuntimeStatus.Completed => DurableRunStatus.Completed,
|
||||
OrchestrationRuntimeStatus.Failed => DurableRunStatus.Failed,
|
||||
OrchestrationRuntimeStatus.Terminated => DurableRunStatus.Terminated,
|
||||
OrchestrationRuntimeStatus.Suspended => DurableRunStatus.Suspended,
|
||||
_ => DurableRunStatus.Unknown
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits for the workflow to complete and returns the result.
|
||||
/// </summary>
|
||||
/// <typeparam name="TResult">The expected result type.</typeparam>
|
||||
/// <param name="cancellationToken">A cancellation token to observe.</param>
|
||||
/// <returns>The result of the workflow execution.</returns>
|
||||
/// <exception cref="InvalidOperationException">Thrown when the workflow failed or was terminated.</exception>
|
||||
public async ValueTask<TResult?> WaitForCompletionAsync<TResult>(CancellationToken cancellationToken = default)
|
||||
{
|
||||
OrchestrationMetadata metadata = await this._client.WaitForInstanceCompletionAsync(
|
||||
this.InstanceId,
|
||||
getInputsAndOutputs: true,
|
||||
cancellation: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (metadata.RuntimeStatus == OrchestrationRuntimeStatus.Completed)
|
||||
{
|
||||
return metadata.ReadOutputAs<TResult>();
|
||||
}
|
||||
|
||||
if (metadata.RuntimeStatus == OrchestrationRuntimeStatus.Failed)
|
||||
{
|
||||
string errorMessage = metadata.FailureDetails?.ErrorMessage ?? "Workflow execution failed.";
|
||||
throw new InvalidOperationException(errorMessage);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Workflow ended with unexpected status: {metadata.RuntimeStatus}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits for the workflow to complete and returns the string result.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">A cancellation token to observe.</param>
|
||||
/// <returns>The string result of the workflow execution.</returns>
|
||||
public ValueTask<string?> WaitForCompletionAsync(CancellationToken cancellationToken = default)
|
||||
=> this.WaitForCompletionAsync<string>(cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Sends an external event to the workflow orchestration.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This can be used to send responses or messages to the workflow while it's running.
|
||||
/// The orchestration must be waiting for the event using <c>WaitForExternalEvent</c>.
|
||||
/// </remarks>
|
||||
/// <param name="eventName">The name of the event to raise.</param>
|
||||
/// <param name="eventData">The data to send with the event.</param>
|
||||
/// <param name="cancellationToken">A cancellation token to observe.</param>
|
||||
#pragma warning disable CA1030 // Use events where appropriate - This is intentionally a method that sends events to an orchestration
|
||||
internal async ValueTask SendExternalEventAsync(string eventName, object? eventData = null, CancellationToken cancellationToken = default)
|
||||
#pragma warning restore CA1030
|
||||
{
|
||||
await this._client.RaiseEventAsync(
|
||||
this.InstanceId,
|
||||
eventName,
|
||||
eventData,
|
||||
cancellation: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a workflow event to the orchestration.
|
||||
/// </summary>
|
||||
/// <param name="workflowEvent">The workflow event to send.</param>
|
||||
/// <param name="cancellationToken">A cancellation token to observe.</param>
|
||||
internal ValueTask SendEventAsync(WorkflowEvent workflowEvent, CancellationToken cancellationToken = default)
|
||||
=> this.SendExternalEventAsync("WorkflowEvent", workflowEvent, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Sends an external response to the workflow.
|
||||
/// </summary>
|
||||
/// <param name="response">The external response to send.</param>
|
||||
/// <param name="cancellationToken">A cancellation token to observe.</param>
|
||||
public ValueTask SendResponseAsync(ExternalResponse response, CancellationToken cancellationToken = default)
|
||||
=> this.SendExternalEventAsync("ExternalResponse", response, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Sends a response to a pending request port in the workflow (human-in-the-loop).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The response is serialized to JSON before being sent to match what the orchestration expects.
|
||||
/// Use this method when responding to a <see cref="RequestPort"/> that is waiting for external input.
|
||||
/// </remarks>
|
||||
/// <typeparam name="TResponse">The type of the response data.</typeparam>
|
||||
/// <param name="requestPortId">The ID of the request port to respond to.</param>
|
||||
/// <param name="response">The response data to send.</param>
|
||||
/// <param name="cancellationToken">A cancellation token to observe.</param>
|
||||
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing workflow types provided by the caller.")]
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing workflow types provided by the caller.")]
|
||||
public ValueTask SendResponseAsync<TResponse>(string requestPortId, TResponse response, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(requestPortId);
|
||||
|
||||
// Serialize the response to JSON string - the orchestration expects a string via WaitForExternalEvent<string>
|
||||
string serializedResponse = JsonSerializer.Serialize(response);
|
||||
return this.SendExternalEventAsync(requestPortId, serializedResponse, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all events that have been collected from the workflow.
|
||||
/// </summary>
|
||||
public IEnumerable<WorkflowEvent> OutgoingEvents => this._eventSink;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of events collected since the last access to <see cref="NewEvents"/>.
|
||||
/// </summary>
|
||||
public int NewEventCount => this._eventSink.Count - this._lastBookmark;
|
||||
|
||||
/// <summary>
|
||||
/// Gets all events collected since the last access to <see cref="NewEvents"/>.
|
||||
/// </summary>
|
||||
public IEnumerable<WorkflowEvent> NewEvents
|
||||
{
|
||||
get
|
||||
{
|
||||
if (this._lastBookmark >= this._eventSink.Count)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
int currentBookmark = this._lastBookmark;
|
||||
this._lastBookmark = this._eventSink.Count;
|
||||
|
||||
return this._eventSink.Skip(currentBookmark);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an event to the local event sink.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is used internally to collect events raised by the orchestration.
|
||||
/// In the durable scenario, events are typically returned as part of the orchestration output
|
||||
/// or raised via external events.
|
||||
/// </remarks>
|
||||
/// <param name="workflowEvent">The event to add.</param>
|
||||
internal void AddEvent(WorkflowEvent workflowEvent)
|
||||
{
|
||||
this._eventSink.Add(workflowEvent);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Terminates the workflow orchestration.
|
||||
/// </summary>
|
||||
/// <param name="reason">An optional reason for the termination.</param>
|
||||
/// <param name="cancellationToken">A cancellation token to observe.</param>
|
||||
public async ValueTask TerminateAsync(string? reason = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await this._client.TerminateInstanceAsync(
|
||||
this.InstanceId,
|
||||
reason,
|
||||
cancellation: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Purges the orchestration instance history.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">A cancellation token to observe.</param>
|
||||
public async ValueTask PurgeAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await this._client.PurgeInstanceAsync(
|
||||
this.InstanceId,
|
||||
cancellation: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
// Nothing to dispose for durable runs - the orchestration continues independently
|
||||
return default;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the execution status of a durable workflow run.
|
||||
/// </summary>
|
||||
public enum DurableRunStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// The orchestration instance was not found.
|
||||
/// </summary>
|
||||
NotFound,
|
||||
|
||||
/// <summary>
|
||||
/// The orchestration is pending and has not started.
|
||||
/// </summary>
|
||||
Pending,
|
||||
|
||||
/// <summary>
|
||||
/// The orchestration is currently running.
|
||||
/// </summary>
|
||||
Running,
|
||||
|
||||
/// <summary>
|
||||
/// The orchestration completed successfully.
|
||||
/// </summary>
|
||||
Completed,
|
||||
|
||||
/// <summary>
|
||||
/// The orchestration failed with an error.
|
||||
/// </summary>
|
||||
Failed,
|
||||
|
||||
/// <summary>
|
||||
/// The orchestration was terminated.
|
||||
/// </summary>
|
||||
Terminated,
|
||||
|
||||
/// <summary>
|
||||
/// The orchestration is suspended.
|
||||
/// </summary>
|
||||
Suspended,
|
||||
|
||||
/// <summary>
|
||||
/// The orchestration status is unknown.
|
||||
/// </summary>
|
||||
Unknown
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.DurableTask.Client;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a durable workflow run that supports streaming workflow events as they occur.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This class provides a similar API to <see cref="StreamingRun"/> but for workflows executed as durable orchestrations.
|
||||
/// Events are detected by monitoring the orchestration status for <see cref="RequestPort"/> executors that are waiting
|
||||
/// for external input (human-in-the-loop scenarios).
|
||||
/// </remarks>
|
||||
[DebuggerDisplay("{WorkflowName} ({InstanceId})")]
|
||||
public sealed class DurableStreamingRun : IStreamingRun
|
||||
{
|
||||
private readonly DurableTaskClient _client;
|
||||
private readonly Workflow _workflow;
|
||||
private readonly List<RequestPort> _requestPorts;
|
||||
|
||||
internal DurableStreamingRun(DurableTaskClient client, string instanceId, Workflow workflow)
|
||||
{
|
||||
this._client = client;
|
||||
this.InstanceId = instanceId;
|
||||
this._workflow = workflow;
|
||||
|
||||
// Extract RequestPorts from the workflow for event detection
|
||||
this._requestPorts = ExtractRequestPorts(workflow);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the unique instance ID for this orchestration run.
|
||||
/// </summary>
|
||||
public string InstanceId { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string RunId => this.InstanceId;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the name of the workflow being executed.
|
||||
/// </summary>
|
||||
public string WorkflowName => this._workflow.Name ?? string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the request ports defined in the workflow.
|
||||
/// </summary>
|
||||
public IReadOnlyList<RequestPort> RequestPorts => this._requestPorts;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current execution status of the workflow run.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">A cancellation token to observe.</param>
|
||||
/// <returns>The current status of the durable run.</returns>
|
||||
public async ValueTask<DurableRunStatus> GetStatusAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
OrchestrationMetadata? metadata = await this._client.GetInstanceAsync(
|
||||
this.InstanceId,
|
||||
getInputsAndOutputs: false,
|
||||
cancellation: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (metadata is null)
|
||||
{
|
||||
return DurableRunStatus.NotFound;
|
||||
}
|
||||
|
||||
return metadata.RuntimeStatus switch
|
||||
{
|
||||
OrchestrationRuntimeStatus.Pending => DurableRunStatus.Pending,
|
||||
OrchestrationRuntimeStatus.Running => DurableRunStatus.Running,
|
||||
OrchestrationRuntimeStatus.Completed => DurableRunStatus.Completed,
|
||||
OrchestrationRuntimeStatus.Failed => DurableRunStatus.Failed,
|
||||
OrchestrationRuntimeStatus.Terminated => DurableRunStatus.Terminated,
|
||||
OrchestrationRuntimeStatus.Suspended => DurableRunStatus.Suspended,
|
||||
_ => DurableRunStatus.Unknown
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IAsyncEnumerable<WorkflowEvent> WatchStreamAsync(CancellationToken cancellationToken = default)
|
||||
=> this.WatchStreamAsync(pollingInterval: null, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Asynchronously streams workflow events as they occur during workflow execution.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This method monitors the durable orchestration and yields <see cref="WorkflowEvent"/> instances
|
||||
/// when the workflow reaches points that require external input (human-in-the-loop scenarios).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When the orchestration reaches a <see cref="RequestPort"/> executor, a <see cref="DurableRequestInfoEvent"/>
|
||||
/// is yielded containing the request data. The caller should then call <see cref="SendResponseAsync{TResponse}(DurableRequestInfoEvent, TResponse, CancellationToken)"/>
|
||||
/// to provide the response and continue the workflow.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="pollingInterval">The interval between status checks. Defaults to 500ms.</param>
|
||||
/// <param name="cancellationToken">A cancellation token to observe.</param>
|
||||
/// <returns>An asynchronous stream of <see cref="WorkflowEvent"/> objects.</returns>
|
||||
public async IAsyncEnumerable<WorkflowEvent> WatchStreamAsync(
|
||||
TimeSpan? pollingInterval,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
TimeSpan interval = pollingInterval ?? TimeSpan.FromMilliseconds(500);
|
||||
|
||||
// Track which request ports we've already yielded events for and are waiting for response
|
||||
// Key: EventName (RequestPort ID), Value: Input data (to detect if we're at a different invocation)
|
||||
Dictionary<string, string> pendingRequests = [];
|
||||
|
||||
// Track how many events we've already read from custom status
|
||||
int lastReadEventIndex = 0;
|
||||
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
OrchestrationMetadata? metadata = await this._client.GetInstanceAsync(
|
||||
this.InstanceId,
|
||||
getInputsAndOutputs: true,
|
||||
cancellation: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (metadata is null)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
// Check if the orchestration has completed
|
||||
if (metadata.RuntimeStatus == OrchestrationRuntimeStatus.Completed)
|
||||
{
|
||||
yield return new DurableWorkflowCompletedEvent(metadata.SerializedOutput);
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (metadata.RuntimeStatus == OrchestrationRuntimeStatus.Failed)
|
||||
{
|
||||
string errorMessage = metadata.FailureDetails?.ErrorMessage ?? "Workflow execution failed.";
|
||||
yield return new DurableWorkflowFailedEvent(errorMessage);
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (metadata.RuntimeStatus == OrchestrationRuntimeStatus.Terminated)
|
||||
{
|
||||
yield return new DurableWorkflowFailedEvent("Workflow was terminated.");
|
||||
yield break;
|
||||
}
|
||||
|
||||
// Check custom status for events and pending external events
|
||||
if (metadata.SerializedCustomStatus is not null)
|
||||
{
|
||||
DurableWorkflowCustomStatus? customStatus = TryParseCustomStatus(metadata.SerializedCustomStatus);
|
||||
if (customStatus is not null)
|
||||
{
|
||||
// Yield any new events from executors
|
||||
while (lastReadEventIndex < customStatus.Events.Count)
|
||||
{
|
||||
string serializedEvent = customStatus.Events[lastReadEventIndex];
|
||||
lastReadEventIndex++;
|
||||
|
||||
WorkflowEvent? workflowEvent = TryDeserializeEvent(serializedEvent);
|
||||
if (workflowEvent is not null)
|
||||
{
|
||||
yield return workflowEvent;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for pending external event (HITL)
|
||||
if (customStatus.PendingEvent is not null)
|
||||
{
|
||||
PendingExternalEventStatus pendingStatus = customStatus.PendingEvent;
|
||||
string eventName = pendingStatus.EventName;
|
||||
string inputData = pendingStatus.Input;
|
||||
|
||||
// Only yield a new event if:
|
||||
// 1. We haven't seen this event name before, OR
|
||||
// 2. The input data is different (meaning this is a new invocation of the same RequestPort)
|
||||
bool shouldYield = !pendingRequests.TryGetValue(eventName, out string? previousInput)
|
||||
|| previousInput != inputData;
|
||||
|
||||
if (shouldYield)
|
||||
{
|
||||
pendingRequests[eventName] = inputData;
|
||||
|
||||
// Find the matching RequestPort
|
||||
RequestPort? requestPort = this._requestPorts.Find(p => p.Id == eventName);
|
||||
|
||||
yield return new DurableRequestInfoEvent(
|
||||
RequestPortId: eventName,
|
||||
Input: inputData,
|
||||
RequestType: pendingStatus.RequestType,
|
||||
ResponseType: pendingStatus.ResponseType,
|
||||
RequestPort: requestPort);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Try parsing as legacy PendingExternalEventStatus for backward compatibility
|
||||
PendingExternalEventStatus? pendingStatus = TryParsePendingStatus(metadata.SerializedCustomStatus);
|
||||
if (pendingStatus is not null)
|
||||
{
|
||||
string eventName = pendingStatus.EventName;
|
||||
string inputData = pendingStatus.Input;
|
||||
|
||||
bool shouldYield = !pendingRequests.TryGetValue(eventName, out string? previousInput)
|
||||
|| previousInput != inputData;
|
||||
|
||||
if (shouldYield)
|
||||
{
|
||||
pendingRequests[eventName] = inputData;
|
||||
RequestPort? requestPort = this._requestPorts.Find(p => p.Id == eventName);
|
||||
|
||||
yield return new DurableRequestInfoEvent(
|
||||
RequestPortId: eventName,
|
||||
Input: inputData,
|
||||
RequestType: pendingStatus.RequestType,
|
||||
ResponseType: pendingStatus.ResponseType,
|
||||
RequestPort: requestPort);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Custom status is null - the orchestration is not waiting for external input
|
||||
// Clear any pending requests that were waiting (they've been processed)
|
||||
pendingRequests.Clear();
|
||||
}
|
||||
|
||||
await Task.Delay(interval, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow custom status.")]
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow custom status.")]
|
||||
private static DurableWorkflowCustomStatus? TryParseCustomStatus(string serializedStatus)
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<DurableWorkflowCustomStatus>(serializedStatus);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow event types.")]
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow event types.")]
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2057", Justification = "Event types are registered at startup and available at runtime.")]
|
||||
private static WorkflowEvent? TryDeserializeEvent(string serializedEvent)
|
||||
{
|
||||
try
|
||||
{
|
||||
// First try to deserialize as SerializedWorkflowEvent (new format with type info)
|
||||
SerializedWorkflowEvent? wrapper =
|
||||
JsonSerializer.Deserialize<SerializedWorkflowEvent>(serializedEvent);
|
||||
|
||||
if (wrapper?.TypeName is not null && wrapper.Data is not null)
|
||||
{
|
||||
Type? eventType = Type.GetType(wrapper.TypeName);
|
||||
if (eventType is not null)
|
||||
{
|
||||
// Use custom deserialization for event types with constructor parameter mismatches
|
||||
return DeserializeEventByType(eventType, wrapper.Data);
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to deserializing as base WorkflowEvent (legacy format)
|
||||
return JsonSerializer.Deserialize<WorkflowEvent>(serializedEvent);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes an event by type, handling constructor parameter name mismatches.
|
||||
/// </summary>
|
||||
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow event types.")]
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow event types.")]
|
||||
private static WorkflowEvent? DeserializeEventByType(Type eventType, string json)
|
||||
{
|
||||
using JsonDocument doc = JsonDocument.Parse(json);
|
||||
JsonElement root = doc.RootElement;
|
||||
|
||||
// Handle ExecutorInvokedEvent: constructor expects (executorId, message) but JSON has (ExecutorId, Data)
|
||||
if (eventType == typeof(ExecutorInvokedEvent))
|
||||
{
|
||||
string executorId = root.GetProperty("ExecutorId").GetString() ?? string.Empty;
|
||||
JsonElement? data = GetDataProperty(root);
|
||||
return new ExecutorInvokedEvent(executorId, data!);
|
||||
}
|
||||
|
||||
// Handle ExecutorCompletedEvent: constructor expects (executorId, result) but JSON has (ExecutorId, Data)
|
||||
if (eventType == typeof(ExecutorCompletedEvent))
|
||||
{
|
||||
string executorId = root.GetProperty("ExecutorId").GetString() ?? string.Empty;
|
||||
JsonElement? data = GetDataProperty(root);
|
||||
return new ExecutorCompletedEvent(executorId, data);
|
||||
}
|
||||
|
||||
// For other event types, try standard deserialization with case-insensitive options
|
||||
return JsonSerializer.Deserialize(json, eventType, s_caseInsensitiveOptions) as WorkflowEvent;
|
||||
}
|
||||
|
||||
// Cached JsonSerializerOptions for case-insensitive deserialization
|
||||
private static readonly JsonSerializerOptions s_caseInsensitiveOptions = new() { PropertyNameCaseInsensitive = true };
|
||||
|
||||
/// <summary>
|
||||
/// Gets the Data property from a JSON element.
|
||||
/// </summary>
|
||||
private static JsonElement? GetDataProperty(JsonElement root)
|
||||
{
|
||||
if (!root.TryGetProperty("Data", out JsonElement dataElement))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (dataElement.ValueKind == JsonValueKind.Null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return dataElement.Clone();
|
||||
}
|
||||
|
||||
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing known type PendingExternalEventStatus.")]
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing known type PendingExternalEventStatus.")]
|
||||
private static PendingExternalEventStatus? TryParsePendingStatus(string serializedStatus)
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<PendingExternalEventStatus>(serializedStatus);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends an external event to the workflow orchestration.
|
||||
/// </summary>
|
||||
/// <param name="eventName">The name of the event to raise (typically the <see cref="RequestPort.Id"/>).</param>
|
||||
/// <param name="eventData">The data to send with the event.</param>
|
||||
/// <param name="cancellationToken">A cancellation token to observe.</param>
|
||||
#pragma warning disable CA1030 // Use events where appropriate
|
||||
public async ValueTask SendExternalEventAsync(string eventName, object? eventData = null, CancellationToken cancellationToken = default)
|
||||
#pragma warning restore CA1030
|
||||
{
|
||||
await this._client.RaiseEventAsync(
|
||||
this.InstanceId,
|
||||
eventName,
|
||||
eventData,
|
||||
cancellation: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public ValueTask SendResponseAsync(ExternalResponse response, CancellationToken cancellationToken = default)
|
||||
=> this.SendExternalEventAsync("ExternalResponse", response, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Sends a response to a pending request in the workflow.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The response is serialized to JSON before being sent to match what the orchestration expects.
|
||||
/// </remarks>
|
||||
/// <typeparam name="TResponse">The type of the response data.</typeparam>
|
||||
/// <param name="requestPortId">The ID of the request port to respond to.</param>
|
||||
/// <param name="response">The response data to send.</param>
|
||||
/// <param name="cancellationToken">A cancellation token to observe.</param>
|
||||
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing workflow types provided by the caller.")]
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing workflow types provided by the caller.")]
|
||||
public ValueTask SendResponseAsync<TResponse>(string requestPortId, TResponse response, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(requestPortId);
|
||||
|
||||
// Serialize the response to JSON string - the orchestration expects a string via WaitForExternalEvent<string>
|
||||
string serializedResponse = JsonSerializer.Serialize(response);
|
||||
return this.SendExternalEventAsync(requestPortId, serializedResponse, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a response to a <see cref="DurableRequestInfoEvent"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The response is serialized to JSON before being sent to match what the orchestration expects.
|
||||
/// </remarks>
|
||||
/// <typeparam name="TResponse">The type of the response data.</typeparam>
|
||||
/// <param name="requestEvent">The request event to respond to.</param>
|
||||
/// <param name="response">The response data to send.</param>
|
||||
/// <param name="cancellationToken">A cancellation token to observe.</param>
|
||||
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing workflow types provided by the caller.")]
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing workflow types provided by the caller.")]
|
||||
public ValueTask SendResponseAsync<TResponse>(DurableRequestInfoEvent requestEvent, TResponse response, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(requestEvent);
|
||||
|
||||
// Serialize the response to JSON string - the orchestration expects a string via WaitForExternalEvent<string>
|
||||
string serializedResponse = JsonSerializer.Serialize(response);
|
||||
return this.SendExternalEventAsync(requestEvent.RequestPortId, serializedResponse, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits for the workflow to complete and returns the result.
|
||||
/// </summary>
|
||||
/// <typeparam name="TResult">The expected result type.</typeparam>
|
||||
/// <param name="cancellationToken">A cancellation token to observe.</param>
|
||||
/// <returns>The result of the workflow execution.</returns>
|
||||
public async ValueTask<TResult?> WaitForCompletionAsync<TResult>(CancellationToken cancellationToken = default)
|
||||
{
|
||||
OrchestrationMetadata metadata = await this._client.WaitForInstanceCompletionAsync(
|
||||
this.InstanceId,
|
||||
getInputsAndOutputs: true,
|
||||
cancellation: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (metadata.RuntimeStatus == OrchestrationRuntimeStatus.Completed)
|
||||
{
|
||||
return metadata.ReadOutputAs<TResult>();
|
||||
}
|
||||
|
||||
if (metadata.RuntimeStatus == OrchestrationRuntimeStatus.Failed)
|
||||
{
|
||||
string errorMessage = metadata.FailureDetails?.ErrorMessage ?? "Workflow execution failed.";
|
||||
throw new InvalidOperationException(errorMessage);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Workflow ended with unexpected status: {metadata.RuntimeStatus}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits for the workflow to complete and returns the string result.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">A cancellation token to observe.</param>
|
||||
/// <returns>The string result of the workflow execution.</returns>
|
||||
public ValueTask<string?> WaitForCompletionAsync(CancellationToken cancellationToken = default)
|
||||
=> this.WaitForCompletionAsync<string>(cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Terminates the workflow orchestration.
|
||||
/// </summary>
|
||||
/// <param name="reason">An optional reason for the termination.</param>
|
||||
/// <param name="cancellationToken">A cancellation token to observe.</param>
|
||||
public async ValueTask TerminateAsync(string? reason = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await this._client.TerminateInstanceAsync(
|
||||
this.InstanceId,
|
||||
reason,
|
||||
cancellation: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
// Nothing to dispose for durable runs - the orchestration continues independently
|
||||
return default;
|
||||
}
|
||||
|
||||
private static List<RequestPort> ExtractRequestPorts(Workflow workflow)
|
||||
{
|
||||
List<RequestPort> requestPorts = [];
|
||||
|
||||
foreach (WorkflowExecutorInfo executorInfo in WorkflowHelper.GetExecutorsFromWorkflowInOrder(workflow))
|
||||
{
|
||||
if (executorInfo.RequestPort is not null)
|
||||
{
|
||||
requestPorts.Add(executorInfo.RequestPort);
|
||||
}
|
||||
}
|
||||
|
||||
return requestPorts;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.DurableTask.Client;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask;
|
||||
|
||||
/// <summary>
|
||||
/// Provides methods to run workflows as durable orchestrations.
|
||||
/// </summary>
|
||||
internal static class DurableWorkflow
|
||||
{
|
||||
/// <summary>
|
||||
/// Runs a workflow as a durable orchestration and returns a handle to monitor its execution.
|
||||
/// </summary>
|
||||
/// <typeparam name="TInput">The type of the input to the workflow.</typeparam>
|
||||
/// <param name="workflow">The workflow to execute.</param>
|
||||
/// <param name="input">The input to pass to the workflow's starting executor.</param>
|
||||
/// <param name="client">The durable task client for orchestration operations.</param>
|
||||
/// <param name="instanceId">Optional instance ID for the orchestration. If not provided, a new ID will be generated.</param>
|
||||
/// <param name="cancellationToken">A cancellation token to observe.</param>
|
||||
/// <returns>An <see cref="IRun"/> that can be used to monitor the workflow execution.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when workflow or client is null.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when the workflow does not have a valid name.</exception>
|
||||
public static async ValueTask<IRun> RunAsync<TInput>(
|
||||
Workflow workflow,
|
||||
TInput input,
|
||||
DurableTaskClient client,
|
||||
string? instanceId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
where TInput : notnull
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(workflow);
|
||||
ArgumentNullException.ThrowIfNull(client);
|
||||
|
||||
if (string.IsNullOrEmpty(workflow.Name))
|
||||
{
|
||||
throw new ArgumentException("Workflow must have a valid Name property.", nameof(workflow));
|
||||
}
|
||||
|
||||
string orchestrationName = WorkflowNamingHelper.ToOrchestrationFunctionName(workflow.Name);
|
||||
string actualInstanceId = await client.ScheduleNewOrchestrationInstanceAsync(
|
||||
orchestratorName: orchestrationName,
|
||||
input: input,
|
||||
cancellation: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new DurableRun(client, actualInstanceId, workflow.Name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs a workflow as a durable orchestration with string input.
|
||||
/// </summary>
|
||||
/// <param name="workflow">The workflow to execute.</param>
|
||||
/// <param name="input">The string input to pass to the workflow.</param>
|
||||
/// <param name="client">The durable task client for orchestration operations.</param>
|
||||
/// <param name="instanceId">Optional instance ID for the orchestration.</param>
|
||||
/// <param name="cancellationToken">A cancellation token to observe.</param>
|
||||
/// <returns>An <see cref="IRun"/> that can be used to monitor the workflow execution.</returns>
|
||||
public static ValueTask<IRun> RunAsync(
|
||||
Workflow workflow,
|
||||
string input,
|
||||
DurableTaskClient client,
|
||||
string? instanceId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> RunAsync<string>(workflow, input, client, instanceId, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Starts a workflow as a durable orchestration and returns a streaming handle to watch events.
|
||||
/// </summary>
|
||||
/// <typeparam name="TInput">The type of the input to the workflow.</typeparam>
|
||||
/// <param name="workflow">The workflow to execute.</param>
|
||||
/// <param name="input">The input to pass to the workflow's starting executor.</param>
|
||||
/// <param name="client">The durable task client for orchestration operations.</param>
|
||||
/// <param name="instanceId">Optional instance ID for the orchestration. If not provided, a new ID will be generated.</param>
|
||||
/// <param name="cancellationToken">A cancellation token to observe.</param>
|
||||
/// <returns>An <see cref="IStreamingRun"/> that can be used to stream workflow events.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when workflow or client is null.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when the workflow does not have a valid name.</exception>
|
||||
public static async ValueTask<IStreamingRun> StreamAsync<TInput>(
|
||||
Workflow workflow,
|
||||
TInput input,
|
||||
DurableTaskClient client,
|
||||
string? instanceId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
where TInput : notnull
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(workflow);
|
||||
ArgumentNullException.ThrowIfNull(client);
|
||||
|
||||
if (string.IsNullOrEmpty(workflow.Name))
|
||||
{
|
||||
throw new ArgumentException("Workflow must have a valid Name property.", nameof(workflow));
|
||||
}
|
||||
|
||||
string orchestrationName = WorkflowNamingHelper.ToOrchestrationFunctionName(workflow.Name);
|
||||
string actualInstanceId = await client.ScheduleNewOrchestrationInstanceAsync(
|
||||
orchestratorName: orchestrationName,
|
||||
input: input,
|
||||
cancellation: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new DurableStreamingRun(client, actualInstanceId, workflow);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts a workflow as a durable orchestration with string input and returns a streaming handle.
|
||||
/// </summary>
|
||||
/// <param name="workflow">The workflow to execute.</param>
|
||||
/// <param name="input">The string input to pass to the workflow.</param>
|
||||
/// <param name="client">The durable task client for orchestration operations.</param>
|
||||
/// <param name="instanceId">Optional instance ID for the orchestration.</param>
|
||||
/// <param name="cancellationToken">A cancellation token to observe.</param>
|
||||
/// <returns>An <see cref="IStreamingRun"/> that can be used to stream workflow events.</returns>
|
||||
public static ValueTask<IStreamingRun> StreamAsync(
|
||||
Workflow workflow,
|
||||
string input,
|
||||
DurableTaskClient client,
|
||||
string? instanceId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> StreamAsync<string>(workflow, input, client, instanceId, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Attaches to an existing workflow orchestration instance.
|
||||
/// </summary>
|
||||
/// <param name="instanceId">The instance ID of the orchestration to attach to.</param>
|
||||
/// <param name="workflowName">The name of the workflow being executed.</param>
|
||||
/// <param name="client">The durable task client for orchestration operations.</param>
|
||||
/// <returns>An <see cref="IRun"/> that can be used to monitor the workflow execution.</returns>
|
||||
public static IRun Attach(
|
||||
string instanceId,
|
||||
string workflowName,
|
||||
DurableTaskClient client)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(instanceId);
|
||||
ArgumentException.ThrowIfNullOrEmpty(workflowName);
|
||||
ArgumentNullException.ThrowIfNull(client);
|
||||
|
||||
return new DurableRun(client, instanceId, workflowName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attaches to an existing workflow orchestration instance for streaming.
|
||||
/// </summary>
|
||||
/// <param name="instanceId">The instance ID of the orchestration to attach to.</param>
|
||||
/// <param name="workflow">The workflow being executed.</param>
|
||||
/// <param name="client">The durable task client for orchestration operations.</param>
|
||||
/// <returns>An <see cref="IStreamingRun"/> that can be used to stream workflow events.</returns>
|
||||
public static IStreamingRun AttachStream(
|
||||
string instanceId,
|
||||
Workflow workflow,
|
||||
DurableTaskClient client)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(instanceId);
|
||||
ArgumentNullException.ThrowIfNull(workflow);
|
||||
ArgumentNullException.ThrowIfNull(client);
|
||||
|
||||
return new DurableStreamingRun(client, instanceId, workflow);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.DurableTask.Client;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask;
|
||||
|
||||
/// <summary>
|
||||
/// Provides a durable task-based implementation of <see cref="IWorkflowClient"/> for running
|
||||
/// workflows as durable orchestrations.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This class wraps the <see cref="DurableTaskClient"/> and provides methods to run workflows
|
||||
/// without requiring the client to be passed explicitly. Register this class in DI using
|
||||
/// <see cref="DurableWorkflowServiceCollectionExtensions.ConfigureDurableWorkflows"/>.
|
||||
/// </remarks>
|
||||
public sealed class DurableWorkflowClient : IWorkflowClient
|
||||
{
|
||||
private readonly DurableTaskClient _client;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DurableWorkflowClient"/> class.
|
||||
/// </summary>
|
||||
/// <param name="client">The durable task client for orchestration operations.</param>
|
||||
public DurableWorkflowClient(DurableTaskClient client)
|
||||
{
|
||||
this._client = client ?? throw new ArgumentNullException(nameof(client));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public ValueTask<IRun> RunAsync<TInput>(
|
||||
Workflow workflow,
|
||||
TInput input,
|
||||
string? runId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
where TInput : notnull
|
||||
=> DurableWorkflow.RunAsync(workflow, input, this._client, runId, cancellationToken);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public ValueTask<IRun> RunAsync(
|
||||
Workflow workflow,
|
||||
string input,
|
||||
string? runId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> DurableWorkflow.RunAsync(workflow, input, this._client, runId, cancellationToken);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public ValueTask<IStreamingRun> StreamAsync<TInput>(
|
||||
Workflow workflow,
|
||||
TInput input,
|
||||
string? runId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
where TInput : notnull
|
||||
=> DurableWorkflow.StreamAsync(workflow, input, this._client, runId, cancellationToken);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public ValueTask<IStreamingRun> StreamAsync(
|
||||
Workflow workflow,
|
||||
string input,
|
||||
string? runId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> DurableWorkflow.StreamAsync(workflow, input, this._client, runId, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Attaches to an existing workflow orchestration instance.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is a durable-specific method not available on <see cref="IWorkflowClient"/>.
|
||||
/// </remarks>
|
||||
/// <param name="instanceId">The instance ID of the orchestration to attach to.</param>
|
||||
/// <param name="workflowName">The name of the workflow being executed.</param>
|
||||
/// <returns>An <see cref="IRun"/> that can be used to monitor the workflow execution.</returns>
|
||||
public IRun Attach(string instanceId, string workflowName)
|
||||
=> DurableWorkflow.Attach(instanceId, workflowName, this._client);
|
||||
|
||||
/// <summary>
|
||||
/// Attaches to an existing workflow orchestration instance for streaming.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is a durable-specific method not available on <see cref="IWorkflowClient"/>.
|
||||
/// </remarks>
|
||||
/// <param name="instanceId">The instance ID of the orchestration to attach to.</param>
|
||||
/// <param name="workflow">The workflow being executed.</param>
|
||||
/// <returns>An <see cref="IStreamingRun"/> that can be used to stream workflow events.</returns>
|
||||
public IStreamingRun AttachStream(string instanceId, Workflow workflow)
|
||||
=> DurableWorkflow.AttachStream(instanceId, workflow, this._client);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask;
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when a durable workflow completes successfully.
|
||||
/// </summary>
|
||||
[DebuggerDisplay("Completed: {Result}")]
|
||||
public sealed class DurableWorkflowCompletedEvent : WorkflowEvent
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DurableWorkflowCompletedEvent"/> class.
|
||||
/// </summary>
|
||||
/// <param name="result">The serialized result of the workflow.</param>
|
||||
public DurableWorkflowCompletedEvent(string? result) : base(result)
|
||||
{
|
||||
this.Result = result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the serialized result of the workflow.
|
||||
/// </summary>
|
||||
public string? Result { get; }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask;
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when a durable workflow fails.
|
||||
/// </summary>
|
||||
[DebuggerDisplay("Failed: {ErrorMessage}")]
|
||||
public sealed class DurableWorkflowFailedEvent : WorkflowEvent
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DurableWorkflowFailedEvent"/> class.
|
||||
/// </summary>
|
||||
/// <param name="errorMessage">The error message describing the failure.</param>
|
||||
public DurableWorkflowFailedEvent(string errorMessage) : base(errorMessage)
|
||||
{
|
||||
this.ErrorMessage = errorMessage;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the error message describing the failure.
|
||||
/// </summary>
|
||||
public string ErrorMessage { get; }
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask;
|
||||
|
||||
/// <summary>
|
||||
/// Source-generated JSON serialization context for durable workflow types.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This context provides AOT-compatible and trimmer-safe JSON serialization for the
|
||||
/// internal data transfer types used by the durable workflow infrastructure:
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item><description><see cref="DurableActivityInput"/>: Activity input wrapper with state</description></item>
|
||||
/// <item><description><see cref="DurableActivityOutput"/>: Activity output wrapper with results and events</description></item>
|
||||
/// <item><description><see cref="SentMessageInfo"/>: Messages sent via SendMessageAsync</description></item>
|
||||
/// <item><description><see cref="SerializedWorkflowEvent"/>: Workflow event wrapper</description></item>
|
||||
/// <item><description><see cref="ActivityInputWithState"/>: Orchestrator-to-activity input wrapper</description></item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// Note: User-defined executor input/output types still use reflection-based serialization
|
||||
/// since their types are not known at compile time.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[JsonSourceGenerationOptions(
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
|
||||
[JsonSerializable(typeof(DurableActivityInput))]
|
||||
[JsonSerializable(typeof(DurableActivityOutput))]
|
||||
[JsonSerializable(typeof(SentMessageInfo))]
|
||||
[JsonSerializable(typeof(List<SentMessageInfo>))]
|
||||
[JsonSerializable(typeof(SerializedWorkflowEvent))]
|
||||
[JsonSerializable(typeof(ActivityInputWithState))]
|
||||
[JsonSerializable(typeof(List<string>))]
|
||||
[JsonSerializable(typeof(Dictionary<string, string>))]
|
||||
[JsonSerializable(typeof(Dictionary<string, string?>))]
|
||||
internal partial class DurableWorkflowJsonContext : JsonSerializerContext
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask;
|
||||
|
||||
/// <summary>
|
||||
/// Provides configuration options for managing durable workflows within an application.
|
||||
/// </summary>
|
||||
[DebuggerDisplay("Workflows = {Workflows.Count}")]
|
||||
public sealed class DurableWorkflowOptions
|
||||
{
|
||||
private readonly Dictionary<string, Workflow> _workflows = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly DurableOptions? _parentOptions;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DurableWorkflowOptions"/> class.
|
||||
/// </summary>
|
||||
/// <param name="parentOptions">Optional parent options container for accessing related configuration.</param>
|
||||
internal DurableWorkflowOptions(DurableOptions? parentOptions = null)
|
||||
{
|
||||
this._parentOptions = parentOptions;
|
||||
this.Executors = new ExecutorRegistry();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the collection of workflows available in the current context, keyed by their unique names.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, Workflow> Workflows => this._workflows;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the executor registry for direct executor lookup.
|
||||
/// </summary>
|
||||
internal ExecutorRegistry Executors { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Adds a workflow to the collection for processing or execution.
|
||||
/// </summary>
|
||||
/// <param name="workflow">The workflow instance to add. Cannot be null.</param>
|
||||
/// <remarks>
|
||||
/// When a workflow is added, any AI agent executors in the workflow will be automatically
|
||||
/// registered with the <see cref="DurableAgentsOptions"/> if it was provided during construction.
|
||||
/// </remarks>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="workflow"/> is null.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when the workflow does not have a valid name.</exception>
|
||||
public void AddWorkflow(Workflow workflow)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(workflow);
|
||||
|
||||
if (string.IsNullOrEmpty(workflow.Name))
|
||||
{
|
||||
throw new ArgumentException("Workflow must have a valid Name property.", nameof(workflow));
|
||||
}
|
||||
|
||||
this._workflows[workflow.Name] = workflow;
|
||||
|
||||
RegisterExecutors(workflow, this.Executors);
|
||||
|
||||
DurableAgentsOptions? agentOptions = this._parentOptions?.Agents;
|
||||
if (agentOptions is not null)
|
||||
{
|
||||
RegisterAgenticExecutors(workflow, agentOptions);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a collection of workflows to the current instance.
|
||||
/// </summary>
|
||||
/// <param name="workflows">The collection of <see cref="Workflow"/> objects to add. Cannot be <see langword="null"/>.</param>
|
||||
public void AddWorkflows(IEnumerable<Workflow> workflows)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(workflows);
|
||||
|
||||
foreach (var workflow in workflows)
|
||||
{
|
||||
this.AddWorkflow(workflow);
|
||||
}
|
||||
}
|
||||
|
||||
private static void RegisterExecutors(Workflow workflow, ExecutorRegistry registry)
|
||||
{
|
||||
foreach (KeyValuePair<string, ExecutorBinding> executor in workflow.ReflectExecutors())
|
||||
{
|
||||
int underscoreIndex = executor.Key.IndexOf('_');
|
||||
string executorName = underscoreIndex > 0 ? executor.Key[..underscoreIndex] : executor.Key;
|
||||
registry.Register(executorName, executor.Key, workflow);
|
||||
}
|
||||
}
|
||||
|
||||
private static void RegisterAgenticExecutors(Workflow workflow, DurableAgentsOptions agentOptions)
|
||||
{
|
||||
foreach (KeyValuePair<string, ExecutorBinding> executor in workflow.ReflectExecutors())
|
||||
{
|
||||
if (executor.Value.RawValue is AIAgent agent && agent.Name is not null && !agentOptions.ContainsAgent(agent.Name))
|
||||
{
|
||||
agentOptions.AddAIAgent(agent, workflowOnly: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+513
@@ -0,0 +1,513 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using Microsoft.Agents.AI.DurableTask.State;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Agents.AI.Workflows.Checkpointing;
|
||||
using Microsoft.DurableTask;
|
||||
using Microsoft.DurableTask.Client;
|
||||
using Microsoft.DurableTask.Worker;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for configuring durable workflows with the service collection.
|
||||
/// </summary>
|
||||
public static class DurableWorkflowServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configures durable workflows with the service collection, automatically registering
|
||||
/// orchestrations and activities for each workflow.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection to configure.</param>
|
||||
/// <param name="configure">A delegate to configure the durable options.</param>
|
||||
/// <param name="workerBuilder">An optional delegate to configure the durable task worker.</param>
|
||||
/// <param name="clientBuilder">An optional delegate to configure the durable task client.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection ConfigureDurableWorkflows(
|
||||
this IServiceCollection services,
|
||||
Action<DurableOptions> configure,
|
||||
Action<IDurableTaskWorkerBuilder>? workerBuilder = null,
|
||||
Action<IDurableTaskClientBuilder>? clientBuilder = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configure);
|
||||
|
||||
// Create and configure durable options
|
||||
DurableOptions durableOptions = new();
|
||||
configure(durableOptions);
|
||||
|
||||
// Register DurableOptions as a singleton
|
||||
services.AddSingleton(durableOptions);
|
||||
|
||||
// Register the workflow runner
|
||||
services.AddSingleton<DurableWorkflowRunner>();
|
||||
|
||||
// Build registration info for all workflows (including sub-workflows)
|
||||
List<WorkflowRegistrationInfo> registrations = [];
|
||||
HashSet<string> registeredActivities = [];
|
||||
HashSet<string> registeredOrchestrations = [];
|
||||
|
||||
// Take a snapshot of the workflows to avoid collection modified during enumeration
|
||||
// (sub-workflows are added to the collection during recursive registration)
|
||||
foreach (Workflow workflow in durableOptions.Workflows.Workflows.Values.ToList())
|
||||
{
|
||||
BuildWorkflowRegistrationRecursive(
|
||||
workflow,
|
||||
durableOptions.Workflows,
|
||||
registrations,
|
||||
registeredActivities,
|
||||
registeredOrchestrations);
|
||||
}
|
||||
|
||||
// Get any AI agents that were auto-registered from workflows
|
||||
IReadOnlyDictionary<string, Func<IServiceProvider, AIAgent>> agentFactories = durableOptions.Agents.GetAgentFactories();
|
||||
|
||||
// Configure Durable Task Worker with orchestrations and activities
|
||||
services.AddDurableTaskWorker(builder =>
|
||||
{
|
||||
workerBuilder?.Invoke(builder);
|
||||
|
||||
builder.AddTasks(registry =>
|
||||
{
|
||||
// Register all workflow tasks
|
||||
foreach (WorkflowRegistrationInfo registration in registrations)
|
||||
{
|
||||
// Register orchestration
|
||||
registry.AddOrchestratorFunc<string, string>(
|
||||
registration.OrchestrationName,
|
||||
(context, input) => RunWorkflowOrchestrationAsync(context, input, durableOptions));
|
||||
|
||||
// Register activities
|
||||
foreach (ActivityRegistrationInfo activity in registration.Activities)
|
||||
{
|
||||
ExecutorBinding binding = activity.Binding;
|
||||
registry.AddActivityFunc<string, string>(
|
||||
activity.ActivityName,
|
||||
(context, input) => ExecuteActivityAsync(binding, input));
|
||||
}
|
||||
}
|
||||
|
||||
// Register agent entities for any AI agents used in workflows
|
||||
foreach (string agentName in agentFactories.Keys)
|
||||
{
|
||||
registry.AddEntity<AgentEntity>(AgentSessionId.ToEntityName(agentName));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Register DurableAgentsOptions and agent factories for entity resolution
|
||||
if (agentFactories.Count > 0)
|
||||
{
|
||||
services.AddSingleton(durableOptions.Agents);
|
||||
|
||||
// Register the agent factories dictionary for backward compatibility
|
||||
services.TryAddSingleton(
|
||||
sp => sp.GetRequiredService<DurableAgentsOptions>().GetAgentFactories());
|
||||
|
||||
// A custom data converter is needed for proper JSON serialization
|
||||
services.TryAddSingleton<DataConverter, WorkflowDataConverter>();
|
||||
}
|
||||
|
||||
// Configure Durable Task Client if a builder is provided
|
||||
if (clientBuilder is not null)
|
||||
{
|
||||
services.AddDurableTaskClient(clientBuilder);
|
||||
}
|
||||
|
||||
// Register the DurableWorkflowClient for DI-friendly workflow execution
|
||||
// Register both the concrete type and the interface
|
||||
services.TryAddSingleton<DurableWorkflowClient>();
|
||||
services.TryAddSingleton<IWorkflowClient>(sp => sp.GetRequiredService<DurableWorkflowClient>());
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recursively builds workflow registrations, including any sub-workflows.
|
||||
/// Also adds sub-workflows to the workflow options so they can be looked up at runtime.
|
||||
/// </summary>
|
||||
/// <param name="workflow">The workflow to register.</param>
|
||||
/// <param name="workflowOptions">The workflow options to add sub-workflows to.</param>
|
||||
/// <param name="registrations">The list to add registrations to.</param>
|
||||
/// <param name="registeredActivities">Set of already registered activity names to avoid duplicates.</param>
|
||||
/// <param name="registeredOrchestrations">Set of already registered orchestration names to avoid duplicates.</param>
|
||||
private static void BuildWorkflowRegistrationRecursive(
|
||||
Workflow workflow,
|
||||
DurableWorkflowOptions workflowOptions,
|
||||
List<WorkflowRegistrationInfo> registrations,
|
||||
HashSet<string> registeredActivities,
|
||||
HashSet<string> registeredOrchestrations)
|
||||
{
|
||||
string orchestrationName = WorkflowNamingHelper.ToOrchestrationFunctionName(workflow.Name!);
|
||||
|
||||
// Skip if this workflow is already registered (handles circular references)
|
||||
if (!registeredOrchestrations.Add(orchestrationName))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Build registration for this workflow
|
||||
registrations.Add(BuildWorkflowRegistration(workflow, registeredActivities));
|
||||
|
||||
// Recursively register any sub-workflows
|
||||
foreach (KeyValuePair<string, ExecutorBinding> entry in workflow.ReflectExecutors())
|
||||
{
|
||||
if (entry.Value is SubworkflowBinding subworkflowBinding)
|
||||
{
|
||||
Workflow subWorkflow = subworkflowBinding.WorkflowInstance;
|
||||
|
||||
// Add sub-workflow to options so it can be looked up by the runner at runtime
|
||||
workflowOptions.AddWorkflow(subWorkflow);
|
||||
|
||||
BuildWorkflowRegistrationRecursive(
|
||||
subWorkflow,
|
||||
workflowOptions,
|
||||
registrations,
|
||||
registeredActivities,
|
||||
registeredOrchestrations);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static WorkflowRegistrationInfo BuildWorkflowRegistration(
|
||||
Workflow workflow,
|
||||
HashSet<string> registeredActivities)
|
||||
{
|
||||
string workflowName = workflow.Name!;
|
||||
string orchestrationName = WorkflowNamingHelper.ToOrchestrationFunctionName(workflowName);
|
||||
Dictionary<string, ExecutorBinding> executorBindings = workflow.ReflectExecutors();
|
||||
|
||||
List<ActivityRegistrationInfo> activities = [];
|
||||
|
||||
foreach (KeyValuePair<string, ExecutorBinding> entry in executorBindings)
|
||||
{
|
||||
// Skip agent executors - they're handled differently
|
||||
if (entry.Value is AIAgentBinding)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip sub-workflow executors - they're handled as sub-orchestrations
|
||||
if (entry.Value is SubworkflowBinding)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string executorName = WorkflowNamingHelper.GetExecutorName(entry.Key);
|
||||
string activityName = WorkflowNamingHelper.ToOrchestrationFunctionName(executorName);
|
||||
|
||||
// Skip if already registered (same executor used in multiple workflows)
|
||||
if (!registeredActivities.Add(activityName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
activities.Add(new ActivityRegistrationInfo(activityName, entry.Value));
|
||||
}
|
||||
|
||||
return new WorkflowRegistrationInfo(orchestrationName, activities);
|
||||
}
|
||||
|
||||
private static async Task<string> RunWorkflowOrchestrationAsync(
|
||||
TaskOrchestrationContext context,
|
||||
string input,
|
||||
DurableOptions durableOptions)
|
||||
{
|
||||
ILogger logger = context.CreateReplaySafeLogger("WorkflowOrchestration");
|
||||
DurableWorkflowRunner runner = new(
|
||||
NullLoggerFactory.Instance.CreateLogger<DurableWorkflowRunner>(),
|
||||
durableOptions);
|
||||
|
||||
return await runner.RunWorkflowOrchestrationAsync(context, input, logger).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026:RequiresUnreferencedCode", Justification = "Executor types are registered at startup.")]
|
||||
[UnconditionalSuppressMessage("AOT", "IL3050:RequiresDynamicCode", Justification = "Executor types are registered at startup.")]
|
||||
private static async Task<string> ExecuteActivityAsync(ExecutorBinding binding, string input)
|
||||
{
|
||||
DurableActivityInput? inputWithState = TryDeserializeActivityInput(input);
|
||||
string executorInput = inputWithState?.Input ?? input;
|
||||
Dictionary<string, string> sharedState = inputWithState?.State ?? [];
|
||||
|
||||
Executor executor = await binding.FactoryAsync!("activity-run").ConfigureAwait(false);
|
||||
Type inputType = ResolveInputType(inputWithState?.InputTypeName, executor.InputTypes);
|
||||
object typedInput = DeserializeInput(executorInput, inputType);
|
||||
|
||||
PipelineActivityContext workflowContext = new(sharedState, executor);
|
||||
object? result = await executor.ExecuteAsync(
|
||||
typedInput,
|
||||
new TypeId(inputType),
|
||||
workflowContext,
|
||||
CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
return SerializeActivityOutput(result, workflowContext);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes the activity output using source-generated serialization.
|
||||
/// </summary>
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026:RequiresUnreferencedCode", Justification = "SerializeResult uses reflection for user types.")]
|
||||
[UnconditionalSuppressMessage("AOT", "IL3050:RequiresDynamicCode", Justification = "SerializeResult uses reflection for user types.")]
|
||||
private static string SerializeActivityOutput(object? result, PipelineActivityContext context)
|
||||
{
|
||||
DurableActivityOutput output = new()
|
||||
{
|
||||
Result = SerializeResult(result),
|
||||
StateUpdates = context.StateUpdates,
|
||||
ClearedScopes = [.. context.ClearedScopes],
|
||||
Events = context.Events.ConvertAll(SerializeEvent),
|
||||
SentMessages = context.SentMessages.ConvertAll(m => new SentMessageInfo
|
||||
{
|
||||
Message = m.Message,
|
||||
TypeName = m.TypeName
|
||||
})
|
||||
};
|
||||
|
||||
return JsonSerializer.Serialize(output, DurableWorkflowJsonContext.Default.DurableActivityOutput);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the input type from the provided type name, or falls back to the first supported type.
|
||||
/// </summary>
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026:RequiresUnreferencedCode", Justification = "Type resolution for registered executor types.")]
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2057:TypeGetType", Justification = "Type resolution for registered executor types.")]
|
||||
[UnconditionalSuppressMessage("AOT", "IL3050:RequiresDynamicCode", Justification = "Type resolution for registered executor types.")]
|
||||
private static Type ResolveInputType(string? inputTypeName, ISet<Type> supportedTypes)
|
||||
{
|
||||
if (string.IsNullOrEmpty(inputTypeName))
|
||||
{
|
||||
return supportedTypes.FirstOrDefault() ?? typeof(string);
|
||||
}
|
||||
|
||||
// Try to find a matching type in the supported types
|
||||
Type? matchedType = supportedTypes.FirstOrDefault(t =>
|
||||
t.AssemblyQualifiedName == inputTypeName ||
|
||||
t.FullName == inputTypeName ||
|
||||
t.Name == inputTypeName);
|
||||
|
||||
if (matchedType is not null)
|
||||
{
|
||||
return matchedType;
|
||||
}
|
||||
|
||||
// Try to load the type directly (for types not in supported types)
|
||||
Type? loadedType = Type.GetType(inputTypeName);
|
||||
|
||||
// If the loaded type is string but the executor doesn't support string,
|
||||
// fall back to the first supported type. This handles the case where
|
||||
// serialized JSON objects are passed with type "System.String" but need
|
||||
// to be deserialized to the actual expected type (e.g., OrderInfo).
|
||||
if (loadedType == typeof(string) && !supportedTypes.Contains(typeof(string)))
|
||||
{
|
||||
return supportedTypes.FirstOrDefault() ?? typeof(string);
|
||||
}
|
||||
|
||||
return loadedType ?? supportedTypes.FirstOrDefault() ?? typeof(string);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to deserialize activity input using source-generated serialization.
|
||||
/// </summary>
|
||||
private static DurableActivityInput? TryDeserializeActivityInput(string input)
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize(input, DurableWorkflowJsonContext.Default.DurableActivityInput);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes a workflow event with type information.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The event data itself uses reflection-based serialization since event types
|
||||
/// are user-defined, but the wrapper uses source generation.
|
||||
/// </remarks>
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026:RequiresUnreferencedCode", Justification = "Event data uses reflection for user types.")]
|
||||
[UnconditionalSuppressMessage("AOT", "IL3050:RequiresDynamicCode", Justification = "Event data uses reflection for user types.")]
|
||||
private static string SerializeEvent(WorkflowEvent evt)
|
||||
{
|
||||
// Serialize with type information so we can deserialize to the correct type later
|
||||
SerializedWorkflowEvent wrapper = new()
|
||||
{
|
||||
TypeName = evt.GetType().AssemblyQualifiedName,
|
||||
Data = JsonSerializer.Serialize(evt, evt.GetType())
|
||||
};
|
||||
return JsonSerializer.Serialize(wrapper, DurableWorkflowJsonContext.Default.SerializedWorkflowEvent);
|
||||
}
|
||||
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026:RequiresUnreferencedCode", Justification = "Deserializing workflow types registered at startup.")]
|
||||
[UnconditionalSuppressMessage("AOT", "IL3050:RequiresDynamicCode", Justification = "Deserializing workflow types registered at startup.")]
|
||||
private static object DeserializeInput(string input, Type targetType)
|
||||
{
|
||||
if (targetType == typeof(string))
|
||||
{
|
||||
return input;
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize(input, targetType)
|
||||
?? throw new InvalidOperationException($"Failed to deserialize input to type '{targetType.Name}'.");
|
||||
}
|
||||
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026:RequiresUnreferencedCode", Justification = "Serializing workflow types registered at startup.")]
|
||||
[UnconditionalSuppressMessage("AOT", "IL3050:RequiresDynamicCode", Justification = "Serializing workflow types registered at startup.")]
|
||||
private static string SerializeResult(object? result)
|
||||
{
|
||||
if (result is null)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
if (result is string str)
|
||||
{
|
||||
return str;
|
||||
}
|
||||
|
||||
return JsonSerializer.Serialize(result, result.GetType());
|
||||
}
|
||||
|
||||
private sealed record WorkflowRegistrationInfo(string OrchestrationName, List<ActivityRegistrationInfo> Activities);
|
||||
|
||||
private sealed record ActivityRegistrationInfo(string ActivityName, ExecutorBinding Binding);
|
||||
|
||||
/// <summary>
|
||||
/// Custom data converter for workflow execution with AI agents.
|
||||
/// </summary>
|
||||
private sealed class WorkflowDataConverter : DataConverter
|
||||
{
|
||||
private static readonly JsonSerializerOptions s_options = new(DurableAgentJsonUtilities.DefaultOptions)
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Fallback path uses reflection when metadata unavailable.")]
|
||||
[UnconditionalSuppressMessage("ReflectionAnalysis", "IL3050", Justification = "Fallback path uses reflection when metadata unavailable.")]
|
||||
public override object? Deserialize(string? data, Type targetType)
|
||||
{
|
||||
if (data is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (targetType == typeof(DurableAgentState))
|
||||
{
|
||||
return JsonSerializer.Deserialize(data, DurableAgentStateJsonContext.Default.DurableAgentState);
|
||||
}
|
||||
|
||||
return TryDeserializeWithTypeInfo(data, targetType)
|
||||
?? JsonSerializer.Deserialize(data, targetType, s_options);
|
||||
}
|
||||
|
||||
[return: NotNullIfNotNull(nameof(value))]
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Fallback path uses reflection when metadata unavailable.")]
|
||||
[UnconditionalSuppressMessage("ReflectionAnalysis", "IL3050", Justification = "Fallback path uses reflection when metadata unavailable.")]
|
||||
public override string? Serialize(object? value)
|
||||
{
|
||||
if (value is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value is DurableAgentState durableAgentState)
|
||||
{
|
||||
return JsonSerializer.Serialize(durableAgentState, DurableAgentStateJsonContext.Default.DurableAgentState);
|
||||
}
|
||||
|
||||
return TrySerializeWithTypeInfo(value)
|
||||
?? JsonSerializer.Serialize(value, s_options);
|
||||
}
|
||||
|
||||
private static object? TryDeserializeWithTypeInfo(string data, Type targetType)
|
||||
{
|
||||
JsonTypeInfo? typeInfo = s_options.GetTypeInfo(targetType);
|
||||
return typeInfo is not null ? JsonSerializer.Deserialize(data, typeInfo) : null;
|
||||
}
|
||||
|
||||
private static string? TrySerializeWithTypeInfo(object value)
|
||||
{
|
||||
JsonTypeInfo? typeInfo = s_options.GetTypeInfo(value.GetType());
|
||||
return typeInfo is not null ? JsonSerializer.Serialize(value, typeInfo) : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Input payload for activity execution, containing the executor input and shared workflow state.
|
||||
/// </summary>
|
||||
internal sealed class DurableActivityInput
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the serialized executor input.
|
||||
/// </summary>
|
||||
public string? Input { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the assembly-qualified type name of the input, used for proper deserialization.
|
||||
/// </summary>
|
||||
public string? InputTypeName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the shared state dictionary (scope-prefixed key -> serialized value).
|
||||
/// </summary>
|
||||
public Dictionary<string, string> State { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Output payload from activity execution, containing the result, state updates, and emitted events.
|
||||
/// </summary>
|
||||
internal sealed class DurableActivityOutput
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the serialized result of the activity.
|
||||
/// </summary>
|
||||
public string? Result { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets state updates made during activity execution (scope-prefixed key -> serialized value, null = delete).
|
||||
/// </summary>
|
||||
public Dictionary<string, string?> StateUpdates { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets scopes that were cleared during activity execution.
|
||||
/// </summary>
|
||||
public List<string> ClearedScopes { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the serialized workflow events emitted during activity execution.
|
||||
/// </summary>
|
||||
public List<string> Events { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets messages sent via SendMessageAsync during activity execution.
|
||||
/// Each entry is a tuple of (serializedMessage, typeName).
|
||||
/// </summary>
|
||||
public List<SentMessageInfo> SentMessages { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about a message sent via SendMessageAsync.
|
||||
/// </summary>
|
||||
internal sealed class SentMessageInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the serialized message content.
|
||||
/// </summary>
|
||||
public string? Message { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the full type name of the message.
|
||||
/// </summary>
|
||||
public string? TypeName { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask;
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when an executor yields intermediate output via <see cref="IWorkflowContext.YieldOutputAsync"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is the durable equivalent of <see cref="WorkflowOutputEvent"/> since that class has an internal
|
||||
/// constructor not accessible from outside the Workflows assembly.
|
||||
/// </remarks>
|
||||
[DebuggerDisplay("Yielded by {ExecutorId}: {Output}")]
|
||||
public sealed class DurableYieldedOutputEvent : WorkflowEvent
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DurableYieldedOutputEvent"/> class.
|
||||
/// </summary>
|
||||
/// <param name="executorId">The ID of the executor that yielded the output.</param>
|
||||
/// <param name="output">The yielded output value.</param>
|
||||
public DurableYieldedOutputEvent(string executorId, object output) : base(output)
|
||||
{
|
||||
this.ExecutorId = executorId;
|
||||
this.Output = output;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the ID of the executor that yielded the output.
|
||||
/// </summary>
|
||||
public string ExecutorId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the yielded output value.
|
||||
/// </summary>
|
||||
public object Output { get; }
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask;
|
||||
|
||||
/// <summary>
|
||||
/// Provides a registry for storing and retrieving executor bindings independently from workflows.
|
||||
/// </summary>
|
||||
internal sealed class ExecutorRegistry
|
||||
{
|
||||
private readonly Dictionary<string, ExecutorRegistration> _executors = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of registered executors.
|
||||
/// </summary>
|
||||
public int Count => this._executors.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to get an executor registration by name.
|
||||
/// </summary>
|
||||
/// <param name="executorName">The executor name to look up.</param>
|
||||
/// <param name="registration">When this method returns, contains the registration if found; otherwise, null.</param>
|
||||
/// <returns><see langword="true"/> if the executor was found; otherwise, <see langword="false"/>.</returns>
|
||||
public bool TryGetExecutor(string executorName, out ExecutorRegistration? registration)
|
||||
{
|
||||
return this._executors.TryGetValue(executorName, out registration);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers an executor binding from a workflow.
|
||||
/// </summary>
|
||||
/// <param name="executorName">The executor name (without GUID suffix).</param>
|
||||
/// <param name="executorId">The full executor ID (may include GUID suffix).</param>
|
||||
/// <param name="workflow">The workflow containing the executor.</param>
|
||||
internal void Register(string executorName, string executorId, Workflow workflow)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(executorName);
|
||||
ArgumentException.ThrowIfNullOrEmpty(executorId);
|
||||
ArgumentNullException.ThrowIfNull(workflow);
|
||||
|
||||
Dictionary<string, ExecutorBinding> bindings = workflow.ReflectExecutors();
|
||||
if (!bindings.TryGetValue(executorId, out ExecutorBinding? binding))
|
||||
{
|
||||
throw new InvalidOperationException($"Executor '{executorId}' not found in workflow.");
|
||||
}
|
||||
|
||||
this._executors.TryAdd(executorName, new ExecutorRegistration(executorId, binding));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a registered executor with its associated workflow.
|
||||
/// </summary>
|
||||
/// <param name="ExecutorId">The full executor ID (may include GUID suffix).</param>
|
||||
/// <param name="Binding">The executor binding from the workflow.</param>
|
||||
internal sealed record ExecutorRegistration(string ExecutorId, ExecutorBinding Binding)
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates an instance of the executor.
|
||||
/// </summary>
|
||||
/// <param name="runId">A unique identifier for the run context.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The created executor instance.</returns>
|
||||
public async ValueTask<Executor> CreateExecutorInstanceAsync(string runId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (this.Binding.FactoryAsync is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Cannot create executor '{this.ExecutorId}': Binding is a placeholder.");
|
||||
}
|
||||
|
||||
return await this.Binding.FactoryAsync(runId).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a workflow run that tracks execution status and emitted workflow events,
|
||||
/// supporting resumption with responses to external requests.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This interface defines the common contract for workflow runs across different execution
|
||||
/// environments (in-process, durable, etc.). Implementations provide the mechanism to
|
||||
/// interact with running workflows, send responses, and access emitted events.
|
||||
/// </remarks>
|
||||
public interface IRun : IAsyncDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the unique identifier for the run.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This identifier can be provided at the start of the run, or auto-generated.
|
||||
/// For durable runs, this corresponds to the orchestration instance ID.
|
||||
/// </remarks>
|
||||
string RunId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets all events that have been emitted by the workflow.
|
||||
/// </summary>
|
||||
IEnumerable<WorkflowEvent> OutgoingEvents { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of events emitted since the last access to <see cref="NewEvents"/>.
|
||||
/// </summary>
|
||||
int NewEventCount { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets all events emitted by the workflow since the last access to this property.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Each access to this property advances the bookmark, so subsequent accesses
|
||||
/// will only return events emitted after the previous access.
|
||||
/// </remarks>
|
||||
IEnumerable<WorkflowEvent> NewEvents { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Sends an external response to the workflow.
|
||||
/// </summary>
|
||||
/// <param name="response">The external response to send.</param>
|
||||
/// <param name="cancellationToken">A cancellation token to observe.</param>
|
||||
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
|
||||
ValueTask SendResponseAsync(ExternalResponse response, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a workflow run that supports streaming workflow events as they occur,
|
||||
/// providing a mechanism to send responses back to the workflow.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This interface defines the common contract for streaming workflow runs across different
|
||||
/// execution environments (in-process, durable, etc.). Implementations provide real-time
|
||||
/// access to workflow events and the ability to respond to external requests.
|
||||
/// </remarks>
|
||||
public interface IStreamingRun : IAsyncDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the unique identifier for the run.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This identifier can be provided at the start of the run, or auto-generated.
|
||||
/// For durable runs, this corresponds to the orchestration instance ID.
|
||||
/// </remarks>
|
||||
string RunId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Asynchronously streams workflow events as they occur during workflow execution.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This method yields <see cref="WorkflowEvent"/> instances in real time as the workflow
|
||||
/// progresses. The stream completes when the workflow completes, fails, or is terminated.
|
||||
/// Events are delivered in the order they are raised.
|
||||
/// </remarks>
|
||||
/// <param name="cancellationToken">
|
||||
/// A <see cref="CancellationToken"/> that can be used to cancel the streaming operation.
|
||||
/// If cancellation is requested, the stream will end and no further events will be yielded.
|
||||
/// </param>
|
||||
/// <returns>
|
||||
/// An asynchronous stream of <see cref="WorkflowEvent"/> objects representing significant
|
||||
/// workflow state changes.
|
||||
/// </returns>
|
||||
IAsyncEnumerable<WorkflowEvent> WatchStreamAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Sends an external response to the workflow.
|
||||
/// </summary>
|
||||
/// <param name="response">The external response to send.</param>
|
||||
/// <param name="cancellationToken">A cancellation token to observe.</param>
|
||||
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
|
||||
ValueTask SendResponseAsync(ExternalResponse response, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask;
|
||||
|
||||
/// <summary>
|
||||
/// Defines a client for running and managing workflow executions.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This interface provides a unified API for running workflows across different execution
|
||||
/// environments. Implementations handle the underlying infrastructure details, allowing
|
||||
/// callers to work with workflows without knowledge of the specific execution backend.
|
||||
/// </remarks>
|
||||
public interface IWorkflowClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Runs a workflow and returns a handle to monitor its execution.
|
||||
/// </summary>
|
||||
/// <typeparam name="TInput">The type of the input to the workflow.</typeparam>
|
||||
/// <param name="workflow">The workflow to execute.</param>
|
||||
/// <param name="input">The input to pass to the workflow's starting executor.</param>
|
||||
/// <param name="runId">Optional identifier for the run. If not provided, a new ID will be generated.</param>
|
||||
/// <param name="cancellationToken">A cancellation token to observe.</param>
|
||||
/// <returns>An <see cref="IRun"/> that can be used to monitor the workflow execution.</returns>
|
||||
ValueTask<IRun> RunAsync<TInput>(
|
||||
Workflow workflow,
|
||||
TInput input,
|
||||
string? runId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
where TInput : notnull;
|
||||
|
||||
/// <summary>
|
||||
/// Runs a workflow with string input and returns a handle to monitor its execution.
|
||||
/// </summary>
|
||||
/// <param name="workflow">The workflow to execute.</param>
|
||||
/// <param name="input">The string input to pass to the workflow.</param>
|
||||
/// <param name="runId">Optional identifier for the run. If not provided, a new ID will be generated.</param>
|
||||
/// <param name="cancellationToken">A cancellation token to observe.</param>
|
||||
/// <returns>An <see cref="IRun"/> that can be used to monitor the workflow execution.</returns>
|
||||
ValueTask<IRun> RunAsync(
|
||||
Workflow workflow,
|
||||
string input,
|
||||
string? runId = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Starts a workflow and returns a streaming handle to watch events.
|
||||
/// </summary>
|
||||
/// <typeparam name="TInput">The type of the input to the workflow.</typeparam>
|
||||
/// <param name="workflow">The workflow to execute.</param>
|
||||
/// <param name="input">The input to pass to the workflow's starting executor.</param>
|
||||
/// <param name="runId">Optional identifier for the run. If not provided, a new ID will be generated.</param>
|
||||
/// <param name="cancellationToken">A cancellation token to observe.</param>
|
||||
/// <returns>An <see cref="IStreamingRun"/> that can be used to stream workflow events.</returns>
|
||||
ValueTask<IStreamingRun> StreamAsync<TInput>(
|
||||
Workflow workflow,
|
||||
TInput input,
|
||||
string? runId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
where TInput : notnull;
|
||||
|
||||
/// <summary>
|
||||
/// Starts a workflow with string input and returns a streaming handle to watch events.
|
||||
/// </summary>
|
||||
/// <param name="workflow">The workflow to execute.</param>
|
||||
/// <param name="input">The string input to pass to the workflow.</param>
|
||||
/// <param name="runId">Optional identifier for the run. If not provided, a new ID will be generated.</param>
|
||||
/// <param name="cancellationToken">A cancellation token to observe.</param>
|
||||
/// <returns>An <see cref="IStreamingRun"/> that can be used to stream workflow events.</returns>
|
||||
ValueTask<IStreamingRun> StreamAsync(
|
||||
Workflow workflow,
|
||||
string input,
|
||||
string? runId = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -100,4 +100,52 @@ internal static partial class Logs
|
||||
public static partial void LogTTLExpirationTimeCleared(
|
||||
this ILogger logger,
|
||||
AgentSessionId sessionId);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 12,
|
||||
Level = LogLevel.Information,
|
||||
Message = "Attempting to run workflow: {WorkflowName}")]
|
||||
public static partial void LogAttemptingToRunWorkflow(this ILogger logger, string workflowName);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 13,
|
||||
Level = LogLevel.Warning,
|
||||
Message = "Running workflow: {WorkflowName}")]
|
||||
public static partial void LogRunningWorkflow(this ILogger logger, string? workflowName);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 14,
|
||||
Level = LogLevel.Warning,
|
||||
Message = "Attempting to execute activity in workflow '{WorkflowName}' for executor '{ExecutorName}'")]
|
||||
public static partial void LogAttemptingToExecuteActivity(this ILogger logger, string workflowName, string executorName);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 15,
|
||||
Level = LogLevel.Warning,
|
||||
Message = "Executing activity for executor '{ExecutorId}' of type '{ExecutorType}'")]
|
||||
public static partial void LogExecutingActivity(this ILogger logger, string executorId, string executorType);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 16,
|
||||
Level = LogLevel.Warning,
|
||||
Message = "Activity executed for executor '{ExecutorId}' with result: {Result}")]
|
||||
public static partial void LogActivityExecuted(this ILogger logger, string executorId, string result);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 17,
|
||||
Level = LogLevel.Debug,
|
||||
Message = "Executor '{ExecutorId}' skipped due to edge condition evaluation")]
|
||||
public static partial void LogExecutorSkipped(this ILogger logger, string executorId);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 18,
|
||||
Level = LogLevel.Debug,
|
||||
Message = "Waiting for external event '{EventName}' with input: {Input}")]
|
||||
public static partial void LogWaitingForExternalEvent(this ILogger logger, string eventName, string input);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 19,
|
||||
Level = LogLevel.Debug,
|
||||
Message = "Received external event '{EventName}' with response: {Response}")]
|
||||
public static partial void LogReceivedExternalEvent(this ILogger logger, string eventName, string response);
|
||||
}
|
||||
|
||||
@@ -21,9 +21,11 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.DurableTask.Client" />
|
||||
<PackageReference Include="Microsoft.DurableTask.Worker" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask;
|
||||
|
||||
/// <summary>
|
||||
/// A workflow context for activity execution that uses pipeline-based state management.
|
||||
/// State is passed in from the orchestration and updates are collected for return.
|
||||
/// </summary>
|
||||
internal sealed class PipelineActivityContext : IWorkflowContext
|
||||
{
|
||||
private readonly Dictionary<string, string> _initialState;
|
||||
private readonly Executor _executor;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PipelineActivityContext"/> class.
|
||||
/// </summary>
|
||||
/// <param name="initialState">The shared state passed from the orchestration.</param>
|
||||
/// <param name="executor">The executor running in this context.</param>
|
||||
public PipelineActivityContext(Dictionary<string, string>? initialState, Executor executor)
|
||||
{
|
||||
this._initialState = initialState ?? [];
|
||||
this._executor = executor;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the events that were added during activity execution.
|
||||
/// </summary>
|
||||
public List<WorkflowEvent> Events { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the state updates made during activity execution.
|
||||
/// </summary>
|
||||
public Dictionary<string, string?> StateUpdates { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the scopes that were cleared during activity execution.
|
||||
/// </summary>
|
||||
public HashSet<string> ClearedScopes { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the messages sent during activity execution via SendMessageAsync.
|
||||
/// Each entry is (serializedMessage, typeName).
|
||||
/// </summary>
|
||||
public List<(string Message, string TypeName)> SentMessages { get; } = [];
|
||||
|
||||
/// <inheritdoc/>
|
||||
public ValueTask AddEventAsync(WorkflowEvent workflowEvent, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (workflowEvent is not null)
|
||||
{
|
||||
this.Events.Add(workflowEvent);
|
||||
}
|
||||
|
||||
return default;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing workflow message types.")]
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing workflow message types.")]
|
||||
public ValueTask SendMessageAsync(object message, string? targetId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (message is not null)
|
||||
{
|
||||
// Capture the message and its type for routing in the orchestrator
|
||||
string serializedMessage = JsonSerializer.Serialize(message, message.GetType());
|
||||
string typeName = message.GetType().FullName ?? message.GetType().Name;
|
||||
this.SentMessages.Add((serializedMessage, typeName));
|
||||
}
|
||||
|
||||
return default;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public ValueTask YieldOutputAsync(object output, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (output is not null)
|
||||
{
|
||||
// Validate output type matches executor's declared output types (same as in-process execution)
|
||||
if (!CanOutput(this._executor.OutputTypes, output.GetType()))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Cannot output object of type {output.GetType().Name}. " +
|
||||
$"Expecting one of [{string.Join(", ", this._executor.OutputTypes)}].");
|
||||
}
|
||||
|
||||
this.Events.Add(new DurableYieldedOutputEvent(this._executor.Id, output));
|
||||
}
|
||||
|
||||
return default;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public ValueTask RequestHaltAsync()
|
||||
{
|
||||
this.Events.Add(new DurableHaltRequestedEvent(this._executor.Id));
|
||||
return default;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow state types.")]
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow state types.")]
|
||||
public ValueTask<T?> ReadStateAsync<T>(string key, string? scopeName = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
string scopeKey = GetScopeKey(scopeName, key);
|
||||
string normalizedScope = scopeName ?? "__default__";
|
||||
|
||||
// Check if scope was cleared
|
||||
if (this.ClearedScopes.Contains(normalizedScope))
|
||||
{
|
||||
// Only return from updates made after clear
|
||||
if (this.StateUpdates.TryGetValue(scopeKey, out string? updatedAfterClear) && updatedAfterClear is not null)
|
||||
{
|
||||
return ValueTask.FromResult(JsonSerializer.Deserialize<T>(updatedAfterClear));
|
||||
}
|
||||
|
||||
return ValueTask.FromResult<T?>(default);
|
||||
}
|
||||
|
||||
// Check local updates first (read-your-writes)
|
||||
if (this.StateUpdates.TryGetValue(scopeKey, out string? updated))
|
||||
{
|
||||
if (updated is null)
|
||||
{
|
||||
return ValueTask.FromResult<T?>(default);
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(JsonSerializer.Deserialize<T>(updated));
|
||||
}
|
||||
|
||||
// Fall back to initial state passed from orchestration
|
||||
if (this._initialState.TryGetValue(scopeKey, out string? initial))
|
||||
{
|
||||
return ValueTask.FromResult(JsonSerializer.Deserialize<T>(initial));
|
||||
}
|
||||
|
||||
return ValueTask.FromResult<T?>(default);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow state types.")]
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow state types.")]
|
||||
public async ValueTask<T> ReadOrInitStateAsync<T>(string key, Func<T> initialStateFactory, string? scopeName = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
T? value = await this.ReadStateAsync<T>(key, scopeName, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (value is not null)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
// Initialize with factory value
|
||||
T initialValue = initialStateFactory();
|
||||
await this.QueueStateUpdateAsync(key, initialValue, scopeName, cancellationToken).ConfigureAwait(false);
|
||||
return initialValue;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public ValueTask<HashSet<string>> ReadStateKeysAsync(string? scopeName = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
string scopePrefix = GetScopePrefix(scopeName);
|
||||
string normalizedScope = scopeName ?? "__default__";
|
||||
HashSet<string> keys = [];
|
||||
|
||||
// If scope was cleared, only return keys from updates made after clear
|
||||
if (this.ClearedScopes.Contains(normalizedScope))
|
||||
{
|
||||
foreach (KeyValuePair<string, string?> update in this.StateUpdates)
|
||||
{
|
||||
if (update.Key.StartsWith(scopePrefix, StringComparison.Ordinal) && update.Value is not null)
|
||||
{
|
||||
keys.Add(update.Key[scopePrefix.Length..]);
|
||||
}
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(keys);
|
||||
}
|
||||
|
||||
// Start with keys from initial state
|
||||
foreach (string stateKey in this._initialState.Keys)
|
||||
{
|
||||
if (stateKey.StartsWith(scopePrefix, StringComparison.Ordinal))
|
||||
{
|
||||
keys.Add(stateKey[scopePrefix.Length..]);
|
||||
}
|
||||
}
|
||||
|
||||
// Merge with updates
|
||||
foreach (KeyValuePair<string, string?> update in this.StateUpdates)
|
||||
{
|
||||
if (update.Key.StartsWith(scopePrefix, StringComparison.Ordinal))
|
||||
{
|
||||
string foundKey = update.Key[scopePrefix.Length..];
|
||||
if (update.Value is not null)
|
||||
{
|
||||
keys.Add(foundKey);
|
||||
}
|
||||
else
|
||||
{
|
||||
keys.Remove(foundKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(keys);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing workflow state types.")]
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing workflow state types.")]
|
||||
public ValueTask QueueStateUpdateAsync<T>(string key, T? value, string? scopeName = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
string scopeKey = GetScopeKey(scopeName, key);
|
||||
this.StateUpdates[scopeKey] = value is null ? null : JsonSerializer.Serialize(value);
|
||||
return default;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public ValueTask QueueClearScopeAsync(string? scopeName = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
string normalizedScope = scopeName ?? "__default__";
|
||||
this.ClearedScopes.Add(normalizedScope);
|
||||
|
||||
// Remove any pending updates in this scope
|
||||
string scopePrefix = GetScopePrefix(scopeName);
|
||||
List<string> keysToRemove = this.StateUpdates.Keys
|
||||
.Where(k => k.StartsWith(scopePrefix, StringComparison.Ordinal))
|
||||
.ToList();
|
||||
|
||||
foreach (string key in keysToRemove)
|
||||
{
|
||||
this.StateUpdates.Remove(key);
|
||||
}
|
||||
|
||||
return default;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IReadOnlyDictionary<string, string>? TraceContext => null;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool ConcurrentRunsEnabled => false;
|
||||
|
||||
private static bool CanOutput(ISet<Type> outputTypes, Type messageType)
|
||||
{
|
||||
foreach (Type type in outputTypes)
|
||||
{
|
||||
if (type.IsAssignableFrom(messageType))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string GetScopeKey(string? scopeName, string key)
|
||||
=> $"{GetScopePrefix(scopeName)}{key}";
|
||||
|
||||
private static string GetScopePrefix(string? scopeName)
|
||||
=> scopeName is null ? "__default__:" : $"{scopeName}:";
|
||||
}
|
||||
@@ -8,6 +8,7 @@ using Microsoft.DurableTask;
|
||||
using Microsoft.DurableTask.Client;
|
||||
using Microsoft.DurableTask.Worker;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask;
|
||||
|
||||
@@ -80,23 +81,45 @@ public static class ServiceCollectionExtensions
|
||||
DurableAgentsOptions options = new();
|
||||
configure(options);
|
||||
|
||||
IReadOnlyDictionary<string, Func<IServiceProvider, AIAgent>> agents = options.GetAgentFactories();
|
||||
IReadOnlyDictionary<string, Func<IServiceProvider, AIAgent>> newAgents = options.GetAgentFactories();
|
||||
|
||||
// The agent dictionary contains the real agent factories, which is used by the agent entities.
|
||||
services.AddSingleton(agents);
|
||||
// Check if we already have DurableAgentsOptions registered and merge with it
|
||||
ServiceDescriptor? existingOptionsDescriptor = services.FirstOrDefault(
|
||||
d => d.ServiceType == typeof(DurableAgentsOptions));
|
||||
|
||||
// Register the options so AgentEntity can access TTL configuration
|
||||
services.AddSingleton(options);
|
||||
if (existingOptionsDescriptor?.ImplementationInstance is DurableAgentsOptions existingOptions)
|
||||
{
|
||||
// Merge new agents into the existing options
|
||||
foreach (KeyValuePair<string, Func<IServiceProvider, AIAgent>> agent in newAgents)
|
||||
{
|
||||
if (!existingOptions.ContainsAgent(agent.Key))
|
||||
{
|
||||
existingOptions.AddAIAgentFactory(agent.Key, agent.Value, options.GetTimeToLive(agent.Key));
|
||||
}
|
||||
}
|
||||
|
||||
options = existingOptions;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Register the options so AgentEntity can access configuration
|
||||
services.AddSingleton(options);
|
||||
}
|
||||
|
||||
// The keyed services are used to resolve durable agent *proxy* instances for external clients.
|
||||
foreach (var factory in agents)
|
||||
foreach (var factory in newAgents)
|
||||
{
|
||||
services.AddKeyedSingleton(factory.Key, (sp, _) => factory.Value(sp).AsDurableAgentProxy(sp));
|
||||
}
|
||||
|
||||
// Register the agent factories dictionary for backward compatibility.
|
||||
// This allows consumers to retrieve agents via services.GetService<IReadOnlyDictionary<string, Func<IServiceProvider, AIAgent>>>().
|
||||
services.TryAddSingleton(
|
||||
sp => sp.GetRequiredService<DurableAgentsOptions>().GetAgentFactories());
|
||||
|
||||
// A custom data converter is needed because the default chat client uses camel case for JSON properties,
|
||||
// which is not the default behavior for the Durable Task SDK.
|
||||
services.AddSingleton<DataConverter, DefaultDataConverter>();
|
||||
services.TryAddSingleton<DataConverter, DefaultDataConverter>();
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the complete execution plan for a workflow, including parallel execution levels.
|
||||
/// </summary>
|
||||
[DebuggerDisplay("Start = {StartExecutorId}, Levels = {Levels.Count}")]
|
||||
internal sealed class WorkflowExecutionPlan
|
||||
{
|
||||
/// <summary>
|
||||
/// The execution levels in order. Each level contains executors that can run in parallel.
|
||||
/// </summary>
|
||||
public List<WorkflowExecutionLevel> Levels { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Maps each executor ID to its predecessors (for Fan-In result aggregation).
|
||||
/// </summary>
|
||||
public Dictionary<string, List<string>> Predecessors { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Maps each executor ID to its successors (for Fan-Out result distribution).
|
||||
/// </summary>
|
||||
public Dictionary<string, List<string>> Successors { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Maps edge connections (sourceId, targetId) to their condition functions.
|
||||
/// The condition function takes the predecessor's result and returns true if the edge should be followed.
|
||||
/// </summary>
|
||||
public Dictionary<(string SourceId, string TargetId), Func<object?, bool>?> EdgeConditions { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Maps executor IDs to their output types (for proper deserialization during condition evaluation).
|
||||
/// </summary>
|
||||
public Dictionary<string, Type?> ExecutorOutputTypes { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the starting executor ID for the workflow.
|
||||
/// </summary>
|
||||
public string StartExecutorId { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an executor in the workflow with its metadata.
|
||||
/// </summary>
|
||||
/// <param name="ExecutorId">The unique identifier of the executor.</param>
|
||||
/// <param name="IsAgenticExecutor">Indicates whether this executor is an agentic executor.</param>
|
||||
/// <param name="RequestPort">The request port if this executor is a request port executor; otherwise, null.</param>
|
||||
/// <param name="SubWorkflow">The sub-workflow if this executor is a sub-workflow executor; otherwise, null.</param>
|
||||
[DebuggerDisplay("{ExecutorId}, Agentic = {IsAgenticExecutor}, HITL = {IsRequestPortExecutor}, SubWorkflow = {IsSubworkflowExecutor}")]
|
||||
internal sealed record WorkflowExecutorInfo(string ExecutorId, bool IsAgenticExecutor, RequestPort? RequestPort = null, Workflow? SubWorkflow = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether this executor is a request port executor (human-in-the-loop).
|
||||
/// </summary>
|
||||
public bool IsRequestPortExecutor => this.RequestPort is not null;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether this executor is a sub-workflow executor.
|
||||
/// </summary>
|
||||
public bool IsSubworkflowExecutor => this.SubWorkflow is not null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a level of executors that can be executed in parallel (Fan-Out).
|
||||
/// All executors in the same level have their dependencies satisfied by previous levels.
|
||||
/// </summary>
|
||||
/// <param name="Level">The level number (0-based, starting from the root executor).</param>
|
||||
/// <param name="Executors">The executors that can run in parallel at this level.</param>
|
||||
/// <param name="IsFanIn">Indicates if this level is a Fan-In point (has executors with multiple predecessors).</param>
|
||||
[DebuggerDisplay("Level {Level}: {Executors.Count} executor(s), FanIn = {IsFanIn}")]
|
||||
internal sealed record WorkflowExecutionLevel(int Level, List<WorkflowExecutorInfo> Executors, bool IsFanIn);
|
||||
|
||||
/// <summary>
|
||||
/// Provides helper methods for analyzing and executing workflows.
|
||||
/// </summary>
|
||||
internal static class WorkflowHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Accepts a workflow instance and returns a list of executors with metadata in the order they should be executed.
|
||||
/// </summary>
|
||||
/// <param name="workflow">The workflow instance to analyze.</param>
|
||||
/// <returns>A list of executor information in topological order (execution order).</returns>
|
||||
public static List<WorkflowExecutorInfo> GetExecutorsFromWorkflowInOrder(Workflow workflow)
|
||||
{
|
||||
WorkflowExecutionPlan plan = GetExecutionPlan(workflow);
|
||||
|
||||
// Flatten the levels into a single list for backward compatibility
|
||||
List<WorkflowExecutorInfo> result = [];
|
||||
foreach (WorkflowExecutionLevel level in plan.Levels)
|
||||
{
|
||||
result.AddRange(level.Executors);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Analyzes the workflow and returns an execution plan that supports Fan-Out/Fan-In patterns.
|
||||
/// Executors at the same level can be executed in parallel (Fan-Out).
|
||||
/// Fan-In points are identified where multiple executors converge.
|
||||
/// Cyclic workflows are detected and marked for message-driven execution.
|
||||
/// </summary>
|
||||
/// <param name="workflow">The workflow instance to analyze.</param>
|
||||
/// <returns>An execution plan with parallel execution levels.</returns>
|
||||
public static WorkflowExecutionPlan GetExecutionPlan(Workflow workflow)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(workflow);
|
||||
|
||||
Dictionary<string, ExecutorBinding> executors = workflow.ReflectExecutors();
|
||||
Dictionary<string, HashSet<Edge>> edges = workflow.Edges;
|
||||
|
||||
WorkflowExecutionPlan plan = new()
|
||||
{
|
||||
StartExecutorId = workflow.StartExecutorId
|
||||
};
|
||||
|
||||
// Build adjacency lists (successors and predecessors)
|
||||
Dictionary<string, List<string>> successors = new(executors.Count);
|
||||
Dictionary<string, List<string>> predecessors = new(executors.Count);
|
||||
Dictionary<string, int> executorIndex = new(executors.Count);
|
||||
|
||||
// Initialize all executors and extract their output types
|
||||
int index = 0;
|
||||
foreach (KeyValuePair<string, ExecutorBinding> executor in executors)
|
||||
{
|
||||
executorIndex[executor.Key] = index++;
|
||||
successors[executor.Key] = [];
|
||||
predecessors[executor.Key] = [];
|
||||
|
||||
// Extract output type from executor type (e.g., Executor<TInput, TOutput> -> TOutput)
|
||||
plan.ExecutorOutputTypes[executor.Key] = GetExecutorOutputType(executor.Value.ExecutorType);
|
||||
}
|
||||
|
||||
// Build the graph from edges and extract edge conditions
|
||||
Dictionary<(string SourceId, string TargetId), Func<object?, bool>?> edgeConditions = [];
|
||||
foreach (KeyValuePair<string, HashSet<Edge>> edgeGroup in edges)
|
||||
{
|
||||
string sourceId = edgeGroup.Key;
|
||||
List<string> sourceSuccessors = successors[sourceId];
|
||||
|
||||
foreach (Edge edge in edgeGroup.Value)
|
||||
{
|
||||
foreach (string sinkId in edge.Data.Connection.SinkIds)
|
||||
{
|
||||
if (executorIndex.ContainsKey(sinkId))
|
||||
{
|
||||
sourceSuccessors.Add(sinkId);
|
||||
predecessors[sinkId].Add(sourceId);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract condition from DirectEdgeData if present
|
||||
DirectEdgeData? directEdge = edge.DirectEdgeData;
|
||||
if (directEdge is not null)
|
||||
{
|
||||
edgeConditions[(directEdge.SourceId, directEdge.SinkId)] = directEdge.Condition;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Detect back-edges using DFS from start executor
|
||||
HashSet<(string Source, string Target)> backEdges = DetectBackEdges(workflow.StartExecutorId, successors);
|
||||
|
||||
// Calculate in-degrees, EXCLUDING back-edges
|
||||
int[] inDegree = new int[executors.Count];
|
||||
foreach (string executorId in executors.Keys)
|
||||
{
|
||||
foreach (string pred in predecessors[executorId])
|
||||
{
|
||||
// Only count edge if it's NOT a back-edge
|
||||
if (!backEdges.Contains((pred, executorId)))
|
||||
{
|
||||
inDegree[executorIndex[executorId]]++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Store edge conditions in the plan
|
||||
foreach (KeyValuePair<(string SourceId, string TargetId), Func<object?, bool>?> condition in edgeConditions)
|
||||
{
|
||||
plan.EdgeConditions[condition.Key] = condition.Value;
|
||||
}
|
||||
|
||||
// Store the graph structure in the plan
|
||||
foreach (string executorId in executors.Keys)
|
||||
{
|
||||
plan.Predecessors[executorId] = predecessors[executorId];
|
||||
plan.Successors[executorId] = successors[executorId];
|
||||
}
|
||||
|
||||
// Build execution levels using queue-based Kahn's algorithm
|
||||
// Process all nodes with in-degree 0 at once (same level) for parallel execution
|
||||
Queue<string> currentLevel = new();
|
||||
foreach (KeyValuePair<string, int> kvp in executorIndex)
|
||||
{
|
||||
if (inDegree[kvp.Value] == 0)
|
||||
{
|
||||
currentLevel.Enqueue(kvp.Key);
|
||||
}
|
||||
}
|
||||
|
||||
int levelNumber = 0;
|
||||
int processedCount = 0;
|
||||
|
||||
while (currentLevel.Count > 0)
|
||||
{
|
||||
List<WorkflowExecutorInfo> levelExecutors = new(currentLevel.Count);
|
||||
Queue<string> nextLevel = new();
|
||||
bool isFanIn = false;
|
||||
|
||||
while (currentLevel.Count > 0)
|
||||
{
|
||||
string executorId = currentLevel.Dequeue();
|
||||
processedCount++;
|
||||
|
||||
ExecutorBinding executorBinding = executors[executorId];
|
||||
bool isAgentic = IsAgentExecutorType(executorBinding.ExecutorType);
|
||||
RequestPort? requestPort = (executorBinding is RequestPortBinding rpb) ? rpb.Port : null;
|
||||
Workflow? subWorkflow = (executorBinding is SubworkflowBinding swb) ? swb.WorkflowInstance : null;
|
||||
levelExecutors.Add(new WorkflowExecutorInfo(executorId, isAgentic, requestPort, subWorkflow));
|
||||
|
||||
// Check Fan-In for this executor (excluding back-edges)
|
||||
int nonBackEdgePredecessors = predecessors[executorId]
|
||||
.Count(pred => !backEdges.Contains((pred, executorId)));
|
||||
if (nonBackEdgePredecessors > 1)
|
||||
{
|
||||
isFanIn = true;
|
||||
}
|
||||
|
||||
// Decrement in-degree of all successors (excluding back-edges) and enqueue those ready for next level
|
||||
foreach (string successor in successors[executorId])
|
||||
{
|
||||
// Skip back-edges for topological ordering
|
||||
if (backEdges.Contains((executorId, successor)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
int successorIdx = executorIndex[successor];
|
||||
if (--inDegree[successorIdx] == 0)
|
||||
{
|
||||
nextLevel.Enqueue(successor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
plan.Levels.Add(new WorkflowExecutionLevel(levelNumber, levelExecutors, isFanIn));
|
||||
levelNumber++;
|
||||
currentLevel = nextLevel;
|
||||
}
|
||||
|
||||
// Handle any remaining executors not processed (shouldn't happen if back-edge detection is correct)
|
||||
if (processedCount < executors.Count)
|
||||
{
|
||||
List<WorkflowExecutorInfo> remainingExecutors = [];
|
||||
foreach (KeyValuePair<string, ExecutorBinding> executor in executors)
|
||||
{
|
||||
if (inDegree[executorIndex[executor.Key]] > 0)
|
||||
{
|
||||
bool isAgentic = IsAgentExecutorType(executor.Value.ExecutorType);
|
||||
RequestPort? requestPort = (executor.Value is RequestPortBinding rpb) ? rpb.Port : null;
|
||||
Workflow? subWorkflow = (executor.Value is SubworkflowBinding swb) ? swb.WorkflowInstance : null;
|
||||
remainingExecutors.Add(new WorkflowExecutorInfo(executor.Key, isAgentic, requestPort, subWorkflow));
|
||||
}
|
||||
}
|
||||
|
||||
if (remainingExecutors.Count > 0)
|
||||
{
|
||||
bool isFanIn = remainingExecutors.Exists(e => predecessors[e.ExecutorId].Count > 1);
|
||||
plan.Levels.Add(new WorkflowExecutionLevel(levelNumber, remainingExecutors, isFanIn));
|
||||
}
|
||||
}
|
||||
|
||||
return plan;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detects back-edges in the graph using DFS.
|
||||
/// A back-edge is an edge that points to an ancestor in the DFS tree (creates a cycle).
|
||||
/// </summary>
|
||||
/// <param name="startId">The starting executor ID for DFS traversal.</param>
|
||||
/// <param name="successors">The adjacency list mapping each executor to its successors.</param>
|
||||
/// <returns>A set of back-edges as (source, target) tuples.</returns>
|
||||
private static HashSet<(string Source, string Target)> DetectBackEdges(
|
||||
string startId,
|
||||
Dictionary<string, List<string>> successors)
|
||||
{
|
||||
HashSet<(string, string)> backEdges = [];
|
||||
HashSet<string> visited = [];
|
||||
HashSet<string> inStack = []; // Nodes in current DFS path
|
||||
|
||||
void Dfs(string nodeId)
|
||||
{
|
||||
visited.Add(nodeId);
|
||||
inStack.Add(nodeId);
|
||||
|
||||
if (successors.TryGetValue(nodeId, out List<string>? neighbors))
|
||||
{
|
||||
foreach (string neighbor in neighbors)
|
||||
{
|
||||
if (inStack.Contains(neighbor))
|
||||
{
|
||||
// Edge to ancestor in current path = back-edge (creates cycle)
|
||||
backEdges.Add((nodeId, neighbor));
|
||||
}
|
||||
else if (!visited.Contains(neighbor))
|
||||
{
|
||||
Dfs(neighbor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inStack.Remove(nodeId);
|
||||
}
|
||||
|
||||
// Start DFS from the workflow's start executor
|
||||
Dfs(startId);
|
||||
|
||||
// Handle any disconnected components (shouldn't happen in valid workflows, but for safety)
|
||||
foreach (string nodeId in successors.Keys)
|
||||
{
|
||||
if (!visited.Contains(nodeId))
|
||||
{
|
||||
Dfs(nodeId);
|
||||
}
|
||||
}
|
||||
|
||||
return backEdges;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the specified executor type is an agentic executor.
|
||||
/// </summary>
|
||||
/// <param name="executorType">The executor type to check.</param>
|
||||
/// <returns><c>true</c> if the executor is an agentic executor; otherwise, <c>false</c>.</returns>
|
||||
internal static bool IsAgentExecutorType(Type executorType)
|
||||
{
|
||||
// hack for now. In the future, the MAF type could expose something which can help with this.
|
||||
// Check if the type name or assembly indicates it's an agent executor
|
||||
// This includes AgentRunStreamingExecutor, AgentExecutor, ChatClientAgent wrappers, etc.
|
||||
string typeName = executorType.FullName ?? executorType.Name;
|
||||
string assemblyName = executorType.Assembly.GetName().Name ?? string.Empty;
|
||||
|
||||
return typeName.Contains("AIAgentHostExecutor", StringComparison.OrdinalIgnoreCase) &&
|
||||
assemblyName.Contains("Microsoft.Agents.AI", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the output type from an executor type.
|
||||
/// For Executor<TInput, TOutput>, returns TOutput.
|
||||
/// For Executor<TInput>, returns null (void output).
|
||||
/// </summary>
|
||||
/// <param name="executorType">The executor type to analyze.</param>
|
||||
/// <returns>The output type, or null if the executor has no typed output.</returns>
|
||||
private static Type? GetExecutorOutputType(Type executorType)
|
||||
{
|
||||
// Walk up the inheritance chain to find Executor<TInput, TOutput> or Executor<TInput>
|
||||
Type? currentType = executorType;
|
||||
while (currentType is not null)
|
||||
{
|
||||
if (currentType.IsGenericType)
|
||||
{
|
||||
Type genericDefinition = currentType.GetGenericTypeDefinition();
|
||||
Type[] genericArgs = currentType.GetGenericArguments();
|
||||
|
||||
// Check for Executor<TInput, TOutput> (2 type parameters)
|
||||
if (genericArgs.Length == 2 && genericDefinition.Name.StartsWith("Executor", StringComparison.Ordinal))
|
||||
{
|
||||
return genericArgs[1]; // TOutput
|
||||
}
|
||||
|
||||
// Check for Executor<TInput> (1 type parameter) - void return
|
||||
if (genericArgs.Length == 1 && genericDefinition.Name.StartsWith("Executor", StringComparison.Ordinal))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
currentType = currentType.BaseType;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask;
|
||||
|
||||
/// <summary>
|
||||
/// Provides helper methods for workflow naming conventions used in durable orchestrations.
|
||||
/// </summary>
|
||||
internal static class WorkflowNamingHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// The prefix used for durable workflow orchestration function names.
|
||||
/// </summary>
|
||||
public const string OrchestrationFunctionPrefix = "dafx-";
|
||||
|
||||
/// <summary>
|
||||
/// Converts a workflow name to its corresponding orchestration function name.
|
||||
/// </summary>
|
||||
/// <param name="workflowName">The workflow name.</param>
|
||||
/// <returns>The orchestration function name.</returns>
|
||||
/// <exception cref="ArgumentException">Thrown when the workflow name is null or empty.</exception>
|
||||
public static string ToOrchestrationFunctionName(string workflowName)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(workflowName);
|
||||
return $"{OrchestrationFunctionPrefix}{workflowName}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts an orchestration function name back to its workflow name.
|
||||
/// </summary>
|
||||
/// <param name="orchestrationFunctionName">The orchestration function name.</param>
|
||||
/// <returns>The workflow name.</returns>
|
||||
/// <exception cref="ArgumentException">Thrown when the orchestration function name is null, empty, or doesn't have the expected prefix.</exception>
|
||||
public static string ToWorkflowName(string orchestrationFunctionName)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(orchestrationFunctionName);
|
||||
|
||||
if (!orchestrationFunctionName.StartsWith(OrchestrationFunctionPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Orchestration function name '{orchestrationFunctionName}' does not start with the expected '{OrchestrationFunctionPrefix}' prefix.",
|
||||
nameof(orchestrationFunctionName));
|
||||
}
|
||||
|
||||
string workflowName = orchestrationFunctionName[OrchestrationFunctionPrefix.Length..];
|
||||
|
||||
if (string.IsNullOrEmpty(workflowName))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Orchestration function name '{orchestrationFunctionName}' does not contain a workflow name after the prefix.",
|
||||
nameof(orchestrationFunctionName));
|
||||
}
|
||||
|
||||
return workflowName;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to convert an orchestration function name back to its workflow name.
|
||||
/// </summary>
|
||||
/// <param name="orchestrationFunctionName">The orchestration function name.</param>
|
||||
/// <param name="workflowName">When this method returns, contains the workflow name if the conversion succeeded, or null if it failed.</param>
|
||||
/// <returns><c>true</c> if the conversion succeeded; otherwise, <c>false</c>.</returns>
|
||||
public static bool TryGetWorkflowName(string? orchestrationFunctionName, out string? workflowName)
|
||||
{
|
||||
workflowName = null;
|
||||
|
||||
if (string.IsNullOrEmpty(orchestrationFunctionName))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!orchestrationFunctionName.StartsWith(OrchestrationFunctionPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
workflowName = orchestrationFunctionName[OrchestrationFunctionPrefix.Length..];
|
||||
return !string.IsNullOrEmpty(workflowName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The suffix separator used when the workflow builder appends a GUID to executor IDs.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// For agentic executors, the workflow builder appends a GUID suffix to ensure uniqueness.
|
||||
/// For example: "Physicist_8884e71021334ce49517fa2b17b1695b".
|
||||
/// </remarks>
|
||||
private const char ExecutorIdSuffixSeparator = '_';
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the executor name from an executor ID.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// For non-agentic executors, the executor ID is the same as the executor name (e.g., "OrderParser").
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// For agentic executors, the workflow builder appends a GUID suffix separated by an underscore
|
||||
/// (e.g., "Physicist_8884e71021334ce49517fa2b17b1695b"). This method extracts just the name portion.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="executorId">The executor ID, which may contain a GUID suffix.</param>
|
||||
/// <returns>The executor name without any GUID suffix.</returns>
|
||||
/// <exception cref="ArgumentException">Thrown when the executor ID is null or empty.</exception>
|
||||
public static string GetExecutorName(string executorId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(executorId);
|
||||
|
||||
int separatorIndex = executorId.IndexOf(ExecutorIdSuffixSeparator);
|
||||
return separatorIndex > 0 ? executorId[..separatorIndex] : executorId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the executor ID contains a GUID suffix.
|
||||
/// </summary>
|
||||
/// <param name="executorId">The executor ID to check.</param>
|
||||
/// <returns><c>true</c> if the executor ID contains a suffix; otherwise, <c>false</c>.</returns>
|
||||
public static bool HasExecutorIdSuffix(string? executorId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(executorId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
int separatorIndex = executorId.IndexOf(ExecutorIdSuffixSeparator);
|
||||
return separatorIndex > 0 && separatorIndex < executorId.Length - 1;
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,44 @@ internal sealed class BuiltInFunctionExecutor : IFunctionExecutor
|
||||
IFunctionInputBindingFeature? functionInputBindingFeature = context.Features.Get<IFunctionInputBindingFeature>() ??
|
||||
throw new InvalidOperationException("Function input binding feature is not available on the current context.");
|
||||
|
||||
if (context.FunctionDefinition.EntryPoint == BuiltInFunctions.InvokeWorkflowActivityFunctionEntryPoint)
|
||||
{
|
||||
// Bind all inputs to get the input string and DurableTaskClient
|
||||
FunctionInputBindingResult? bindingResults = await functionInputBindingFeature.BindFunctionInputAsync(context);
|
||||
if (bindingResults is not { Values: { } activityBindings })
|
||||
{
|
||||
throw new InvalidOperationException($"Function input binding failed for the invocation {context.InvocationId}");
|
||||
}
|
||||
|
||||
DurableTaskClient? activityDurableTaskClient = null;
|
||||
string? activityInput = null;
|
||||
foreach (object? binding in activityBindings)
|
||||
{
|
||||
if (binding is string stringInput)
|
||||
{
|
||||
activityInput = stringInput;
|
||||
}
|
||||
|
||||
if (binding is DurableTaskClient client)
|
||||
{
|
||||
activityDurableTaskClient = client;
|
||||
}
|
||||
}
|
||||
|
||||
if (activityInput is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Activity input binding is missing for the invocation {context.InvocationId}.");
|
||||
}
|
||||
|
||||
if (activityDurableTaskClient is null)
|
||||
{
|
||||
throw new InvalidOperationException($"DurableTaskClient binding is missing for the invocation {context.InvocationId}.");
|
||||
}
|
||||
|
||||
context.GetInvocationResult().Value = await BuiltInFunctions.InvokeWorkflowActivityAsync(activityInput, activityDurableTaskClient, context);
|
||||
return;
|
||||
}
|
||||
|
||||
FunctionInputBindingResult? inputBindingResults = await functionInputBindingFeature.BindFunctionInputAsync(context);
|
||||
if (inputBindingResults is not { Values: { } values })
|
||||
{
|
||||
@@ -102,6 +140,32 @@ internal sealed class BuiltInFunctionExecutor : IFunctionExecutor
|
||||
return;
|
||||
}
|
||||
|
||||
if (context.FunctionDefinition.EntryPoint == BuiltInFunctions.RunWorkflowOrechstrtationHttpFunctionEntryPoint)
|
||||
{
|
||||
if (httpRequestData == null)
|
||||
{
|
||||
throw new InvalidOperationException($"HTTP request data binding is missing for the invocation {context.InvocationId}.");
|
||||
}
|
||||
|
||||
context.GetInvocationResult().Value = await BuiltInFunctions.RunWorkflowOrechstrtationHttpTriggerAsync(
|
||||
httpRequestData,
|
||||
durableTaskClient,
|
||||
context);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle workflow MCP tool trigger
|
||||
if (context.FunctionDefinition.EntryPoint == BuiltInFunctions.RunWorkflowMcpToolFunctionEntryPoint)
|
||||
{
|
||||
if (mcpToolInvocationContext is null)
|
||||
{
|
||||
throw new InvalidOperationException($"MCP tool invocation context binding is missing for the invocation {context.InvocationId}.");
|
||||
}
|
||||
|
||||
context.GetInvocationResult().Value = await BuiltInFunctions.RunWorkflowMcpToolAsync(mcpToolInvocationContext, durableTaskClient, context);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Unsupported function entry point '{context.FunctionDefinition.EntryPoint}' for invocation {context.InvocationId}.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,30 @@ internal static class BuiltInFunctions
|
||||
|
||||
internal static readonly string RunAgentHttpFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RunAgentHttpAsync)}";
|
||||
internal static readonly string RunAgentEntityFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(InvokeAgentAsync)}";
|
||||
internal static readonly string RunWorkflowOrechstrtationHttpFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RunWorkflowOrechstrtationHttpTriggerAsync)}";
|
||||
internal static readonly string InvokeWorkflowActivityFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(InvokeWorkflowActivityAsync)}";
|
||||
internal static readonly string RunAgentMcpToolFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RunMcpToolAsync)}";
|
||||
internal static readonly string RunWorkflowMcpToolFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RunWorkflowMcpToolAsync)}";
|
||||
|
||||
#pragma warning disable IL3000 // Avoid accessing Assembly file path when publishing as a single file - Azure Functions does not use single-file publishing
|
||||
internal static readonly string ScriptFile = Path.GetFileName(typeof(BuiltInFunctions).Assembly.Location);
|
||||
#pragma warning restore IL3000
|
||||
|
||||
// Exposed as an activity trigger for workflow executors
|
||||
public static Task<string> InvokeWorkflowActivityAsync(
|
||||
[ActivityTrigger] string input,
|
||||
[DurableClient] DurableTaskClient durableTaskClient,
|
||||
FunctionContext functionContext)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(input);
|
||||
ArgumentNullException.ThrowIfNull(durableTaskClient);
|
||||
ArgumentNullException.ThrowIfNull(functionContext);
|
||||
|
||||
string activityFunctionName = functionContext.FunctionDefinition.Name;
|
||||
|
||||
FunctionsWorkflowRunner runner = functionContext.InstanceServices.GetRequiredService<FunctionsWorkflowRunner>();
|
||||
return runner.ExecuteActivityAsync(activityFunctionName, input, durableTaskClient, functionContext);
|
||||
}
|
||||
|
||||
// Exposed as an entity trigger via AgentFunctionsProvider
|
||||
public static Task<string> InvokeAgentAsync(
|
||||
@@ -43,6 +66,24 @@ internal static class BuiltInFunctions
|
||||
return GrpcEntityRunner.LoadAndRunAsync(encodedEntityRequest, entity, combinedServiceProvider);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invokes a workflow orchestration in response to an HTTP request.
|
||||
/// </summary>
|
||||
public static async Task<HttpResponseData> RunWorkflowOrechstrtationHttpTriggerAsync(
|
||||
[HttpTrigger] HttpRequestData req,
|
||||
[DurableClient] DurableTaskClient client,
|
||||
FunctionContext context)
|
||||
{
|
||||
var workflowName = context.FunctionDefinition.Name.Replace(HttpPrefix, string.Empty);
|
||||
var orchestrationFunctionName = WorkflowNamingHelper.ToOrchestrationFunctionName(workflowName);
|
||||
var inputMessage = await req.ReadAsStringAsync();
|
||||
string instanceId = await client.ScheduleNewOrchestrationInstanceAsync(orchestrationFunctionName, inputMessage);
|
||||
|
||||
HttpResponseData response = req.CreateResponse(HttpStatusCode.Accepted);
|
||||
await response.WriteStringAsync($"InvokeWorkflowOrechstrtationAsync is invoked for {workflowName}. Orchestration instanceId: {instanceId}");
|
||||
return response;
|
||||
}
|
||||
|
||||
public static async Task<HttpResponseData> RunAgentHttpAsync(
|
||||
[HttpTrigger] HttpRequestData req,
|
||||
[DurableClient] DurableTaskClient client,
|
||||
@@ -178,6 +219,39 @@ internal static class BuiltInFunctions
|
||||
return agentResponse.Text;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs a workflow via MCP tool trigger.
|
||||
/// </summary>
|
||||
public static async Task<string?> RunWorkflowMcpToolAsync(
|
||||
[McpToolTrigger("BuiltInWorkflowMcpTool")] ToolInvocationContext context,
|
||||
[DurableClient] DurableTaskClient client,
|
||||
FunctionContext functionContext)
|
||||
{
|
||||
if (context.Arguments is null)
|
||||
{
|
||||
throw new ArgumentException("MCP Tool invocation is missing required arguments.");
|
||||
}
|
||||
|
||||
if (!context.Arguments.TryGetValue("input", out object? inputObj) || inputObj is not string input)
|
||||
{
|
||||
throw new ArgumentException("MCP Tool invocation is missing required 'input' argument of type string.");
|
||||
}
|
||||
|
||||
// Extract workflow name from the MCP tool name (format: mcptool-workflow-{workflowName})
|
||||
string workflowName = context.Name;
|
||||
string orchestrationFunctionName = WorkflowNamingHelper.ToOrchestrationFunctionName(workflowName);
|
||||
|
||||
string instanceId = await client.ScheduleNewOrchestrationInstanceAsync(orchestrationFunctionName, input);
|
||||
|
||||
// Wait for the orchestration to complete and return the result
|
||||
OrchestrationMetadata? metadata = await client.WaitForInstanceCompletionAsync(
|
||||
instanceId,
|
||||
getInputsAndOutputs: true,
|
||||
cancellation: functionContext.CancellationToken);
|
||||
|
||||
return metadata?.ReadOutputAs<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an error response with the specified status code and error message.
|
||||
/// </summary>
|
||||
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.DurableTask;
|
||||
using Microsoft.Azure.Functions.Worker.Builder;
|
||||
using Microsoft.Azure.Functions.Worker.Core.FunctionMetadata;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
|
||||
|
||||
/// <summary>
|
||||
/// Shared configuration logic for durable agents and workflows.
|
||||
/// This class consolidates common service registrations used by both
|
||||
/// <see cref="FunctionsApplicationBuilderExtensions.ConfigureDurableAgents"/> and
|
||||
/// <see cref="DurableOptionsExtensions.ConfigureDurableOptions"/>.
|
||||
/// </summary>
|
||||
internal static class CoreAgentConfigurationExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers the core agent services required for durable agents.
|
||||
/// </summary>
|
||||
/// <param name="builder">The functions application builder.</param>
|
||||
/// <returns>The functions application builder for method chaining.</returns>
|
||||
internal static FunctionsApplicationBuilder RegisterCoreAgentServices(this FunctionsApplicationBuilder builder)
|
||||
{
|
||||
builder.Services.TryAddSingleton<IFunctionsAgentOptionsProvider>(_ =>
|
||||
new DefaultFunctionsAgentOptionsProvider(DurableAgentsOptionsExtensions.GetAgentOptionsSnapshot()));
|
||||
|
||||
builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IFunctionMetadataTransformer, DurableAgentFunctionMetadataTransformer>());
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers the workflow-specific services required for durable workflows.
|
||||
/// This should only be called when workflows are configured in the application.
|
||||
/// </summary>
|
||||
/// <param name="builder">The functions application builder.</param>
|
||||
/// <returns>The functions application builder for method chaining.</returns>
|
||||
internal static FunctionsApplicationBuilder RegisterWorkflowServices(this FunctionsApplicationBuilder builder)
|
||||
{
|
||||
// Register FunctionsWorkflowRunner as a singleton
|
||||
builder.Services.TryAddSingleton<FunctionsWorkflowRunner>();
|
||||
|
||||
// Also register it as DurableWorkflowRunner so orchestrations can resolve it by base type
|
||||
builder.Services.TryAddSingleton<DurableWorkflowRunner>(sp => sp.GetRequiredService<FunctionsWorkflowRunner>());
|
||||
|
||||
builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IFunctionMetadataTransformer, DurableWorkflowFunctionMetadataTransformer>());
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configures the middleware and executor for handling built-in function execution.
|
||||
/// This is shared by both agents and workflows, handling Agent HTTP, MCP tool,
|
||||
/// workflow orchestration, and Entity invocations.
|
||||
/// </summary>
|
||||
/// <param name="builder">The functions application builder.</param>
|
||||
/// <returns>The functions application builder for method chaining.</returns>
|
||||
internal static FunctionsApplicationBuilder ConfigureBuiltInFunctionMiddleware(this FunctionsApplicationBuilder builder)
|
||||
{
|
||||
builder.Services.TryAddSingleton<BuiltInFunctionExecutor>();
|
||||
|
||||
builder.UseWhen<BuiltInFunctionExecutionMiddleware>(static context =>
|
||||
IsBuiltInFunction(context.FunctionDefinition.EntryPoint));
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
private static bool IsBuiltInFunction(string? entryPoint)
|
||||
{
|
||||
return string.Equals(entryPoint, BuiltInFunctions.RunAgentHttpFunctionEntryPoint, StringComparison.Ordinal)
|
||||
|| string.Equals(entryPoint, BuiltInFunctions.RunAgentMcpToolFunctionEntryPoint, StringComparison.Ordinal)
|
||||
|| string.Equals(entryPoint, BuiltInFunctions.RunWorkflowMcpToolFunctionEntryPoint, StringComparison.Ordinal)
|
||||
|| string.Equals(entryPoint, BuiltInFunctions.RunWorkflowOrechstrtationHttpFunctionEntryPoint, StringComparison.Ordinal)
|
||||
|| string.Equals(entryPoint, BuiltInFunctions.InvokeWorkflowActivityFunctionEntryPoint, StringComparison.Ordinal)
|
||||
|| string.Equals(entryPoint, BuiltInFunctions.RunAgentEntityFunctionEntryPoint, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user