mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
242
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 | ||
|
|
330c2607ad | ||
|
|
6e8c7c42c8 | ||
|
|
21e00c054b | ||
|
|
9e57dc7dbe | ||
|
|
90164a6bc1 | ||
|
|
7b8777d1fc | ||
|
|
a97bc322ac | ||
|
|
e8b32ca337 | ||
|
|
e229dfa7e5 | ||
|
|
50c2539f3a | ||
|
|
5436354a83 | ||
|
|
9f893a32a6 | ||
|
|
b072df32c5 | ||
|
|
87c9d74bd7 | ||
|
|
958e6d27ce | ||
|
|
9a37411dc1 | ||
|
|
ea7818d390 | ||
|
|
4940d0ef36 | ||
|
|
f47645cdc8 | ||
|
|
2cf4980d77 | ||
|
|
89285a5f1c | ||
|
|
b4a71f00a3 | ||
|
|
082f39e77e | ||
|
|
6f1ab66795 | ||
|
|
d402d92a47 | ||
|
|
d55dd5f253 | ||
|
|
88e0ee1a2c | ||
|
|
aa6579f38c | ||
|
|
41cc34421f | ||
|
|
eac8baac09 | ||
|
|
77236bf0ec | ||
|
|
6d7690e485 | ||
|
|
6b5437e4ec | ||
|
|
db8a59bd3d | ||
|
|
83e6229c11 | ||
|
|
73761aa4a3 | ||
|
|
742937194a | ||
|
|
74401266e6 | ||
|
|
f8c84d4ee6 | ||
|
|
3ec881509c | ||
|
|
8ee379d344 | ||
|
|
2a43caefaa | ||
|
|
f54248b79f | ||
|
|
0f29637b86 | ||
|
|
e0b9be7e08 | ||
|
|
83e8965c8e | ||
|
|
3c1be2a713 | ||
|
|
467d3a60ed | ||
|
|
3243652df6 | ||
|
|
915df3b404 | ||
|
|
f87e55ba33 | ||
|
|
9bfa1a913c | ||
|
|
9e3b2fa09a | ||
|
|
5687e13221 | ||
|
|
a151f10cc2 | ||
|
|
b773830e4b | ||
|
|
975884f32d | ||
|
|
b5ca0c8eda | ||
|
|
dd3e2b6e53 | ||
|
|
48d124efbe | ||
|
|
6e9420f614 | ||
|
|
2ab859dd94 | ||
|
|
e192af93a7 | ||
|
|
3dbdecedda | ||
|
|
15d0c34d9f | ||
|
|
620da7a829 | ||
|
|
80b25a782b | ||
|
|
ffe2e787ba | ||
|
|
cb2862d4c3 | ||
|
|
9b9a0f178c | ||
|
|
6c956ec596 | ||
|
|
99c5718696 | ||
|
|
f56808b279 | ||
|
|
c70e594e6c | ||
|
|
8b1449024e | ||
|
|
d8cf8361bd | ||
|
|
1ae0b09e42 | ||
|
|
c063fc77e6 | ||
|
|
04657c207a | ||
|
|
655a59a75f | ||
|
|
7d2d34511c | ||
|
|
0b152418b6 | ||
|
|
3e97425245 | ||
|
|
5faa2851bb | ||
|
|
9c094573e8 | ||
|
|
b2893fbc00 | ||
|
|
203fb7b1c4 | ||
|
|
ef44fb4960 | ||
|
|
e63c148fc7 | ||
|
|
c7cb5be231 | ||
|
|
3e13909e59 | ||
|
|
3a5fe31263 | ||
|
|
bb6ecd9c71 | ||
|
|
6e3bc219e0 | ||
|
|
551c2c3abe | ||
|
|
6445b6b3a6 | ||
|
|
d28ad2d7df | ||
|
|
88968da0bd | ||
|
|
50d34aec91 | ||
|
|
13a5b70703 | ||
|
|
9c04196491 | ||
|
|
01c5aabda5 | ||
|
|
3f7ea350dc | ||
|
|
92435c6ab5 | ||
|
|
f6086e4ccd | ||
|
|
99fac4ca56 | ||
|
|
7aa72f6fdb | ||
|
|
49cecf324c | ||
|
|
b88b2c3190 | ||
|
|
ab493af110 | ||
|
|
33888641ec | ||
|
|
299a5110ed | ||
|
|
f508f1d6da | ||
|
|
e9d97ce6b7 | ||
|
|
f4ab586f11 | ||
|
|
a118fd5c07 | ||
|
|
521f04632d | ||
|
|
dd69cabc67 | ||
|
|
2e1189ca65 | ||
|
|
db283cd396 | ||
|
|
f49e537721 | ||
|
|
202f557c71 | ||
|
|
ea370f8ff6 | ||
|
|
24c822590f | ||
|
|
953fde69ac | ||
|
|
7a05849609 | ||
|
|
0aa0579b1b | ||
|
|
844d345106 | ||
|
|
ed5278c41d | ||
|
|
928c9d54ad | ||
|
|
0aba02c402 | ||
|
|
3ef67eff10 | ||
|
|
deea844bc7 | ||
|
|
577ad4b838 | ||
|
|
8b4f7d5e29 | ||
|
|
4b8a545589 | ||
|
|
5ab47596ff | ||
|
|
a32702cf38 | ||
|
|
8b743af217 | ||
|
|
0e152a0e33 | ||
|
|
3b77192ad0 | ||
|
|
defe0f1a89 | ||
|
|
85d70f01f6 | ||
|
|
6930c0f0b6 | ||
|
|
d83cf93f07 | ||
|
|
8783ac58f1 | ||
|
|
e15eab7da6 | ||
|
|
19a9e13788 | ||
|
|
b0a7a1fcb8 | ||
|
|
a841bdd1cc | ||
|
|
d46adffe6c | ||
|
|
b0b5777363 | ||
|
|
37b4cfd024 | ||
|
|
ff9343d7cc | ||
|
|
8ff34f9a43 | ||
|
|
e3f8bfc645 | ||
|
|
b4f2709b6d | ||
|
|
e5c11d38d6 | ||
|
|
a71f768331 | ||
|
|
0298e0a401 | ||
|
|
ca1532cf22 | ||
|
|
360839782c | ||
|
|
ee53fe4666 | ||
|
|
3cd805f0bf | ||
|
|
c7ddb8aa14 | ||
|
|
d5527982b6 | ||
|
|
ec1c5e9c11 | ||
|
|
06cdcb93f0 | ||
|
|
6adcac2e97 | ||
|
|
8fca71e5ad | ||
|
|
2bde58f915 | ||
|
|
03a403d2fa | ||
|
|
e319707058 | ||
|
|
54f482df73 | ||
|
|
754dfb2c9d | ||
|
|
b15466f058 | ||
|
|
3a7047f6e4 | ||
|
|
2f06fe557a | ||
|
|
1dbf3fd5cf | ||
|
|
0132cf65e4 | ||
|
|
a53a3c7af8 | ||
|
|
3c322c91e7 | ||
|
|
958a488f96 | ||
|
|
11d6dcfe80 | ||
|
|
3139347526 | ||
|
|
3c379718e9 | ||
|
|
a7298757f5 | ||
|
|
0dcebc6eae | ||
|
|
e0ff153ee9 | ||
|
|
e008144187 | ||
|
|
0fc7933a92 | ||
|
|
d7434d59ce | ||
|
|
eb1117fff4 | ||
|
|
16230d3b20 | ||
|
|
8d53b20026 | ||
|
|
c376868ec9 | ||
|
|
8bb9927f3c | ||
|
|
194486c4cc | ||
|
|
0413f4220a | ||
|
|
67e83042cf | ||
|
|
5da1c2fd4c | ||
|
|
989b6ebe71 | ||
|
|
3481914981 | ||
|
|
4c6a5d4aa1 | ||
|
|
191779ce80 |
+5
-6
@@ -209,14 +209,13 @@ WARP.md
|
|||||||
**/tmpclaude*
|
**/tmpclaude*
|
||||||
|
|
||||||
# Azurite storage emulator files
|
# Azurite storage emulator files
|
||||||
*/__azurite_db_blob__.json*
|
*/__azurite_db_blob__.json
|
||||||
*/__azurite_db_blob_extent__.json*
|
*/__azurite_db_blob_extent__.json
|
||||||
*/__azurite_db_queue__.json*
|
*/__azurite_db_queue__.json
|
||||||
*/__azurite_db_queue_extent__.json*
|
*/__azurite_db_queue_extent__.json
|
||||||
*/__azurite_db_table__.json*
|
*/__azurite_db_table__.json
|
||||||
*/__blobstorage__/
|
*/__blobstorage__/
|
||||||
*/__queuestorage__/
|
*/__queuestorage__/
|
||||||
*/AzuriteConfig
|
|
||||||
|
|
||||||
# Azure Functions local settings
|
# Azure Functions local settings
|
||||||
local.settings.json
|
local.settings.json
|
||||||
|
|||||||
@@ -112,14 +112,14 @@
|
|||||||
<PackageVersion Include="Microsoft.Bot.ObjectModel.PowerFx" Version="1.2025.1106.1" />
|
<PackageVersion Include="Microsoft.Bot.ObjectModel.PowerFx" Version="1.2025.1106.1" />
|
||||||
<PackageVersion Include="Microsoft.PowerFx.Interpreter" Version="1.5.0-build.20251008-1002" />
|
<PackageVersion Include="Microsoft.PowerFx.Interpreter" Version="1.5.0-build.20251008-1002" />
|
||||||
<!-- Durable Task -->
|
<!-- Durable Task -->
|
||||||
<PackageVersion Include="Microsoft.DurableTask.Client" Version="1.18.0" />
|
<PackageVersion Include="Microsoft.DurableTask.Client" Version="1.19.1" />
|
||||||
<PackageVersion Include="Microsoft.DurableTask.Client.AzureManaged" Version="1.18.0" />
|
<PackageVersion Include="Microsoft.DurableTask.Client.AzureManaged" Version="1.19.0" />
|
||||||
<PackageVersion Include="Microsoft.DurableTask.Worker" Version="1.18.0" />
|
<PackageVersion Include="Microsoft.DurableTask.Worker" Version="1.19.0" />
|
||||||
<PackageVersion Include="Microsoft.DurableTask.Worker.AzureManaged" Version="1.18.0" />
|
<PackageVersion Include="Microsoft.DurableTask.Worker.AzureManaged" Version="1.19.0" />
|
||||||
<!-- Azure Functions -->
|
<!-- Azure Functions -->
|
||||||
<PackageVersion Include="Microsoft.Azure.Functions.Worker" Version="2.50.0" />
|
<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.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.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" Version="3.3.0" />
|
||||||
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" Version="2.1.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/06_LongRunningTools/06_LongRunningTools.csproj" />
|
||||||
<Project Path="samples/AzureFunctions/07_AgentAsMcpTool/07_AgentAsMcpTool.csproj" />
|
<Project Path="samples/AzureFunctions/07_AgentAsMcpTool/07_AgentAsMcpTool.csproj" />
|
||||||
<Project Path="samples/AzureFunctions/08_ReliableStreaming/08_ReliableStreaming.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>
|
||||||
<Folder Name="/Samples/DurableAgents/">
|
<Folder Name="/Samples/DurableAgents/">
|
||||||
<File Path="samples/DurableAgents/ConsoleApps/README.md" />
|
<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/05_AgentOrchestration_HITL/05_AgentOrchestration_HITL.csproj" />
|
||||||
<Project Path="samples/DurableAgents/ConsoleApps/06_LongRunningTools/06_LongRunningTools.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/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>
|
||||||
<Folder Name="/Samples/GettingStarted/">
|
<Folder Name="/Samples/GettingStarted/">
|
||||||
<File Path="samples/GettingStarted/README.md" />
|
<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.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.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.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.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" />
|
<Project Path="src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/Tests/" />
|
<Folder Name="/Tests/" />
|
||||||
|
|||||||
@@ -3,10 +3,14 @@
|
|||||||
<packageSources>
|
<packageSources>
|
||||||
<clear />
|
<clear />
|
||||||
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
|
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
|
||||||
|
<add key="LocalNugetSource" value="C:\LocalNugetSource" />
|
||||||
</packageSources>
|
</packageSources>
|
||||||
<packageSourceMapping>
|
<packageSourceMapping>
|
||||||
<packageSource key="nuget.org">
|
<packageSource key="nuget.org">
|
||||||
<package pattern="*" />
|
<package pattern="*" />
|
||||||
</packageSource>
|
</packageSource>
|
||||||
|
<packageSource key="LocalNugetSource">
|
||||||
|
<package pattern="*" />
|
||||||
|
</packageSource>
|
||||||
</packageSourceMapping>
|
</packageSourceMapping>
|
||||||
</configuration>
|
</configuration>
|
||||||
@@ -8,3 +8,6 @@ dotnet_diagnostic.DURABLE0003.severity = none
|
|||||||
dotnet_diagnostic.DURABLE0004.severity = none
|
dotnet_diagnostic.DURABLE0004.severity = none
|
||||||
dotnet_diagnostic.DURABLE0005.severity = none
|
dotnet_diagnostic.DURABLE0005.severity = none
|
||||||
dotnet_diagnostic.DURABLE0006.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>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<!-- The Functions build tools don't like namespaces that start with a number -->
|
<!-- The Functions build tools don't like namespaces that start with a number -->
|
||||||
<AssemblyName>SingleAgent</AssemblyName>
|
<AssemblyName>Workflow</AssemblyName>
|
||||||
<RootNamespace>SingleAgent</RootNamespace>
|
<RootNamespace>Workflow</RootNamespace>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -5,4 +5,4 @@
|
|||||||
POST {{authority}}/api/agents/Joker/run
|
POST {{authority}}/api/agents/Joker/run
|
||||||
Content-Type: text/plain
|
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.
|
- **[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.
|
- **[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.
|
- **[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
|
## 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)
|
private AIAgent GetAgent(AgentSessionId sessionId)
|
||||||
{
|
{
|
||||||
IReadOnlyDictionary<string, Func<IServiceProvider, AIAgent>> agents =
|
IReadOnlyDictionary<string, Func<IServiceProvider, AIAgent>> agents = this._options.GetAgentFactories();
|
||||||
this._services.GetRequiredService<IReadOnlyDictionary<string, Func<IServiceProvider, AIAgent>>>();
|
|
||||||
if (!agents.TryGetValue(sessionId.Name, out Func<IServiceProvider, AIAgent>? agentFactory))
|
if (!agents.TryGetValue(sessionId.Name, out Func<IServiceProvider, AIAgent>? agentFactory))
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException($"Agent '{sessionId.Name}' not found");
|
throw new InvalidOperationException($"Agent '{sessionId.Name}' not found");
|
||||||
|
|||||||
@@ -10,8 +10,12 @@ public sealed class DurableAgentsOptions
|
|||||||
// Agent names are case-insensitive
|
// Agent names are case-insensitive
|
||||||
private readonly Dictionary<string, Func<IServiceProvider, AIAgent>> _agentFactories = new(StringComparer.OrdinalIgnoreCase);
|
private readonly Dictionary<string, Func<IServiceProvider, AIAgent>> _agentFactories = new(StringComparer.OrdinalIgnoreCase);
|
||||||
private readonly Dictionary<string, TimeSpan?> _agentTimeToLive = 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.
|
/// Thrown when <paramref name="agent.Name"/> is null or whitespace or when an agent with the same name has already been registered.
|
||||||
/// </exception>
|
/// </exception>
|
||||||
public DurableAgentsOptions AddAIAgent(AIAgent agent, TimeSpan? timeToLive = null)
|
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);
|
ArgumentNullException.ThrowIfNull(agent);
|
||||||
|
|
||||||
@@ -120,6 +140,11 @@ public sealed class DurableAgentsOptions
|
|||||||
this._agentTimeToLive[agent.Name] = timeToLive;
|
this._agentTimeToLive[agent.Name] = timeToLive;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (workflowOnly)
|
||||||
|
{
|
||||||
|
this._workflowOnlyAgents.Add(agent.Name);
|
||||||
|
}
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,4 +166,24 @@ public sealed class DurableAgentsOptions
|
|||||||
{
|
{
|
||||||
return this._agentTimeToLive.TryGetValue(agentName, out TimeSpan? ttl) ? ttl : this.DefaultTimeToLive;
|
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(
|
public static partial void LogTTLExpirationTimeCleared(
|
||||||
this ILogger logger,
|
this ILogger logger,
|
||||||
AgentSessionId sessionId);
|
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>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.DurableTask.Client" />
|
<PackageReference Include="Microsoft.DurableTask.Client" />
|
||||||
<PackageReference Include="Microsoft.DurableTask.Worker" />
|
<PackageReference Include="Microsoft.DurableTask.Worker" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
|
||||||
<ProjectReference Include="..\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
<ProjectReference Include="..\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||||
</ItemGroup>
|
</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.Client;
|
||||||
using Microsoft.DurableTask.Worker;
|
using Microsoft.DurableTask.Worker;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||||
|
|
||||||
namespace Microsoft.Agents.AI.DurableTask;
|
namespace Microsoft.Agents.AI.DurableTask;
|
||||||
|
|
||||||
@@ -80,23 +81,45 @@ public static class ServiceCollectionExtensions
|
|||||||
DurableAgentsOptions options = new();
|
DurableAgentsOptions options = new();
|
||||||
configure(options);
|
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.
|
// Check if we already have DurableAgentsOptions registered and merge with it
|
||||||
services.AddSingleton(agents);
|
ServiceDescriptor? existingOptionsDescriptor = services.FirstOrDefault(
|
||||||
|
d => d.ServiceType == typeof(DurableAgentsOptions));
|
||||||
|
|
||||||
// Register the options so AgentEntity can access TTL configuration
|
if (existingOptionsDescriptor?.ImplementationInstance is DurableAgentsOptions existingOptions)
|
||||||
services.AddSingleton(options);
|
{
|
||||||
|
// 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.
|
// 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));
|
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,
|
// 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.
|
// which is not the default behavior for the Durable Task SDK.
|
||||||
services.AddSingleton<DataConverter, DefaultDataConverter>();
|
services.TryAddSingleton<DataConverter, DefaultDataConverter>();
|
||||||
|
|
||||||
return options;
|
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>() ??
|
IFunctionInputBindingFeature? functionInputBindingFeature = context.Features.Get<IFunctionInputBindingFeature>() ??
|
||||||
throw new InvalidOperationException("Function input binding feature is not available on the current context.");
|
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);
|
FunctionInputBindingResult? inputBindingResults = await functionInputBindingFeature.BindFunctionInputAsync(context);
|
||||||
if (inputBindingResults is not { Values: { } values })
|
if (inputBindingResults is not { Values: { } values })
|
||||||
{
|
{
|
||||||
@@ -102,6 +140,32 @@ internal sealed class BuiltInFunctionExecutor : IFunctionExecutor
|
|||||||
return;
|
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}.");
|
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 RunAgentHttpFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RunAgentHttpAsync)}";
|
||||||
internal static readonly string RunAgentEntityFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(InvokeAgentAsync)}";
|
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 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
|
// Exposed as an entity trigger via AgentFunctionsProvider
|
||||||
public static Task<string> InvokeAgentAsync(
|
public static Task<string> InvokeAgentAsync(
|
||||||
@@ -43,6 +66,24 @@ internal static class BuiltInFunctions
|
|||||||
return GrpcEntityRunner.LoadAndRunAsync(encodedEntityRequest, entity, combinedServiceProvider);
|
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(
|
public static async Task<HttpResponseData> RunAgentHttpAsync(
|
||||||
[HttpTrigger] HttpRequestData req,
|
[HttpTrigger] HttpRequestData req,
|
||||||
[DurableClient] DurableTaskClient client,
|
[DurableClient] DurableTaskClient client,
|
||||||
@@ -178,6 +219,39 @@ internal static class BuiltInFunctions
|
|||||||
return agentResponse.Text;
|
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>
|
/// <summary>
|
||||||
/// Creates an error response with the specified status code and error message.
|
/// Creates an error response with the specified status code and error message.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user