Compare commits

...
23 Commits
Author SHA1 Message Date
Shyju Krishnankutty c40e5a9020 Fid http URL for test 2026-01-23 07:05:02 -08:00
Shyju Krishnankutty 12256b59aa Working demos 2026-01-22 22:09:33 -08:00
Shyju Krishnankutty 04fdf25019 Conditional edge routing sample. 2026-01-22 21:27:53 -08:00
Shyju Krishnankutty 29fb22f7d8 Minor cleanup 2026-01-22 17:11:30 -08:00
Shyju Krishnankutty 38fc0b8e08 Remove unused files. 2026-01-22 09:16:11 -08:00
Shyju Krishnankutty 34114f8d7b Cleanup 2026-01-21 20:19:23 -08:00
Shyju Krishnankutty 38c8f3ec18 minor cleanup. 2026-01-21 17:55:21 -08:00
Shyju Krishnankutty 6a49da6f1c Minor cleanup. MCP tool works. 2026-01-21 17:39:53 -08:00
Shyju Krishnankutty d3bfbcbf52 WIP-Run workflow as mcp tool. 2026-01-21 15:28:24 -08:00
Shyju Krishnankutty 588e0bc0b2 WIP 2026-01-21 10:49:10 -08:00
Shyju Krishnankutty 00650f2525 Minor cleanups 2026-01-20 20:20:15 -08:00
Shyju Krishnankutty 530f8b389a Move relevant stuff to DurableTask project from Hosting.AzureFunctions 2026-01-20 16:22:19 -08:00
Shyju Krishnankutty 8c3182e4e8 Shared state support using durable entity. 2026-01-20 08:56:12 -08:00
Shyju Krishnankutty 5ddb4cd546 Register one orchestration per workflow. 2026-01-15 16:26:20 -08:00
Shyju Krishnankutty 50662c3415 Minor cleanup/reorg 2026-01-15 15:15:08 -08:00
Shyju Krishnankutty 6fbb4dcb87 Minor cleanups 2026-01-15 11:43:28 -08:00
Shyju Krishnankutty ff230c86ce cleanup 2026-01-15 11:10:46 -08:00
Shyju Krishnankutty 6e029eb039 WIP 2026-01-15 07:49:07 -08:00
Shyju Krishnankutty defb533b95 working e2e orchestration completion. 2026-01-14 16:18:21 -08:00
Shyju Krishnankutty 7f22a87a24 WIP. runs all executors/agents in workflow sequantially. 2026-01-14 12:34:10 -08:00
Shyju Krishnankutty 4340f37e97 wip 2026-01-13 19:38:26 -08:00
Shyju Krishnankutty aea354b09c WIP 2026-01-12 17:27:31 -08:00
Shyju Krishnankutty da6b2534c2 WIP 2026-01-12 09:06:27 -08:00
62 changed files with 3766 additions and 56 deletions
+5 -5
View File
@@ -112,14 +112,14 @@
<PackageVersion Include="Microsoft.Bot.ObjectModel.PowerFx" Version="1.2025.1106.1" />
<PackageVersion Include="Microsoft.PowerFx.Interpreter" Version="1.5.0-build.20251008-1002" />
<!-- Durable Task -->
<PackageVersion Include="Microsoft.DurableTask.Client" Version="1.18.0" />
<PackageVersion Include="Microsoft.DurableTask.Client.AzureManaged" Version="1.18.0" />
<PackageVersion Include="Microsoft.DurableTask.Worker" Version="1.18.0" />
<PackageVersion Include="Microsoft.DurableTask.Worker.AzureManaged" Version="1.18.0" />
<PackageVersion Include="Microsoft.DurableTask.Client" Version="1.19.1" />
<PackageVersion Include="Microsoft.DurableTask.Client.AzureManaged" Version="1.19.0" />
<PackageVersion Include="Microsoft.DurableTask.Worker" Version="1.19.0" />
<PackageVersion Include="Microsoft.DurableTask.Worker.AzureManaged" Version="1.19.0" />
<!-- Azure Functions -->
<PackageVersion Include="Microsoft.Azure.Functions.Worker" Version="2.50.0" />
<PackageVersion Include="Microsoft.Azure.Functions.Worker.ApplicationInsights" Version="2.50.0" />
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask" Version="1.11.0" />
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask" Version="1.13.1" />
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask.AzureManaged" Version="1.0.1" />
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.Http" Version="3.3.0" />
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" Version="2.1.0" />
+7
View File
@@ -34,6 +34,13 @@
<Project Path="samples/AzureFunctions/06_LongRunningTools/06_LongRunningTools.csproj" />
<Project Path="samples/AzureFunctions/07_AgentAsMcpTool/07_AgentAsMcpTool.csproj" />
<Project Path="samples/AzureFunctions/08_ReliableStreaming/08_ReliableStreaming.csproj" />
<Project Path="samples/AzureFunctions/09_Workflow/09_Workflow.csproj" />
<Project Path="samples/AzureFunctions/10_WorkflowConcurrent/10_WorkflowConcurrent.csproj" />
<Project Path="samples/AzureFunctions/11_WorkflowSharedState/11_WorkflowSharedState.csproj" />
<Project Path="samples/AzureFunctions/12_ConditionalEdges/12_ConditionalEdges.csproj" />
</Folder>
<Folder Name="/Samples/DurableWorkflows/">
<Project Path="samples/DurableWorkflows/01_ExecutorsAndEdges/01_ExecutorsAndEdges.csproj" />
</Folder>
<Folder Name="/Samples/GettingStarted/">
<File Path="samples/GettingStarted/README.md" />
+4
View File
@@ -3,10 +3,14 @@
<packageSources>
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
<add key="LocalNugetSource" value="C:\LocalNugetSource" />
</packageSources>
<packageSourceMapping>
<packageSource key="nuget.org">
<package pattern="*" />
</packageSource>
<packageSource key="LocalNugetSource">
<package pattern="*" />
</packageSource>
</packageSourceMapping>
</configuration>
@@ -8,3 +8,6 @@ dotnet_diagnostic.DURABLE0003.severity = none
dotnet_diagnostic.DURABLE0004.severity = none
dotnet_diagnostic.DURABLE0005.severity = none
dotnet_diagnostic.DURABLE0006.severity = none
# CA1812: Internal classes are instantiated via dependency injection or reflection in samples
dotnet_diagnostic.CA1812.severity = none
@@ -6,8 +6,8 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<!-- The Functions build tools don't like namespaces that start with a number -->
<AssemblyName>SingleAgent</AssemblyName>
<RootNamespace>SingleAgent</RootNamespace>
<AssemblyName>Workflow</AssemblyName>
<RootNamespace>Workflow</RootNamespace>
</PropertyGroup>
<ItemGroup>
@@ -5,4 +5,4 @@
POST {{authority}}/api/agents/Joker/run
Content-Type: text/plain
Tell me a joke about a pirate.
Hello world
@@ -0,0 +1,44 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
<AzureFunctionsVersion>v4</AzureFunctionsVersion>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<!-- The Functions build tools don't like namespaces that start with a number -->
<AssemblyName>SingleAgent</AssemblyName>
<RootNamespace>SingleAgent</RootNamespace>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<!-- Azure Functions packages -->
<ItemGroup>
<PackageReference Include="Microsoft.Azure.Functions.Worker" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask.AzureManaged" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
</ItemGroup>
<!-- Local projects that should be switched to package references when using the sample outside of this MAF repo -->
<!--
<ItemGroup>
<PackageReference Include="Microsoft.Agents.AI.Hosting.AzureFunctions" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" />
</ItemGroup>
-->
<ItemGroup>
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Hosting.AzureFunctions\Microsoft.Agents.AI.Hosting.AzureFunctions.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,67 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows;
namespace SingleAgent;
/// <summary>
/// Parses an Order ID from a string input and returns an Order object populated.
/// </summary>
internal sealed class OrderLookup() : Executor<string, Order>("OrderLookup")
{
public override async ValueTask<Order> HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
// Populate Order information from OrderId.
return new Order(message, 100.0m);
}
}
/// <summary>
/// Enriches an Order object with additional information.
/// </summary>
internal sealed class OrderEnrich() : Executor<Order, Order>("EnrichOrder")
{
public override async ValueTask<Order> HandleAsync(Order message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
if (message.Customer is null)
{
// populate customer information for the order from database.
message.Customer = new Customer(1, "Jerry");
}
return message;
}
}
internal sealed class PaymentProcessor() : Executor<Order, Order>("ProcessPayment")
{
public override async ValueTask<Order> HandleAsync(Order message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
message.PaymentReferenceNumber = Guid.NewGuid().ToString()[^4..];
return message;
}
}
internal sealed class OrderCancel() : Executor<Order, string>("OrderCancel")
{
public override async ValueTask<string> HandleAsync(Order message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
return $"Order {message.Id} cancelled at {DateTime.UtcNow:g} UTC.";
}
}
internal sealed class Order
{
public Order(string id, decimal amount)
{
this.Id = id;
this.Amount = amount;
}
public string Id { get; }
public decimal Amount { get; }
public Customer? Customer { get; set; }
public string? PaymentReferenceNumber { get; set; }
}
public sealed record Customer(int Id, string Name);
@@ -0,0 +1,48 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Hosting.AzureFunctions;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Azure.Functions.Worker.Builder;
using Microsoft.Extensions.Hosting;
using SingleAgent;
Func<string, string> orderParserFunc = input =>
{
// We accept both short ordereId(Ex:12345) and long order reference number(MSFT12345)
// OrderId is the last 5 digigs of order reference number.
const int OrderIdPartLength = 5;
if (input.Length > OrderIdPartLength)
{
return input[^OrderIdPartLength..];
}
return input;
};
var orderParserExecutor = orderParserFunc.BindAsExecutor("ParseOrderId");
OrderLookup orderLookupExecutor = new();
OrderEnrich orderEnricherExeecutor = new();
PaymentProcessor paymentProcessorExecutor = new();
Workflow fulfillOrder = new WorkflowBuilder(orderParserExecutor)
.WithName("FulfillOrder")
.WithDescription("Looks up an order by ID and run payment processing")
.AddEdge(orderParserExecutor, orderLookupExecutor)
.AddEdge(orderLookupExecutor, orderEnricherExeecutor)
.AddEdge(orderEnricherExeecutor, paymentProcessorExecutor)
.Build();
//OrderCancel orderArchiverExecutor = new();
//Workflow cancelOrder = new WorkflowBuilder(orderParserExecutor)
// .WithName("CancelOrder")
// .WithDescription("Cancel an order")
// .AddEdge(orderParserExecutor, orderLookupExecutor)
// .AddEdge(orderLookupExecutor, orderArchiverExecutor)
// .Build();
var host = FunctionsApplication.CreateBuilder(args)
.ConfigureFunctionsWebApplication()
.ConfigureDurableOptions(options => options.Workflows.AddWorkflow(fulfillOrder))
.Build();
host.Run();
@@ -0,0 +1,89 @@
# Single Agent Sample
This sample demonstrates how to use the Durable Agent Framework (DAFx) to create a simple Azure Functions app that hosts a single AI agent and provides direct HTTP API access for interactive conversations.
## Key Concepts Demonstrated
- Using the Microsoft Agent Framework to define a simple AI agent with a name and instructions.
- Registering agents with the Function app and running them using HTTP.
- Conversation management (via session IDs) for isolated interactions.
## Environment Setup
See the [README.md](../README.md) file in the parent directory for more information on how to configure the environment, including how to install and run common sample dependencies.
## Running the Sample
With the environment setup and function app running, you can test the sample by sending an HTTP request to the agent endpoint.
You can use the `demo.http` file to send a message to the agent, or a command line tool like `curl` as shown below:
Bash (Linux/macOS/WSL):
```bash
curl -X POST http://localhost:7071/api/agents/Joker/run \
-H "Content-Type: text/plain" \
-d "Tell me a joke about a pirate."
```
PowerShell:
```powershell
Invoke-RestMethod -Method Post `
-Uri http://localhost:7071/api/agents/Joker/run `
-ContentType text/plain `
-Body "Tell me a joke about a pirate."
```
You can also send JSON requests:
```bash
curl -X POST http://localhost:7071/api/agents/Joker/run \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{"message": "Tell me a joke about a pirate."}'
```
To continue a conversation, include the `thread_id` in the query string or JSON body:
```bash
curl -X POST "http://localhost:7071/api/agents/Joker/run?thread_id=your-thread-id" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{"message": "Tell me another one."}'
```
The response from the agent will be displayed in the terminal where you ran `func start`. The expected `text/plain` output will look something like:
```text
Why don't pirates ever learn the alphabet? Because they always get stuck at "C"!
```
The expected `application/json` output will look something like:
```json
{
"status": 200,
"thread_id": "ee6e47a0-f24b-40b1-ade8-16fcebb9eb40",
"response": {
"Messages": [
{
"AuthorName": "Joker",
"CreatedAt": "2025-11-11T12:00:00.0000000Z",
"Role": "assistant",
"Contents": [
{
"Type": "text",
"Text": "Why don't pirates ever learn the alphabet? Because they always get stuck at 'C'!"
}
]
}
],
"Usage": {
"InputTokenCount": 78,
"OutputTokenCount": 36,
"TotalTokenCount": 114
}
}
}
```
@@ -0,0 +1,14 @@
# Default endpoint address for local testing
@authority=http://localhost:7071
### Look up a long order reference id
POST {{authority}}/api/workflows/FulfillOrder/run
Content-Type: text/plain
QWERTY80853
### Look up a short order id
POST {{authority}}/api/workflows/CancelOrder/run
Content-Type: text/plain
12345
@@ -0,0 +1,20 @@
{
"version": "2.0",
"logging": {
"logLevel": {
"Microsoft.Agents.AI.DurableTask": "Information",
"Microsoft.Agents.AI.Hosting.AzureFunctions": "Information",
"DurableTask": "Information",
"Microsoft.DurableTask": "Information"
}
},
"extensions": {
"durableTask": {
"hubName": "default",
"storageProvider": {
"type": "AzureManaged",
"connectionStringName": "DURABLE_TASK_SCHEDULER_CONNECTION_STRING"
}
}
}
}
@@ -0,0 +1,48 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
<AzureFunctionsVersion>v4</AzureFunctionsVersion>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<!-- The Functions build tools don't like namespaces that start with a number -->
<AssemblyName>SingleAgent</AssemblyName>
<RootNamespace>SingleAgent</RootNamespace>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<None Include="local.settings.json" />
</ItemGroup>
<!-- Azure Functions packages -->
<ItemGroup>
<PackageReference Include="Microsoft.Azure.Functions.Worker" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask.AzureManaged" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
</ItemGroup>
<!-- Local projects that should be switched to package references when using the sample outside of this MAF repo -->
<!--
<ItemGroup>
<PackageReference Include="Microsoft.Agents.AI.Hosting.AzureFunctions" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" />
</ItemGroup>
-->
<ItemGroup>
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Hosting.AzureFunctions\Microsoft.Agents.AI.Hosting.AzureFunctions.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,38 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows;
namespace SingleAgent;
internal sealed class ConcurrentStartExecutor() : Executor<string, string>("ConcurrentStartExecutor")
{
public override ValueTask<string> HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
// do some initial parsing and validation of the message.
// Return a polished version ith additional metadta.
if (!message.StartsWith("Query for the agent:", StringComparison.OrdinalIgnoreCase))
{
message = "Query for the agent: " + message;
}
return ValueTask.FromResult(message);
}
}
internal sealed class ResultAggregationExecutor() : Executor<string[], string>("ResultAggregationExecutor")
{
/// <summary>
/// Handles incoming messages from the agents and aggregates their responses.
/// </summary>
/// <param name="message">The messages from the parallel agents.</param>
/// <param name="context">Workflow context for accessing workflow services and adding events.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.
/// The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A task representing the asynchronous operation.</returns>
public override ValueTask<string> HandleAsync(string[] message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
// Aggregate all responses from parallel executors
string aggregatedResponse = string.Join("\n---\n", message);
return ValueTask.FromResult($"Aggregated {message.Length} responses:\n{aggregatedResponse}");
}
}
@@ -0,0 +1,48 @@
// Copyright (c) Microsoft. All rights reserved.
using Azure;
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Hosting.AzureFunctions;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Azure.Functions.Worker.Builder;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Hosting;
using OpenAI.Chat;
using SingleAgent;
// Get the Azure OpenAI endpoint and deployment name from environment variables.
string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT")
?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT")
?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT is not set.");
// Use Azure Key Credential if provided, otherwise use Azure CLI Credential.
string? azureOpenAiKey = System.Environment.GetEnvironmentVariable("AZURE_OPENAI_KEY");
AzureOpenAIClient client = !string.IsNullOrEmpty(azureOpenAiKey)
? new AzureOpenAIClient(new Uri(endpoint), new AzureKeyCredential(azureOpenAiKey))
: new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential());
AIAgent physicist = client.GetChatClient(deploymentName).CreateAIAgent("You are an expert in physics. You answer questions from a physics perspective.", "Physicist");
AIAgent chemist = client.GetChatClient(deploymentName).CreateAIAgent("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/AuditOrder/run
Content-Type: text/plain
B123
### Start second workflow
POST {{authority}}/api/workflows/AuditOrder/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,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()));
}
}
@@ -216,8 +216,7 @@ internal class AgentEntity(IServiceProvider services, CancellationToken cancella
private AIAgent GetAgent(AgentSessionId sessionId)
{
IReadOnlyDictionary<string, Func<IServiceProvider, AIAgent>> agents =
this._services.GetRequiredService<IReadOnlyDictionary<string, Func<IServiceProvider, AIAgent>>>();
IReadOnlyDictionary<string, Func<IServiceProvider, AIAgent>> agents = this._options.GetAgentFactories();
if (!agents.TryGetValue(sessionId.Name, out Func<IServiceProvider, AIAgent>? agentFactory))
{
throw new InvalidOperationException($"Agent '{sessionId.Name}' not found");
@@ -10,8 +10,12 @@ public sealed class DurableAgentsOptions
// Agent names are case-insensitive
private readonly Dictionary<string, Func<IServiceProvider, AIAgent>> _agentFactories = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, TimeSpan?> _agentTimeToLive = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _workflowOnlyAgents = new(StringComparer.OrdinalIgnoreCase);
internal DurableAgentsOptions()
/// <summary>
/// Initializes a new instance of the <see cref="DurableAgentsOptions"/> class.
/// </summary>
public DurableAgentsOptions()
{
}
@@ -101,6 +105,22 @@ public sealed class DurableAgentsOptions
/// Thrown when <paramref name="agent.Name"/> is null or whitespace or when an agent with the same name has already been registered.
/// </exception>
public DurableAgentsOptions AddAIAgent(AIAgent agent, TimeSpan? timeToLive = null)
{
return this.AddAIAgent(agent, workflowOnly: false, timeToLive);
}
/// <summary>
/// Adds an AI agent to the options with workflow-only configuration.
/// </summary>
/// <param name="agent">The agent to add.</param>
/// <param name="workflowOnly">If true, the agent is only accessible within workflows and won't have HTTP triggers.</param>
/// <param name="timeToLive">Optional time-to-live for this agent's entities. If not specified, uses <see cref="DefaultTimeToLive"/>.</param>
/// <returns>The options instance.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="agent"/> is null.</exception>
/// <exception cref="ArgumentException">
/// Thrown when <paramref name="agent.Name"/> is null or whitespace or when an agent with the same name has already been registered.
/// </exception>
public DurableAgentsOptions AddAIAgent(AIAgent agent, bool workflowOnly, TimeSpan? timeToLive = null)
{
ArgumentNullException.ThrowIfNull(agent);
@@ -120,6 +140,11 @@ public sealed class DurableAgentsOptions
this._agentTimeToLive[agent.Name] = timeToLive;
}
if (workflowOnly)
{
this._workflowOnlyAgents.Add(agent.Name);
}
return this;
}
@@ -141,4 +166,24 @@ public sealed class DurableAgentsOptions
{
return this._agentTimeToLive.TryGetValue(agentName, out TimeSpan? ttl) ? ttl : this.DefaultTimeToLive;
}
/// <summary>
/// Determines whether an agent is configured as workflow-only (no HTTP triggers).
/// </summary>
/// <param name="agentName">The name of the agent.</param>
/// <returns><see langword="true"/> if the agent is workflow-only; otherwise, <see langword="false"/>.</returns>
internal bool IsWorkflowOnly(string agentName)
{
return this._workflowOnlyAgents.Contains(agentName);
}
/// <summary>
/// Determines whether an agent with the specified name is already registered.
/// </summary>
/// <param name="agentName">The name of the agent.</param>
/// <returns><see langword="true"/> if an agent with the name is registered; otherwise, <see langword="false"/>.</returns>
internal bool ContainsAgent(string agentName)
{
return this._agentFactories.ContainsKey(agentName);
}
}
@@ -0,0 +1,269 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using Microsoft.Agents.AI.Workflows;
using Microsoft.DurableTask.Client;
using Microsoft.DurableTask.Client.Entities;
using Microsoft.DurableTask.Entities;
namespace Microsoft.Agents.AI.DurableTask;
/// <summary>
/// An implementation of <see cref="IWorkflowContext"/> for workflow executors running as durable activities.
/// Provides durable state management using Durable Entities. State is scoped to the orchestration instance
/// and shared between executors running on potentially different compute instances.
/// </summary>
/// <remarks>
/// State operations use GetEntityAsync for reads (fetches current entity state) and SignalEntityAsync
/// for writes. Since activities run sequentially in the orchestration and entity signals are processed
/// in order, state consistency is maintained across executors.
/// </remarks>
[RequiresUnreferencedCode("State serialization uses reflection-based JSON serialization.")]
[RequiresDynamicCode("State serialization uses reflection-based JSON serialization.")]
public sealed class DurableExecutorContext : IWorkflowContext
{
private readonly string _instanceId;
private readonly DurableTaskClient _client;
private readonly Dictionary<string, string?> _pendingUpdates = [];
private readonly HashSet<string> _clearedScopes = [];
/// <summary>
/// Initializes a new instance of the <see cref="DurableExecutorContext"/> class.
/// </summary>
/// <param name="instanceId">The orchestration instance ID used to scope the state entity.</param>
/// <param name="client">The durable task client for entity operations.</param>
public DurableExecutorContext(string instanceId, DurableTaskClient client)
{
this._instanceId = instanceId;
this._client = client;
}
/// <inheritdoc/>
public ValueTask AddEventAsync(WorkflowEvent workflowEvent, CancellationToken cancellationToken = default)
{
// In activity context, events are not propagated to the workflow
return default;
}
/// <inheritdoc/>
public ValueTask SendMessageAsync(object message, string? targetId = null, CancellationToken cancellationToken = default)
{
// In activity context, messages cannot be routed to other executors
return default;
}
/// <inheritdoc/>
public ValueTask YieldOutputAsync(object output, CancellationToken cancellationToken = default)
{
// In activity context, outputs are not yielded to the workflow
return default;
}
/// <inheritdoc/>
public ValueTask RequestHaltAsync()
{
// Halt requests are not supported in activity context
return default;
}
/// <inheritdoc/>
public async ValueTask<T?> ReadStateAsync<T>(string key, string? scopeName = null, CancellationToken cancellationToken = default)
{
string scopeKey = GetScopeKey(scopeName, key);
// 1. Check pending updates first (read-your-writes within this activity)
if (this._pendingUpdates.TryGetValue(scopeKey, out string? pendingValue))
{
return pendingValue is null ? default : JsonSerializer.Deserialize<T>(pendingValue);
}
// 2. Check if the scope was cleared in this activity
string normalizedScope = scopeName ?? "__default__";
if (this._clearedScopes.Contains(normalizedScope))
{
return default;
}
// 3. Read from the durable entity
EntityInstanceId entityId = this.GetStateEntityId();
EntityMetadata? metadata = await this._client.Entities
.GetEntityAsync(entityId, includeState: true, cancellation: cancellationToken)
.ConfigureAwait(false);
if (metadata?.IncludesState != true)
{
return default;
}
WorkflowStateData? stateData = metadata.State.ReadAs<WorkflowStateData>();
if (stateData?.Values is null)
{
return default;
}
if (stateData.Values.TryGetValue(scopeKey, out string? serializedValue) && serializedValue is not null)
{
return JsonSerializer.Deserialize<T>(serializedValue);
}
return default;
}
/// <inheritdoc/>
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 and write to entity
T initialValue = initialStateFactory();
await this.QueueStateUpdateAsync(key, initialValue, scopeName, cancellationToken).ConfigureAwait(false);
return initialValue;
}
/// <inheritdoc/>
public async ValueTask<HashSet<string>> ReadStateKeysAsync(string? scopeName = null, CancellationToken cancellationToken = default)
{
string normalizedScope = scopeName ?? "__default__";
string scopePrefix = GetScopePrefix(scopeName);
HashSet<string> keys = [];
// If scope was cleared, only return keys from pending updates
if (this._clearedScopes.Contains(normalizedScope))
{
return this.GetPendingKeysForScope(scopeName);
}
// Read keys from the durable entity
EntityInstanceId entityId = this.GetStateEntityId();
EntityMetadata? metadata = await this._client.Entities
.GetEntityAsync(entityId, includeState: true, cancellation: cancellationToken)
.ConfigureAwait(false);
if (metadata?.IncludesState == true)
{
WorkflowStateData? stateData = metadata.State.ReadAs<WorkflowStateData>();
if (stateData?.Values is not null)
{
foreach (string scopeKey in stateData.Values.Keys)
{
if (scopeKey.StartsWith(scopePrefix, StringComparison.Ordinal))
{
string foundKey = scopeKey[scopePrefix.Length..];
keys.Add(foundKey);
}
}
}
}
// Merge with pending updates
foreach (KeyValuePair<string, string?> pending in this._pendingUpdates)
{
if (pending.Key.StartsWith(scopePrefix, StringComparison.Ordinal))
{
string foundKey = pending.Key[scopePrefix.Length..];
if (pending.Value is not null)
{
keys.Add(foundKey);
}
else
{
keys.Remove(foundKey);
}
}
}
return keys;
}
/// <inheritdoc/>
public async ValueTask QueueStateUpdateAsync<T>(string key, T? value, string? scopeName = null, CancellationToken cancellationToken = default)
{
string scopeKey = GetScopeKey(scopeName, key);
string? serializedValue = value is null ? null : JsonSerializer.Serialize(value);
// Store locally for read-your-writes within this activity
this._pendingUpdates[scopeKey] = serializedValue;
// Write to the durable entity via signal
// Since activities run sequentially and signals are processed in order,
// the next activity will see this update when it reads from the entity
EntityInstanceId entityId = this.GetStateEntityId();
WorkflowStateWriteRequest request = new() { Key = key, ScopeName = scopeName, Value = serializedValue };
await this._client.Entities
.SignalEntityAsync(entityId, nameof(WorkflowSharedStateEntity.WriteState), request, cancellation: cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc/>
public async ValueTask QueueClearScopeAsync(string? scopeName = null, CancellationToken cancellationToken = default)
{
string normalizedScope = scopeName ?? "__default__";
this._clearedScopes.Add(normalizedScope);
// Remove pending updates in this scope
string scopePrefix = GetScopePrefix(scopeName);
List<string> keysToRemove = this._pendingUpdates.Keys
.Where(k => k.StartsWith(scopePrefix, StringComparison.Ordinal))
.ToList();
foreach (string key in keysToRemove)
{
this._pendingUpdates.Remove(key);
}
// Clear in the durable entity via signal
EntityInstanceId entityId = this.GetStateEntityId();
await this._client.Entities
.SignalEntityAsync(entityId, nameof(WorkflowSharedStateEntity.ClearScope), scopeName, cancellation: cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc/>
public IReadOnlyDictionary<string, string>? TraceContext => null;
/// <inheritdoc/>
public bool ConcurrentRunsEnabled => false;
private EntityInstanceId GetStateEntityId()
{
// Entity is keyed by orchestration instance ID for isolation between runs
return new EntityInstanceId(WorkflowSharedStateEntity.EntityName, this._instanceId);
}
private HashSet<string> GetPendingKeysForScope(string? scopeName)
{
string scopePrefix = GetScopePrefix(scopeName);
HashSet<string> keys = [];
foreach (KeyValuePair<string, string?> pending in this._pendingUpdates)
{
if (pending.Key.StartsWith(scopePrefix, StringComparison.Ordinal) && pending.Value is not null)
{
string key = pending.Key[scopePrefix.Length..];
keys.Add(key);
}
}
return keys;
}
private static string GetScopeKey(string? scopeName, string key)
{
return $"{GetScopePrefix(scopeName)}{key}";
}
private static string GetScopePrefix(string? scopeName)
{
return scopeName is null ? "__default__:" : $"{scopeName}:";
}
}
@@ -0,0 +1,27 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.DurableTask;
/// <summary>
/// Provides configuration options for durable agents and workflows.
/// </summary>
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,100 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows;
using Microsoft.Agents.AI.Workflows.Checkpointing;
namespace Microsoft.Agents.AI.DurableTask;
/// <summary>
/// Provides configuration options for managing durable workflows within an application.
/// </summary>
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);
}
}
}
}
@@ -0,0 +1,429 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using Microsoft.Agents.AI.Workflows;
using Microsoft.DurableTask;
using Microsoft.DurableTask.Entities;
using Microsoft.Extensions.Logging;
namespace Microsoft.Agents.AI.DurableTask;
/// <summary>
/// Core workflow runner that executes workflow orchestrations using Durable Tasks.
/// This class contains the core workflow execution logic independent of the hosting environment.
/// </summary>
public class DurableWorkflowRunner
{
/// <summary>
/// Initializes a new instance of the <see cref="DurableWorkflowRunner"/> class.
/// </summary>
/// <param name="logger">The logger instance.</param>
/// <param name="durableOptions">The durable options containing workflow configurations.</param>
public DurableWorkflowRunner(ILogger<DurableWorkflowRunner> logger, DurableOptions durableOptions)
{
ArgumentNullException.ThrowIfNull(logger);
ArgumentNullException.ThrowIfNull(durableOptions);
this.Logger = logger;
this.Options = durableOptions.Workflows;
}
/// <summary>
/// Gets the workflow options.
/// </summary>
protected DurableWorkflowOptions Options { get; }
/// <summary>
/// Gets the logger instance.
/// </summary>
protected ILogger Logger { get; }
/// <summary>
/// Runs a workflow orchestration.
/// </summary>
/// <param name="context">The task orchestration context.</param>
/// <param name="input">The workflow run input containing workflow name and input.</param>
/// <param name="logger">The replay-safe logger for orchestration logging.</param>
/// <returns>The result of the workflow execution.</returns>
/// <exception cref="InvalidOperationException">Thrown when the specified workflow is not found.</exception>
public async Task<string> RunWorkflowOrchestrationAsync(
TaskOrchestrationContext context,
string input,
ILogger logger)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(input);
string orchestrationName = context.Name;
string workflowName = WorkflowNamingHelper.ToWorkflowName(orchestrationName);
if (!this.Options.Workflows.TryGetValue(workflowName, out Workflow? workflow))
{
throw new InvalidOperationException($"Workflow '{workflowName}' not found.");
}
logger.LogRunningWorkflow(workflow.Name);
string result = await this.ExecuteWorkflowLevelsAsync(context, workflow, input, logger).ConfigureAwait(true);
await CleanupWorkflowStateAsync(context).ConfigureAwait(true);
return result;
}
/// <summary>
/// Cleans up the workflow state entity by signaling it to delete itself.
/// </summary>
private static async Task CleanupWorkflowStateAsync(TaskOrchestrationContext context)
{
EntityInstanceId stateEntityId = new(WorkflowSharedStateEntity.EntityName, context.InstanceId);
// Call the entity's Delete method to clean up state
// Using CallEntityAsync ensures the deletion completes before the orchestration finishes
await context.Entities.CallEntityAsync(stateEntityId, nameof(WorkflowSharedStateEntity.Delete)).ConfigureAwait(true);
}
/// <summary>
/// Parses the executor name from an activity function name.
/// </summary>
/// <param name="activityFunctionName">The activity function name.</param>
/// <returns>The extracted executor name.</returns>
protected static string ParseExecutorName(string activityFunctionName)
{
if (!activityFunctionName.StartsWith(WorkflowNamingHelper.OrchestrationFunctionPrefix, StringComparison.Ordinal))
{
throw new InvalidOperationException(
$"Activity function name '{activityFunctionName}' does not start with '{WorkflowNamingHelper.OrchestrationFunctionPrefix}' prefix.");
}
string executorName = activityFunctionName[WorkflowNamingHelper.OrchestrationFunctionPrefix.Length..];
if (string.IsNullOrEmpty(executorName))
{
throw new InvalidOperationException(
$"Activity function name '{activityFunctionName}' is not in the expected format '{WorkflowNamingHelper.OrchestrationFunctionPrefix}{{executorName}}'.");
}
return executorName;
}
/// <summary>
/// Serializes a list of strings to JSON.
/// </summary>
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing known types.")]
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing known types.")]
protected static string SerializeToJson(List<string> values)
{
return JsonSerializer.Serialize(values);
}
/// <summary>
/// Serializes a result object to JSON or string.
/// </summary>
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing workflow types registered at startup.")]
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing workflow types registered at startup.")]
protected static string SerializeResult(object? result)
{
if (result is null)
{
return string.Empty;
}
if (result is string str)
{
return str;
}
Type resultType = result.GetType();
if (resultType.IsPrimitive || resultType == typeof(decimal))
{
return result.ToString() ?? string.Empty;
}
return JsonSerializer.Serialize(result, resultType);
}
/// <summary>
/// Deserializes input from JSON to the target type.
/// </summary>
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow types registered at startup.")]
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow types registered at startup.")]
protected static object DeserializeInput(string input, Type targetType)
{
if (targetType == typeof(string))
{
return input;
}
string json = input;
if (input.StartsWith('"') && input.EndsWith('"'))
{
try
{
string? innerJson = JsonSerializer.Deserialize<string>(input);
if (innerJson is not null)
{
json = innerJson;
}
}
catch (JsonException)
{
// Not double-serialized, use original
}
}
return JsonSerializer.Deserialize(json, targetType)
?? throw new InvalidOperationException($"Failed to deserialize input to type '{targetType.Name}'.");
}
private async Task<string> ExecuteWorkflowLevelsAsync(
TaskOrchestrationContext context,
Workflow workflow,
string initialInput,
ILogger logger)
{
WorkflowExecutionPlan plan = WorkflowHelper.GetExecutionPlan(workflow);
Dictionary<string, string> results = [];
foreach (WorkflowExecutionLevel level in plan.Levels)
{
// Filter executors based on edge conditions from their predecessors
List<WorkflowExecutorInfo> eligibleExecutors = GetEligibleExecutors(level.Executors, results, plan, logger);
if (eligibleExecutors.Count == 0)
{
// No eligible executors at this level, continue to next level
continue;
}
if (eligibleExecutors.Count == 1)
{
WorkflowExecutorInfo executorInfo = eligibleExecutors[0];
string input = GetExecutorInput(executorInfo.ExecutorId, initialInput, results, plan);
results[executorInfo.ExecutorId] = await this.ExecuteExecutorAsync(context, executorInfo, input, logger).ConfigureAwait(true);
}
else
{
List<Task<(string Id, string Result)>> tasks = [];
foreach (WorkflowExecutorInfo executorInfo in eligibleExecutors)
{
string input = GetExecutorInput(executorInfo.ExecutorId, initialInput, results, plan);
tasks.Add(this.ExecuteExecutorWithIdAsync(context, executorInfo, input, logger));
}
foreach ((string id, string result) in await Task.WhenAll(tasks).ConfigureAwait(true))
{
results[id] = result;
}
}
}
return GetFinalResult(plan, results);
}
/// <summary>
/// Filters executors based on their incoming edge conditions.
/// An executor is eligible if all its incoming edges have conditions that evaluate to true,
/// or if the edges have no conditions.
/// </summary>
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow types registered at startup.")]
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow types registered at startup.")]
private static List<WorkflowExecutorInfo> GetEligibleExecutors(
List<WorkflowExecutorInfo> executors,
Dictionary<string, string> results,
WorkflowExecutionPlan plan,
ILogger logger)
{
List<WorkflowExecutorInfo> eligible = [];
foreach (WorkflowExecutorInfo executorInfo in executors)
{
List<string> predecessors = plan.Predecessors[executorInfo.ExecutorId];
// Root executor (no predecessors) is always eligible
if (predecessors.Count == 0)
{
eligible.Add(executorInfo);
continue;
}
// Check if any predecessor's edge condition allows this executor to run
bool isEligible = false;
foreach (string predecessorId in predecessors)
{
// Get the condition for this edge (predecessor -> current executor)
if (!plan.EdgeConditions.TryGetValue((predecessorId, executorInfo.ExecutorId), out Func<object?, bool>? condition))
{
// No condition registered for this edge, assume it's eligible
isEligible = true;
break;
}
if (condition is null)
{
// Edge has no condition, always eligible
isEligible = true;
break;
}
// Evaluate the condition using the predecessor's result
if (results.TryGetValue(predecessorId, out string? predecessorResult))
{
try
{
// Get the predecessor's output type for proper deserialization
Type? predecessorOutputType = plan.ExecutorOutputTypes.GetValueOrDefault(predecessorId);
// Deserialize the predecessor result to the expected type for condition evaluation
object? resultObject = DeserializeForCondition(predecessorResult, predecessorOutputType);
if (condition(resultObject))
{
isEligible = true;
break;
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to evaluate condition for edge from '{PredecessorId}' to '{ExecutorId}'", predecessorId, executorInfo.ExecutorId);
}
}
}
if (isEligible)
{
eligible.Add(executorInfo);
}
else
{
logger.LogExecutorSkipped(executorInfo.ExecutorId);
}
}
return eligible;
}
/// <summary>
/// Deserializes a JSON string result into an object for condition evaluation.
/// </summary>
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow types registered at startup.")]
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow types registered at startup.")]
private static object? DeserializeForCondition(string json, Type? targetType)
{
if (string.IsNullOrEmpty(json))
{
return null;
}
try
{
if (targetType is null)
{
return JsonSerializer.Deserialize<object>(json);
}
return JsonSerializer.Deserialize(json, targetType);
}
catch (JsonException)
{
// If it's not valid JSON, return the string as-is
return json;
}
}
private async Task<(string Id, string Result)> ExecuteExecutorWithIdAsync(
TaskOrchestrationContext context,
WorkflowExecutorInfo executorInfo,
string input,
ILogger logger)
{
string result = await this.ExecuteExecutorAsync(context, executorInfo, input, logger).ConfigureAwait(true);
return (executorInfo.ExecutorId, result);
}
private async Task<string> ExecuteExecutorAsync(
TaskOrchestrationContext context,
WorkflowExecutorInfo executorInfo,
string input,
ILogger logger)
{
if (!executorInfo.IsAgenticExecutor)
{
string executorName = WorkflowNamingHelper.GetExecutorName(executorInfo.ExecutorId);
string triggerName = WorkflowNamingHelper.ToOrchestrationFunctionName(executorName);
return await context.CallActivityAsync<string>(triggerName, input).ConfigureAwait(true);
}
return await ExecuteAgentAsync(context, executorInfo, input, logger).ConfigureAwait(true);
}
private static async Task<string> ExecuteAgentAsync(
TaskOrchestrationContext context,
WorkflowExecutorInfo executorInfo,
string input,
ILogger logger)
{
string agentName = WorkflowNamingHelper.GetExecutorName(executorInfo.ExecutorId);
DurableAIAgent agent = context.GetAgent(agentName);
if (agent is null)
{
logger.LogWarning("Agent '{AgentName}' not found", agentName);
return $"Agent '{agentName}' not found";
}
AgentThread thread = agent.GetNewThread();
AgentRunResponse response = await agent.RunAsync(input, thread).ConfigureAwait(true);
return response.Text;
}
private static string GetExecutorInput(
string executorId,
string initialInput,
Dictionary<string, string> results,
WorkflowExecutionPlan plan)
{
List<string> predecessors = plan.Predecessors[executorId];
if (predecessors.Count == 0)
{
return initialInput;
}
if (predecessors.Count == 1)
{
return results.TryGetValue(predecessors[0], out string? result) ? result : initialInput;
}
List<string> aggregated = [];
foreach (string predecessorId in predecessors)
{
if (results.TryGetValue(predecessorId, out string? result))
{
aggregated.Add(result);
}
}
return SerializeToJson(aggregated);
}
private static string GetFinalResult(WorkflowExecutionPlan plan, Dictionary<string, string> results)
{
WorkflowExecutionLevel lastLevel = plan.Levels[^1];
if (lastLevel.Executors.Count == 1)
{
return results[lastLevel.Executors[0].ExecutorId];
}
List<string> finalResults = [];
foreach (WorkflowExecutorInfo executor in lastLevel.Executors)
{
if (results.TryGetValue(executor.ExecutorId, out string? result))
{
finalResults.Add(result);
}
}
return string.Join("\n---\n", finalResults);
}
}
@@ -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);
}
}
@@ -100,4 +100,40 @@ internal static partial class Logs
public static partial void LogTTLExpirationTimeCleared(
this ILogger logger,
AgentSessionId sessionId);
[LoggerMessage(
EventId = 12,
Level = LogLevel.Debug,
Message = "Attempting to run workflow: {WorkflowName}")]
public static partial void LogAttemptingToRunWorkflow(this ILogger logger, string workflowName);
[LoggerMessage(
EventId = 13,
Level = LogLevel.Debug,
Message = "Running workflow: {WorkflowName}")]
public static partial void LogRunningWorkflow(this ILogger logger, string? workflowName);
[LoggerMessage(
EventId = 14,
Level = LogLevel.Debug,
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.Debug,
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.Debug,
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);
}
@@ -21,9 +21,11 @@
<ItemGroup>
<PackageReference Include="Microsoft.DurableTask.Client" />
<PackageReference Include="Microsoft.DurableTask.Worker" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
<ProjectReference Include="..\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
</ItemGroup>
@@ -8,6 +8,7 @@ using Microsoft.DurableTask;
using Microsoft.DurableTask.Client;
using Microsoft.DurableTask.Worker;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace Microsoft.Agents.AI.DurableTask;
@@ -80,23 +81,45 @@ public static class ServiceCollectionExtensions
DurableAgentsOptions options = new();
configure(options);
IReadOnlyDictionary<string, Func<IServiceProvider, AIAgent>> agents = options.GetAgentFactories();
IReadOnlyDictionary<string, Func<IServiceProvider, AIAgent>> newAgents = options.GetAgentFactories();
// The agent dictionary contains the real agent factories, which is used by the agent entities.
services.AddSingleton(agents);
// Check if we already have DurableAgentsOptions registered and merge with it
ServiceDescriptor? existingOptionsDescriptor = services.FirstOrDefault(
d => d.ServiceType == typeof(DurableAgentsOptions));
// Register the options so AgentEntity can access TTL configuration
services.AddSingleton(options);
if (existingOptionsDescriptor?.ImplementationInstance is DurableAgentsOptions existingOptions)
{
// Merge new agents into the existing options
foreach (KeyValuePair<string, Func<IServiceProvider, AIAgent>> agent in newAgents)
{
if (!existingOptions.ContainsAgent(agent.Key))
{
existingOptions.AddAIAgentFactory(agent.Key, agent.Value, options.GetTimeToLive(agent.Key));
}
}
options = existingOptions;
}
else
{
// Register the options so AgentEntity can access configuration
services.AddSingleton(options);
}
// The keyed services are used to resolve durable agent *proxy* instances for external clients.
foreach (var factory in agents)
foreach (var factory in newAgents)
{
services.AddKeyedSingleton(factory.Key, (sp, _) => factory.Value(sp).AsDurableAgentProxy(sp));
}
// Register the agent factories dictionary for backward compatibility.
// This allows consumers to retrieve agents via services.GetService<IReadOnlyDictionary<string, Func<IServiceProvider, AIAgent>>>().
services.TryAddSingleton(
sp => sp.GetRequiredService<DurableAgentsOptions>().GetAgentFactories());
// A custom data converter is needed because the default chat client uses camel case for JSON properties,
// which is not the default behavior for the Durable Task SDK.
services.AddSingleton<DataConverter, DefaultDataConverter>();
services.TryAddSingleton<DataConverter, DefaultDataConverter>();
return options;
}
@@ -0,0 +1,274 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows;
using Microsoft.Agents.AI.Workflows.Checkpointing;
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>
public sealed record WorkflowExecutorInfo(string ExecutorId, bool IsAgenticExecutor);
/// <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>
public sealed record WorkflowExecutionLevel(int Level, List<WorkflowExecutorInfo> Executors, bool IsFanIn);
/// <summary>
/// Represents the complete execution plan for a workflow, including parallel execution levels.
/// </summary>
public 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 whether this workflow has any parallel execution opportunities.
/// </summary>
public bool HasParallelism => this.Levels.Any(l => l.Executors.Count > 1);
/// <summary>
/// Gets whether this workflow has any Fan-In points.
/// </summary>
public bool HasFanIn => this.Levels.Any(l => l.IsFanIn);
}
/// <summary>
/// Provides helper methods for analyzing and executing workflows.
/// </summary>
public 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.
/// </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<EdgeInfo>> edges = workflow.ReflectEdges();
Dictionary<(string SourceId, string TargetId), Func<object?, bool>?> edgeConditions = workflow.GetEdgeConditions();
WorkflowExecutionPlan plan = new();
// Build adjacency lists (successors and predecessors)
Dictionary<string, List<string>> successors = [];
Dictionary<string, List<string>> predecessors = [];
Dictionary<string, int> inDegree = [];
// Initialize all executors and extract their output types
foreach (KeyValuePair<string, ExecutorBinding> executor in executors)
{
successors[executor.Key] = [];
predecessors[executor.Key] = [];
inDegree[executor.Key] = 0;
// 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
foreach (KeyValuePair<string, HashSet<EdgeInfo>> edgeGroup in edges)
{
string sourceId = edgeGroup.Key;
foreach (EdgeInfo edge in edgeGroup.Value)
{
foreach (string sinkId in edge.Connection.SinkIds)
{
if (executors.ContainsKey(sinkId))
{
successors[sourceId].Add(sinkId);
predecessors[sinkId].Add(sourceId);
inDegree[sinkId]++;
}
}
}
}
// 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 modified Kahn's algorithm
// Instead of processing one at a time, we process all nodes with in-degree 0 at once (same level)
HashSet<string> processed = [];
Dictionary<string, int> currentInDegree = new(inDegree);
int levelNumber = 0;
while (processed.Count < executors.Count)
{
// Find all executors that can be executed at this level (in-degree == 0 and not yet processed)
List<string> currentLevelIds = [];
foreach (KeyValuePair<string, int> kvp in currentInDegree)
{
if (kvp.Value == 0 && !processed.Contains(kvp.Key))
{
currentLevelIds.Add(kvp.Key);
}
}
// If no executors found but not all processed, there might be a cycle
if (currentLevelIds.Count == 0)
{
// Add remaining unprocessed executors
foreach (string executorId in executors.Keys)
{
if (!processed.Contains(executorId))
{
currentLevelIds.Add(executorId);
}
}
if (currentLevelIds.Count == 0)
{
break;
}
}
// Check if this level is a Fan-In point (any executor has multiple predecessors)
bool isFanIn = currentLevelIds.Any(id => predecessors[id].Count > 1);
// Convert to WorkflowExecutorInfo
List<WorkflowExecutorInfo> levelExecutors = [];
foreach (string executorId in currentLevelIds)
{
processed.Add(executorId);
if (executors.TryGetValue(executorId, out ExecutorBinding? executorBinding))
{
bool isAgentic = IsAgentExecutorType(executorBinding.ExecutorType);
levelExecutors.Add(new WorkflowExecutorInfo(executorId, isAgentic));
}
// Decrement in-degree of all successors
foreach (string successor in successors[executorId])
{
currentInDegree[successor]--;
}
}
plan.Levels.Add(new WorkflowExecutionLevel(levelNumber, levelExecutors, isFanIn));
levelNumber++;
}
return plan;
}
/// <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&lt;TInput, TOutput&gt;, returns TOutput.
/// For Executor&lt;TInput&gt;, 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>
public 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;
}
}
@@ -0,0 +1,171 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.DurableTask.Entities;
namespace Microsoft.Agents.AI.DurableTask;
/// <summary>
/// Durable entity that manages workflow state across activities within an orchestration run.
/// Each orchestration instance gets its own entity instance (keyed by orchestration instance ID),
/// ensuring state isolation between workflow runs. The entity is automatically cleaned up
/// when the orchestration completes.
/// </summary>
public sealed class WorkflowSharedStateEntity : TaskEntity<WorkflowStateData>
{
/// <summary>
/// The entity name used for registration and lookup.
/// </summary>
public const string EntityName = "workflow-shared-state";
/// <summary>
/// Reads a state value by key and scope.
/// </summary>
/// <param name="request">The read request containing key and optional scope.</param>
/// <returns>The serialized state value, or null if not found.</returns>
public string? ReadState(WorkflowStateReadRequest request)
{
string scopeKey = GetScopeKey(request.ScopeName, request.Key);
return this.State.Values.TryGetValue(scopeKey, out string? value) ? value : null;
}
/// <summary>
/// Reads the entire state dictionary.
/// </summary>
/// <returns>A copy of the current state.</returns>
public Dictionary<string, string> ReadAllState()
{
return new Dictionary<string, string>(this.State.Values);
}
/// <summary>
/// Writes or updates a state value by key and scope.
/// </summary>
/// <param name="request">The write request containing key, scope, and value.</param>
public void WriteState(WorkflowStateWriteRequest request)
{
string scopeKey = GetScopeKey(request.ScopeName, request.Key);
if (request.Value is null)
{
this.State.Values.Remove(scopeKey);
}
else
{
this.State.Values[scopeKey] = request.Value;
}
}
/// <summary>
/// Gets all keys within a specific scope.
/// </summary>
/// <param name="scopeName">The scope name, or null for the default scope.</param>
/// <returns>A collection of keys within the scope.</returns>
public HashSet<string> GetStateKeys(string? scopeName)
{
string scopePrefix = GetScopePrefix(scopeName);
HashSet<string> keys = [];
foreach (string scopeKey in this.State.Values.Keys)
{
if (scopeKey.StartsWith(scopePrefix, StringComparison.Ordinal))
{
string key = scopeKey[scopePrefix.Length..];
keys.Add(key);
}
}
return keys;
}
/// <summary>
/// Clears all state entries within a specific scope.
/// </summary>
/// <param name="scopeName">The scope name, or null for the default scope.</param>
public void ClearScope(string? scopeName)
{
string scopePrefix = GetScopePrefix(scopeName);
List<string> keysToRemove = [];
foreach (string scopeKey in this.State.Values.Keys)
{
if (scopeKey.StartsWith(scopePrefix, StringComparison.Ordinal))
{
keysToRemove.Add(scopeKey);
}
}
foreach (string key in keysToRemove)
{
this.State.Values.Remove(key);
}
}
/// <summary>
/// Deletes the entity, cleaning up all state.
/// Called by the orchestration when it completes.
/// </summary>
public void Delete()
{
// Setting State to null tells the Durable Task framework to delete the entity.
// The entity will be garbage collected after idle timeout.
this.State = null!;
}
private static string GetScopeKey(string? scopeName, string key)
{
return $"{GetScopePrefix(scopeName)}{key}";
}
private static string GetScopePrefix(string? scopeName)
{
return scopeName is null ? "__default__:" : $"{scopeName}:";
}
}
/// <summary>
/// Represents the internal state data for a workflow state entity.
/// </summary>
public sealed class WorkflowStateData
{
/// <summary>
/// Gets the state dictionary mapping scope-prefixed keys to serialized values.
/// </summary>
public Dictionary<string, string> Values { get; init; } = [];
}
/// <summary>
/// Request model for reading workflow state.
/// </summary>
public sealed class WorkflowStateReadRequest
{
/// <summary>
/// Gets or sets the state key.
/// </summary>
public string Key { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the optional scope name.
/// </summary>
public string? ScopeName { get; set; }
}
/// <summary>
/// Request model for writing workflow state.
/// </summary>
public sealed class WorkflowStateWriteRequest
{
/// <summary>
/// Gets or sets the state key.
/// </summary>
public string Key { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the optional scope name.
/// </summary>
public string? ScopeName { get; set; }
/// <summary>
/// Gets or sets the serialized value, or null to delete the key.
/// </summary>
public string? Value { get; set; }
}
@@ -25,6 +25,44 @@ internal sealed class BuiltInFunctionExecutor : IFunctionExecutor
IFunctionInputBindingFeature? functionInputBindingFeature = context.Features.Get<IFunctionInputBindingFeature>() ??
throw new InvalidOperationException("Function input binding feature is not available on the current context.");
if (context.FunctionDefinition.EntryPoint == BuiltInFunctions.InvokeWorkflowActivityFunctionEntryPoint)
{
// Bind all inputs to get the input string and DurableTaskClient
FunctionInputBindingResult? bindingResults = await functionInputBindingFeature.BindFunctionInputAsync(context);
if (bindingResults is not { Values: { } activityBindings })
{
throw new InvalidOperationException($"Function input binding failed for the invocation {context.InvocationId}");
}
DurableTaskClient? activityDurableTaskClient = null;
string? activityInput = null;
foreach (object? binding in activityBindings)
{
if (binding is string stringInput)
{
activityInput = stringInput;
}
if (binding is DurableTaskClient client)
{
activityDurableTaskClient = client;
}
}
if (activityInput is null)
{
throw new InvalidOperationException($"Activity input binding is missing for the invocation {context.InvocationId}.");
}
if (activityDurableTaskClient is null)
{
throw new InvalidOperationException($"DurableTaskClient binding is missing for the invocation {context.InvocationId}.");
}
context.GetInvocationResult().Value = await BuiltInFunctions.InvokeWorkflowActivityAsync(activityInput, activityDurableTaskClient, context);
return;
}
FunctionInputBindingResult? inputBindingResults = await functionInputBindingFeature.BindFunctionInputAsync(context);
if (inputBindingResults is not { Values: { } values })
{
@@ -102,6 +140,32 @@ internal sealed class BuiltInFunctionExecutor : IFunctionExecutor
return;
}
if (context.FunctionDefinition.EntryPoint == BuiltInFunctions.RunWorkflowOrechstrtationHttpFunctionEntryPoint)
{
if (httpRequestData == null)
{
throw new InvalidOperationException($"HTTP request data binding is missing for the invocation {context.InvocationId}.");
}
context.GetInvocationResult().Value = await BuiltInFunctions.RunWorkflowOrechstrtationHttpTriggerAsync(
httpRequestData,
durableTaskClient,
context);
return;
}
// Handle workflow MCP tool trigger
if (context.FunctionDefinition.EntryPoint == BuiltInFunctions.RunWorkflowMcpToolFunctionEntryPoint)
{
if (mcpToolInvocationContext is null)
{
throw new InvalidOperationException($"MCP tool invocation context binding is missing for the invocation {context.InvocationId}.");
}
context.GetInvocationResult().Value = await BuiltInFunctions.RunWorkflowMcpToolAsync(mcpToolInvocationContext, durableTaskClient, context);
return;
}
throw new InvalidOperationException($"Unsupported function entry point '{context.FunctionDefinition.EntryPoint}' for invocation {context.InvocationId}.");
}
}
@@ -20,7 +20,30 @@ internal static class BuiltInFunctions
internal static readonly string RunAgentHttpFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RunAgentHttpAsync)}";
internal static readonly string RunAgentEntityFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(InvokeAgentAsync)}";
internal static readonly string RunWorkflowOrechstrtationHttpFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RunWorkflowOrechstrtationHttpTriggerAsync)}";
internal static readonly string InvokeWorkflowActivityFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(InvokeWorkflowActivityAsync)}";
internal static readonly string RunAgentMcpToolFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RunMcpToolAsync)}";
internal static readonly string RunWorkflowMcpToolFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RunWorkflowMcpToolAsync)}";
#pragma warning disable IL3000 // Avoid accessing Assembly file path when publishing as a single file - Azure Functions does not use single-file publishing
internal static readonly string ScriptFile = Path.GetFileName(typeof(BuiltInFunctions).Assembly.Location);
#pragma warning restore IL3000
// Exposed as an activity trigger for workflow executors
public static Task<string> InvokeWorkflowActivityAsync(
[ActivityTrigger] string input,
[DurableClient] DurableTaskClient durableTaskClient,
FunctionContext functionContext)
{
ArgumentNullException.ThrowIfNull(input);
ArgumentNullException.ThrowIfNull(durableTaskClient);
ArgumentNullException.ThrowIfNull(functionContext);
string activityFunctionName = functionContext.FunctionDefinition.Name;
FunctionsWorkflowRunner runner = functionContext.InstanceServices.GetRequiredService<FunctionsWorkflowRunner>();
return runner.ExecuteActivityAsync(activityFunctionName, input, durableTaskClient, functionContext);
}
// Exposed as an entity trigger via AgentFunctionsProvider
public static Task<string> InvokeAgentAsync(
@@ -43,6 +66,24 @@ internal static class BuiltInFunctions
return GrpcEntityRunner.LoadAndRunAsync(encodedEntityRequest, entity, combinedServiceProvider);
}
/// <summary>
/// Invokes a workflow orchestration in response to an HTTP request.
/// </summary>
public static async Task<HttpResponseData> RunWorkflowOrechstrtationHttpTriggerAsync(
[HttpTrigger] HttpRequestData req,
[DurableClient] DurableTaskClient client,
FunctionContext context)
{
var workflowName = context.FunctionDefinition.Name.Replace(HttpPrefix, string.Empty);
var orchestrationFunctionName = WorkflowNamingHelper.ToOrchestrationFunctionName(workflowName);
var inputMessage = await req.ReadAsStringAsync();
string instanceId = await client.ScheduleNewOrchestrationInstanceAsync(orchestrationFunctionName, inputMessage);
HttpResponseData response = req.CreateResponse(HttpStatusCode.Accepted);
await response.WriteStringAsync($"InvokeWorkflowOrechstrtationAsync is invoked for {workflowName}. Orchestration instanceId: {instanceId}");
return response;
}
public static async Task<HttpResponseData> RunAgentHttpAsync(
[HttpTrigger] HttpRequestData req,
[DurableClient] DurableTaskClient client,
@@ -178,6 +219,39 @@ internal static class BuiltInFunctions
return agentResponse.Text;
}
/// <summary>
/// Runs a workflow via MCP tool trigger.
/// </summary>
public static async Task<string?> RunWorkflowMcpToolAsync(
[McpToolTrigger("BuiltInWorkflowMcpTool")] ToolInvocationContext context,
[DurableClient] DurableTaskClient client,
FunctionContext functionContext)
{
if (context.Arguments is null)
{
throw new ArgumentException("MCP Tool invocation is missing required arguments.");
}
if (!context.Arguments.TryGetValue("input", out object? inputObj) || inputObj is not string input)
{
throw new ArgumentException("MCP Tool invocation is missing required 'input' argument of type string.");
}
// Extract workflow name from the MCP tool name (format: mcptool-workflow-{workflowName})
string workflowName = context.Name;
string orchestrationFunctionName = WorkflowNamingHelper.ToOrchestrationFunctionName(workflowName);
string instanceId = await client.ScheduleNewOrchestrationInstanceAsync(orchestrationFunctionName, input);
// Wait for the orchestration to complete and return the result
OrchestrationMetadata? metadata = await client.WaitForInstanceCompletionAsync(
instanceId,
getInputsAndOutputs: true,
cancellation: functionContext.CancellationToken);
return metadata?.ReadOutputAs<string>();
}
/// <summary>
/// Creates an error response with the specified status code and error message.
/// </summary>
@@ -0,0 +1,80 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.DurableTask;
using Microsoft.Azure.Functions.Worker.Builder;
using Microsoft.Azure.Functions.Worker.Core.FunctionMetadata;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
/// <summary>
/// Shared configuration logic for durable agents and workflows.
/// This class consolidates common service registrations used by both
/// <see cref="FunctionsApplicationBuilderExtensions.ConfigureDurableAgents"/> and
/// <see cref="DurableOptionsExtensions.ConfigureDurableOptions"/>.
/// </summary>
internal static class CoreAgentConfigurationExtensions
{
/// <summary>
/// Registers the core agent services required for durable agents.
/// </summary>
/// <param name="builder">The functions application builder.</param>
/// <returns>The functions application builder for method chaining.</returns>
internal static FunctionsApplicationBuilder RegisterCoreAgentServices(this FunctionsApplicationBuilder builder)
{
builder.Services.TryAddSingleton<IFunctionsAgentOptionsProvider>(_ =>
new DefaultFunctionsAgentOptionsProvider(DurableAgentsOptionsExtensions.GetAgentOptionsSnapshot()));
builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IFunctionMetadataTransformer, DurableAgentFunctionMetadataTransformer>());
return builder;
}
/// <summary>
/// Registers the workflow-specific services required for durable workflows.
/// This should only be called when workflows are configured in the application.
/// </summary>
/// <param name="builder">The functions application builder.</param>
/// <returns>The functions application builder for method chaining.</returns>
internal static FunctionsApplicationBuilder RegisterWorkflowServices(this FunctionsApplicationBuilder builder)
{
// Register FunctionsWorkflowRunner as a singleton
builder.Services.TryAddSingleton<FunctionsWorkflowRunner>();
// Also register it as DurableWorkflowRunner so orchestrations can resolve it by base type
builder.Services.TryAddSingleton<DurableWorkflowRunner>(sp => sp.GetRequiredService<FunctionsWorkflowRunner>());
builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IFunctionMetadataTransformer, DurableWorkflowFunctionMetadataTransformer>());
return builder;
}
/// <summary>
/// Configures the middleware and executor for handling built-in function execution.
/// This is shared by both agents and workflows, handling Agent HTTP, MCP tool,
/// workflow orchestration, and Entity invocations.
/// </summary>
/// <param name="builder">The functions application builder.</param>
/// <returns>The functions application builder for method chaining.</returns>
internal static FunctionsApplicationBuilder ConfigureBuiltInFunctionMiddleware(this FunctionsApplicationBuilder builder)
{
builder.Services.TryAddSingleton<BuiltInFunctionExecutor>();
builder.UseWhen<BuiltInFunctionExecutionMiddleware>(static context =>
IsBuiltInFunction(context.FunctionDefinition.EntryPoint));
return builder;
}
private static bool IsBuiltInFunction(string? entryPoint)
{
return string.Equals(entryPoint, BuiltInFunctions.RunAgentHttpFunctionEntryPoint, StringComparison.Ordinal)
|| string.Equals(entryPoint, BuiltInFunctions.RunAgentMcpToolFunctionEntryPoint, StringComparison.Ordinal)
|| string.Equals(entryPoint, BuiltInFunctions.RunWorkflowMcpToolFunctionEntryPoint, StringComparison.Ordinal)
|| string.Equals(entryPoint, BuiltInFunctions.RunWorkflowOrechstrtationHttpFunctionEntryPoint, StringComparison.Ordinal)
|| string.Equals(entryPoint, BuiltInFunctions.InvokeWorkflowActivityFunctionEntryPoint, StringComparison.Ordinal)
|| string.Equals(entryPoint, BuiltInFunctions.RunAgentEntityFunctionEntryPoint, StringComparison.Ordinal);
}
}
@@ -13,7 +13,7 @@ namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
internal sealed class DurableAgentFunctionMetadataTransformer : IFunctionMetadataTransformer
{
private readonly ILogger<DurableAgentFunctionMetadataTransformer> _logger;
private readonly IReadOnlyDictionary<string, Func<IServiceProvider, AIAgent>> _agents;
private readonly DurableAgentsOptions _agentOptions;
private readonly IServiceProvider _serviceProvider;
private readonly IFunctionsAgentOptionsProvider _functionsAgentOptionsProvider;
@@ -22,12 +22,12 @@ internal sealed class DurableAgentFunctionMetadataTransformer : IFunctionMetadat
#pragma warning restore IL3000
public DurableAgentFunctionMetadataTransformer(
IReadOnlyDictionary<string, Func<IServiceProvider, AIAgent>> agents,
DurableAgentsOptions agentOptions,
ILogger<DurableAgentFunctionMetadataTransformer> logger,
IServiceProvider serviceProvider,
IFunctionsAgentOptionsProvider functionsAgentOptionsProvider)
{
this._agents = agents ?? throw new ArgumentNullException(nameof(agents));
this._agentOptions = agentOptions ?? throw new ArgumentNullException(nameof(agentOptions));
this._logger = logger ?? throw new ArgumentNullException(nameof(logger));
this._serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
this._functionsAgentOptionsProvider = functionsAgentOptionsProvider ?? throw new ArgumentNullException(nameof(functionsAgentOptionsProvider));
@@ -39,7 +39,7 @@ internal sealed class DurableAgentFunctionMetadataTransformer : IFunctionMetadat
{
this._logger.LogTransformingFunctionMetadata(original.Count);
foreach (KeyValuePair<string, Func<IServiceProvider, AIAgent>> kvp in this._agents)
foreach (KeyValuePair<string, Func<IServiceProvider, AIAgent>> kvp in this._agentOptions.GetAgentFactories())
{
string agentName = kvp.Key;
@@ -113,17 +113,45 @@ public static class DurableAgentsOptionsExtensions
Func<IServiceProvider, AIAgent> factory,
bool enableHttpTrigger,
bool enableMcpToolTrigger)
{
return AddAIAgentFactory(options, name, factory, enableHttpTrigger, enableMcpToolTrigger, timeToLive: null);
}
/// <summary>
/// Registers an AI agent factory with the specified name, trigger options, and time-to-live configuration.
/// </summary>
/// <remarks>If both triggers are disabled, the agent will not be accessible via HTTP or MCP tool
/// endpoints. This method can be used to register multiple agent factories with different configurations.</remarks>
/// <param name="options">The options object to which the AI agent factory will be added. Cannot be null.</param>
/// <param name="name">The unique name used to identify the AI agent factory. Cannot be null.</param>
/// <param name="factory">A delegate that creates an instance of the AI agent using the provided service provider. Cannot be null.</param>
/// <param name="enableHttpTrigger">true to enable the HTTP trigger for the agent; otherwise, false.</param>
/// <param name="enableMcpToolTrigger">true to enable the MCP tool trigger for the agent; otherwise, false.</param>
/// <param name="timeToLive">Optional time-to-live for this agent's entities.</param>
/// <returns>The same DurableAgentsOptions instance, allowing for method chaining.</returns>
public static DurableAgentsOptions AddAIAgentFactory(
this DurableAgentsOptions options,
string name,
Func<IServiceProvider, AIAgent> factory,
bool enableHttpTrigger,
bool enableMcpToolTrigger,
TimeSpan? timeToLive)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(name);
ArgumentNullException.ThrowIfNull(factory);
FunctionsAgentOptions agentOptions = new();
agentOptions.HttpTrigger.IsEnabled = enableHttpTrigger;
agentOptions.McpToolTrigger.IsEnabled = enableMcpToolTrigger;
// Check if agent options already exist (e.g., from a previous ConfigureDurableAgents call)
// If so, preserve the existing options instead of overwriting them
if (!s_agentOptions.ContainsKey(name))
{
FunctionsAgentOptions agentOptions = new();
agentOptions.HttpTrigger.IsEnabled = enableHttpTrigger;
agentOptions.McpToolTrigger.IsEnabled = enableMcpToolTrigger;
s_agentOptions[name] = agentOptions;
}
options.AddAIAgentFactory(name, factory);
s_agentOptions[name] = agentOptions;
options.AddAIAgentFactory(name, factory, timeToLive);
return options;
}
@@ -0,0 +1,115 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.DurableTask;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Builder;
using Microsoft.DurableTask;
using Microsoft.DurableTask.Worker;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
/// <summary>
/// Extension methods for configuring durable options (agents and workflows).
/// </summary>
public static class DurableOptionsExtensions
{
/// <summary>
/// Configures durable agents and workflows in a unified way.
/// </summary>
/// <param name="builder">The Functions application builder.</param>
/// <param name="configure">A delegate to configure the durable options.</param>
/// <returns>The Functions application builder for method chaining.</returns>
/// <remarks>
/// This method provides a unified configuration point for both durable agents and workflows.
/// It automatically generates HTTP API endpoints for agents and workflows, and configures
/// the necessary middleware and services for durable execution.
/// </remarks>
public static FunctionsApplicationBuilder ConfigureDurableOptions(
this FunctionsApplicationBuilder builder,
Action<DurableOptions> configure)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(configure);
DurableOptions options = new();
configure(options);
RegisterServices(builder, options);
ConfigureAgents(builder, options);
builder.ConfigureBuiltInFunctionMiddleware();
if (options.Workflows.Workflows.Count > 0)
{
builder.RegisterWorkflowServices();
ConfigureWorkflowOrchestrations(builder, options.Workflows);
}
return builder;
}
private static void RegisterServices(FunctionsApplicationBuilder builder, DurableOptions options)
{
builder.Services.TryAddSingleton(options);
builder.Services.TryAddSingleton(options.Agents);
builder.RegisterCoreAgentServices();
}
private static void ConfigureAgents(FunctionsApplicationBuilder builder, DurableOptions options)
{
// Only configure agents if there are any agent factories registered in DurableOptions
IReadOnlyDictionary<string, Func<IServiceProvider, AIAgent>> agentFactories = options.Agents.GetAgentFactories();
if (agentFactories.Count == 0)
{
return;
}
builder.Services.ConfigureDurableAgents(agentOpts =>
{
foreach (KeyValuePair<string, Func<IServiceProvider, AIAgent>> agentFactory in agentFactories)
{
bool isWorkflowOnly = options.Agents.IsWorkflowOnly(agentFactory.Key);
agentOpts.AddAIAgentFactory(
agentFactory.Key,
agentFactory.Value,
enableHttpTrigger: !isWorkflowOnly,
enableMcpToolTrigger: false,
timeToLive: options.Agents.GetTimeToLive(agentFactory.Key));
}
agentOpts.DefaultTimeToLive = options.Agents.DefaultTimeToLive;
agentOpts.MinimumTimeToLiveSignalDelay = options.Agents.MinimumTimeToLiveSignalDelay;
});
}
private static void ConfigureWorkflowOrchestrations(FunctionsApplicationBuilder builder, DurableWorkflowOptions workflows)
{
builder.ConfigureDurableWorker().AddTasks(tasks =>
{
// Register the workflow state entity for shared state management within workflows.
tasks.AddEntity<WorkflowSharedStateEntity>(WorkflowSharedStateEntity.EntityName);
foreach (string workflowName in workflows.Workflows.Select(kp => kp.Key))
{
string orchestrationFunctionName = WorkflowNamingHelper.ToOrchestrationFunctionName(workflowName);
tasks.AddOrchestratorFunc<string, string>(
orchestrationFunctionName,
async (orchestrationContext, request) =>
{
FunctionContext functionContext = orchestrationContext.GetFunctionContext()
?? throw new InvalidOperationException("FunctionContext is not available in the orchestration context.");
DurableWorkflowRunner runner = functionContext.InstanceServices.GetRequiredService<DurableWorkflowRunner>();
ILogger logger = orchestrationContext.CreateReplaySafeLogger(orchestrationFunctionName);
return await runner.RunWorkflowOrchestrationAsync(orchestrationContext, request, logger).ConfigureAwait(true);
});
}
});
}
}
@@ -0,0 +1,169 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.DurableTask;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Azure.Functions.Worker.Core.FunctionMetadata;
using Microsoft.Extensions.Logging;
namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
internal sealed class DurableWorkflowFunctionMetadataTransformer : IFunctionMetadataTransformer
{
private readonly ILogger<DurableWorkflowFunctionMetadataTransformer> _logger;
private readonly DurableWorkflowOptions _options;
public DurableWorkflowFunctionMetadataTransformer(ILogger<DurableWorkflowFunctionMetadataTransformer> logger, DurableOptions durableOptions)
{
this._logger = logger ?? throw new ArgumentNullException(nameof(logger));
ArgumentNullException.ThrowIfNull(durableOptions);
this._options = durableOptions.Workflows;
}
public string Name => nameof(DurableWorkflowFunctionMetadataTransformer);
public void Transform(IList<IFunctionMetadata> original)
{
this._logger.LogTransformStart(original.Count);
// Track registered function names to avoid duplicates when the same executor is used in multiple workflows
HashSet<string> registeredFunctionNames = new();
foreach (var workflow in this._options.Workflows)
{
this._logger.LogAddingWorkflowFunction(workflow.Key);
// Currently due to how durable executor is registered, we are not able to bind TaskOrechestrationContext parameter properly
// because the InputBinding for TOC happens inside the DurableExecutor (rathen than in an input converter).
// So for now, we are going to use single orchestration function for all workflows.
//original.Add(CreateOrchestrationTrigger(workflow.Key));
// We also want to create an HTTP trigger for this orchestration so users can start it via HTTP.
this._logger.LogAddingHttpTrigger(workflow.Key);
original.Add(CreateHttpTrigger(workflow.Key, $"workflows/{workflow.Key}/run"));
// Check if MCP tool trigger is enabled for this workflow
if (DurableWorkflowOptionsExtensions.TryGetWorkflowOptions(workflow.Key, out FunctionsWorkflowOptions? workflowOptions) &&
workflowOptions?.McpToolTrigger.IsEnabled == true)
{
this._logger.LogAddingMcpToolTrigger(workflow.Key);
original.Add(CreateMcpToolTrigger(workflow.Key, workflow.Value.Description));
}
// Create activity/entity functions for each executor in the workflow based on their type
// Extract executor IDs from edges and start executor
HashSet<string> executorIds = new() { workflow.Value.StartExecutorId };
var reflectedEdges = workflow.Value.ReflectEdges();
foreach (var (sourceId, edgeSet) in reflectedEdges)
{
executorIds.Add(sourceId);
foreach (var edge in edgeSet)
{
foreach (var sinkId in edge.Connection.SinkIds)
{
executorIds.Add(sinkId);
}
}
}
Dictionary<string, ExecutorBinding> executorBindings = workflow.Value.ReflectExecutors();
foreach (string executorId in executorIds)
{
if (executorBindings.TryGetValue(executorId, out ExecutorBinding? executorBinding))
{
string executorName = WorkflowNamingHelper.GetExecutorName(executorId);
string functionName = WorkflowNamingHelper.ToOrchestrationFunctionName(executorName);
// Skip if this function has already been registered by another workflow
if (!registeredFunctionNames.Add(functionName))
{
this._logger.LogSkippingDuplicateFunction(functionName, workflow.Key);
continue;
}
// Check if the executor type is an agent-related type
if (executorBinding is AIAgentBinding)
{
this._logger.LogAddingAgentEntityFunction(executorId, executorBinding.ExecutorType.FullName ?? executorBinding.ExecutorType.Name, workflow.Key);
//original.Add(CreateAgentTrigger(functionName));
}
else
{
this._logger.LogAddingActivityFunction(executorId, executorBinding.ExecutorType.FullName ?? executorBinding.ExecutorType.Name, workflow.Key);
original.Add(CreateActivityTrigger(functionName));
}
}
}
}
this._logger.LogTransformFinished(original.Count);
}
private static DefaultFunctionMetadata CreateHttpTrigger(string name, string route)
{
return new DefaultFunctionMetadata()
{
Name = $"{BuiltInFunctions.HttpPrefix}{name}",
Language = "dotnet-isolated",
RawBindings =
[
$"{{\"name\":\"req\",\"type\":\"httpTrigger\",\"direction\":\"In\",\"authLevel\":\"function\",\"methods\": [\"post\"],\"route\":\"{route}\"}}",
"{\"name\":\"$return\",\"type\":\"http\",\"direction\":\"Out\"}",
"{\"name\":\"client\",\"type\":\"durableClient\",\"direction\":\"In\"}"
],
EntryPoint = BuiltInFunctions.RunWorkflowOrechstrtationHttpFunctionEntryPoint,
ScriptFile = BuiltInFunctions.ScriptFile
};
}
//private static DefaultFunctionMetadata CreateOrchestrationTrigger(string name)
//{
// return new DefaultFunctionMetadata()
// {
// Name = AgentSessionId.ToEntityName(name),
// Language = "dotnet-isolated",
// RawBindings =
// [
// // """{"name":"context","type":"orchestrationTrigger","direction":"In"}""",
// """{"name":"taskOrchestrationContext","type":"orchestrationTrigger","direction":"In"}""",
// ],
// EntryPoint = BuiltInFunctions.RunWorkflowOrechstrtationFunctionEntryPoint,
// ScriptFile = BuiltInFunctions.ScriptFile,
// };
//}
private static DefaultFunctionMetadata CreateActivityTrigger(string functionName)
{
return new DefaultFunctionMetadata()
{
Name = functionName,
Language = "dotnet-isolated",
RawBindings =
[
"""{"name":"input","type":"activityTrigger","direction":"In","dataType":"String"}""",
"""{"name":"durableTaskClient","type":"durableClient","direction":"In"}"""
],
EntryPoint = BuiltInFunctions.InvokeWorkflowActivityFunctionEntryPoint,
ScriptFile = BuiltInFunctions.ScriptFile,
};
}
private static DefaultFunctionMetadata CreateMcpToolTrigger(string workflowName, string? description)
{
return new DefaultFunctionMetadata
{
Name = $"{BuiltInFunctions.McpToolPrefix}{workflowName}",
Language = "dotnet-isolated",
RawBindings =
[
$$"""{"name":"context","type":"mcpToolTrigger","direction":"In","toolName":"{{workflowName}}","description":"{{description ?? $"Run the {workflowName} workflow"}}","toolProperties":"[{\"propertyName\":\"input\",\"propertyType\":\"string\",\"description\":\"The input to the workflow.\",\"isRequired\":true,\"isArray\":false}]"}""",
"""{"name":"input","type":"mcpToolProperty","direction":"In","propertyName":"input","description":"The input to the workflow","isRequired":true,"dataType":"String","propertyType":"string"}""",
"""{"name":"client","type":"durableClient","direction":"In"}"""
],
EntryPoint = BuiltInFunctions.RunWorkflowMcpToolFunctionEntryPoint,
ScriptFile = BuiltInFunctions.ScriptFile,
};
}
}
@@ -0,0 +1,61 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.Logging;
namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
/// <summary>
/// Logging messages for <see cref="DurableWorkflowFunctionMetadataTransformer"/>.
/// </summary>
[ExcludeFromCodeCoverage]
internal static partial class DurableWorkflowFunctionMetadataTransformerLogs
{
[LoggerMessage(
EventId = 200,
Level = LogLevel.Information,
Message = "Transforming function metadata to add durable workflow functions. Initial function count: {FunctionCount}")]
public static partial void LogTransformStart(this ILogger logger, int functionCount);
[LoggerMessage(
EventId = 201,
Level = LogLevel.Information,
Message = "Adding durable workflow function for workflow: {WorkflowName}")]
public static partial void LogAddingWorkflowFunction(this ILogger logger, string workflowName);
[LoggerMessage(
EventId = 202,
Level = LogLevel.Information,
Message = "Adding HTTP trigger function for workflow: {WorkflowName}")]
public static partial void LogAddingHttpTrigger(this ILogger logger, string workflowName);
[LoggerMessage(
EventId = 203,
Level = LogLevel.Information,
Message = "Adding activity function for executor: {ExecutorId} (Type: {ExecutorType}) in workflow: {WorkflowName}")]
public static partial void LogAddingActivityFunction(this ILogger logger, string executorId, string executorType, string workflowName);
[LoggerMessage(
EventId = 204,
Level = LogLevel.Information,
Message = "Adding agent entity function for executor: {ExecutorId} (Type: {ExecutorType}) in workflow: {WorkflowName}")]
public static partial void LogAddingAgentEntityFunction(this ILogger logger, string executorId, string executorType, string workflowName);
[LoggerMessage(
EventId = 205,
Level = LogLevel.Information,
Message = "Adding MCP tool trigger function for workflow: {WorkflowName}")]
public static partial void LogAddingMcpToolTrigger(this ILogger logger, string workflowName);
[LoggerMessage(
EventId = 206,
Level = LogLevel.Information,
Message = "Transform finished. Updated function count: {FunctionCount}")]
public static partial void LogTransformFinished(this ILogger logger, int functionCount);
[LoggerMessage(
EventId = 207,
Level = LogLevel.Debug,
Message = "Skipping duplicate function registration: {FunctionName} (already registered by another workflow) in workflow: {WorkflowName}")]
public static partial void LogSkippingDuplicateFunction(this ILogger logger, string functionName, string workflowName);
}
@@ -0,0 +1,85 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.DurableTask;
using Microsoft.Agents.AI.Workflows;
namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
/// <summary>
/// Provides extension methods for registering and configuring workflows in the context of the Azure Functions hosting environment.
/// </summary>
public static class DurableWorkflowOptionsExtensions
{
// Registry of workflow options.
private static readonly Dictionary<string, FunctionsWorkflowOptions> s_workflowOptions = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Adds a workflow to the specified <see cref="DurableWorkflowOptions"/> instance and optionally configures
/// workflow-specific options.
/// </summary>
/// <param name="options">The <see cref="DurableWorkflowOptions"/> instance to which the workflow will be added.</param>
/// <param name="workflow">The workflow to add. The workflow's Name property must not be null or empty.</param>
/// <param name="configure">An optional delegate to configure workflow-specific options. If null, default options are used.</param>
/// <returns>The updated <see cref="DurableWorkflowOptions"/> instance containing the added workflow.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="options"/> or <paramref name="workflow"/> is null.</exception>
/// <exception cref="ArgumentException">Thrown when the workflow does not have a valid name.</exception>
public static DurableWorkflowOptions AddWorkflow(
this DurableWorkflowOptions options,
Workflow workflow,
Action<FunctionsWorkflowOptions>? configure)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(workflow);
if (string.IsNullOrEmpty(workflow.Name))
{
throw new ArgumentException("Workflow must have a valid Name property.", nameof(workflow));
}
// Initialize with default behavior (MCP trigger disabled)
FunctionsWorkflowOptions workflowOptions = new();
configure?.Invoke(workflowOptions);
options.AddWorkflow(workflow);
s_workflowOptions[workflow.Name] = workflowOptions;
return options;
}
/// <summary>
/// Adds a workflow to the specified <see cref="DurableWorkflowOptions"/> instance and configures
/// trigger support for MCP tool invocations.
/// </summary>
/// <param name="options">The <see cref="DurableWorkflowOptions"/> instance to which the workflow will be added.</param>
/// <param name="workflow">The workflow to add. The workflow's Name property must not be null or empty.</param>
/// <param name="enableMcpToolTrigger">true to enable an MCP tool trigger for the workflow; otherwise, false.</param>
/// <returns>The updated <see cref="DurableWorkflowOptions"/> instance with the specified workflow and trigger configuration applied.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="options"/> or <paramref name="workflow"/> is null.</exception>
/// <exception cref="ArgumentException">Thrown when the workflow does not have a valid name.</exception>
public static DurableWorkflowOptions AddWorkflow(
this DurableWorkflowOptions options,
Workflow workflow,
bool enableMcpToolTrigger)
{
return AddWorkflow(options, workflow, workflowOptions => workflowOptions.McpToolTrigger.IsEnabled = enableMcpToolTrigger);
}
/// <summary>
/// Tries to get the <see cref="FunctionsWorkflowOptions"/> for a workflow by name.
/// </summary>
/// <param name="workflowName">The name of the workflow.</param>
/// <param name="workflowOptions">When this method returns, contains the workflow options if found; otherwise, null.</param>
/// <returns><c>true</c> if the workflow options were found; otherwise, <c>false</c>.</returns>
internal static bool TryGetWorkflowOptions(string workflowName, out FunctionsWorkflowOptions? workflowOptions)
{
return s_workflowOptions.TryGetValue(workflowName, out workflowOptions);
}
/// <summary>
/// Builds the workflow options used for dependency injection (read-only copy).
/// </summary>
internal static IReadOnlyDictionary<string, FunctionsWorkflowOptions> GetWorkflowOptionsSnapshot()
{
return new Dictionary<string, FunctionsWorkflowOptions>(s_workflowOptions, StringComparer.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,100 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using Microsoft.Agents.AI.DurableTask;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Agents.AI.Workflows.Checkpointing;
using Microsoft.Azure.Functions.Worker;
using Microsoft.DurableTask.Client;
using Microsoft.Extensions.Logging;
namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
/// <summary>
/// Azure Functions-specific workflow runner that extends the base <see cref="DurableWorkflowRunner"/>
/// with Azure Functions activity execution support.
/// </summary>
internal sealed class FunctionsWorkflowRunner : DurableWorkflowRunner
{
/// <summary>
/// Initializes a new instance of the <see cref="FunctionsWorkflowRunner"/> class.
/// </summary>
/// <param name="logger">The logger instance.</param>
/// <param name="durableOptions">The durable options containing workflow configurations.</param>
public FunctionsWorkflowRunner(ILogger<FunctionsWorkflowRunner> logger, DurableOptions durableOptions)
: base(logger, durableOptions)
{
}
/// <summary>
/// Executes an activity function for a workflow executor.
/// </summary>
/// <param name="activityFunctionName">The name of the activity function to execute.</param>
/// <param name="input">The serialized executor input.</param>
/// <param name="durableTaskClient">The durable task client for entity operations.</param>
/// <param name="functionContext">The function context containing binding data with the orchestration instance ID.</param>
/// <returns>The serialized executor output.</returns>
internal async Task<string> ExecuteActivityAsync(
string activityFunctionName,
string input,
DurableTaskClient durableTaskClient,
FunctionContext functionContext)
{
ArgumentNullException.ThrowIfNull(activityFunctionName);
ArgumentNullException.ThrowIfNull(input);
ArgumentNullException.ThrowIfNull(durableTaskClient);
ArgumentNullException.ThrowIfNull(functionContext);
string executorName = ParseExecutorName(activityFunctionName);
if (!this.Options.Executors.TryGetExecutor(executorName, out ExecutorRegistration? registration) || registration is null)
{
throw new InvalidOperationException($"Executor '{executorName}' not found in the executor registry.");
}
this.Logger.LogExecutingActivity(registration.ExecutorId, executorName);
Executor executor = await registration.CreateExecutorInstanceAsync("activity-run", CancellationToken.None)
.ConfigureAwait(false);
Type inputType = executor.InputTypes.FirstOrDefault() ?? typeof(string);
object typedInput = DeserializeInput(input, inputType);
// Get the orchestration instance ID from the function context binding data
string instanceId = GetInstanceIdFromContext(functionContext)
?? throw new InvalidOperationException(
"Could not retrieve orchestration instance ID from FunctionContext. " +
"Ensure the activity is being called from within a durable orchestration.");
// Create context with durable entity-backed state
IWorkflowContext context = CreateExecutorContext(instanceId, durableTaskClient);
object? result = await executor.ExecuteAsync(
typedInput,
new TypeId(inputType),
context,
CancellationToken.None).ConfigureAwait(false);
return SerializeResult(result);
}
private static string? GetInstanceIdFromContext(FunctionContext functionContext)
{
if (functionContext.BindingContext.BindingData.TryGetValue("instanceId", out object? instanceIdObj) &&
instanceIdObj is string instanceId)
{
return instanceId;
}
return null;
}
[UnconditionalSuppressMessage("AOT", "IL2026:RequiresUnreferencedCode", Justification = "DurableExecutorContext state serialization is done at runtime with user-known types.")]
[UnconditionalSuppressMessage("AOT", "IL3050:RequiresDynamicCode", Justification = "DurableExecutorContext state serialization is done at runtime with user-known types.")]
private static DurableExecutorContext CreateExecutorContext(
string instanceId,
DurableTaskClient client)
{
return new DurableExecutorContext(instanceId, client);
}
}
@@ -2,10 +2,6 @@
using Microsoft.Agents.AI.DurableTask;
using Microsoft.Azure.Functions.Worker.Builder;
using Microsoft.Azure.Functions.Worker.Core.FunctionMetadata;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
@@ -24,22 +20,16 @@ public static class FunctionsApplicationBuilderExtensions
this FunctionsApplicationBuilder builder,
Action<DurableAgentsOptions> configure)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(configure);
// The main agent services registration is done in Microsoft.DurableTask.Agents.
builder.Services.ConfigureDurableAgents(configure);
builder.Services.TryAddSingleton<IFunctionsAgentOptionsProvider>(_ =>
new DefaultFunctionsAgentOptionsProvider(DurableAgentsOptionsExtensions.GetAgentOptionsSnapshot()));
builder.RegisterCoreAgentServices();
builder.Services.AddSingleton<IFunctionMetadataTransformer, DurableAgentFunctionMetadataTransformer>();
// Handling of built-in function execution for Agent HTTP, MCP tool, or Entity invocations.
builder.UseWhen<BuiltInFunctionExecutionMiddleware>(static context =>
string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunAgentHttpFunctionEntryPoint, StringComparison.Ordinal) ||
string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunAgentMcpToolFunctionEntryPoint, StringComparison.Ordinal) ||
string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunAgentEntityFunctionEntryPoint, StringComparison.Ordinal));
builder.Services.AddSingleton<BuiltInFunctionExecutor>();
// Configure middleware for built-in function execution.
builder.ConfigureBuiltInFunctionMiddleware();
return builder;
}
@@ -0,0 +1,17 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
/// <summary>
/// Provides configuration options for enabling and customizing function triggers for a workflow.
/// </summary>
public sealed class FunctionsWorkflowOptions
{
/// <summary>
/// Gets or sets the options used to configure the MCP tool trigger behavior.
/// </summary>
/// <remarks>
/// By default, MCP tool trigger is disabled for workflows.
/// </remarks>
public McpToolTriggerOptions McpToolTrigger { get; set; } = new(false);
}
@@ -4,7 +4,7 @@
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<!-- CA2007: This rule should generally be suppressed in Durable Task libraries. Also, this is not library code. -->
<NoWarn>$(NoWarn);CA2007</NoWarn>
<NoWarn>$(NoWarn);CA2007;AD0001</NoWarn>
</PropertyGroup>
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
@@ -35,6 +35,39 @@ public class Workflow
);
}
/// <summary>
/// Gets the condition functions for direct edges, keyed by (sourceId, targetId) tuple.
/// </summary>
/// <returns>A dictionary mapping edge connections to their condition functions (null if no condition).</returns>
/// <remarks>This method creates a new dictionary each time it is called to ensure thread safety.</remarks>
[System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1024:Use properties where appropriate", Justification = "Method creates a new collection on each call.")]
public Dictionary<(string SourceId, string TargetId), Func<object?, bool>?> GetEdgeConditions()
{
Dictionary<(string SourceId, string TargetId), Func<object?, bool>?> conditions = [];
foreach (KeyValuePair<string, HashSet<Edge>> edgeGroup in this.Edges)
{
foreach (Edge edge in edgeGroup.Value)
{
if (edge.DirectEdgeData is DirectEdgeData directEdge)
{
conditions[(directEdge.SourceId, directEdge.SinkId)] = directEdge.Condition;
}
}
}
return conditions;
}
/// <summary>
/// Gets all executor bindings in the workflow, keyed by their ID.
/// </summary>
/// <returns>A dictionary mapping executor IDs to their <see cref="ExecutorBinding"/>.</returns>
public Dictionary<string, ExecutorBinding> ReflectExecutors()
{
return new Dictionary<string, ExecutorBinding>(this.ExecutorBindings);
}
internal Dictionary<string, RequestPort> Ports { get; init; } = [];
/// <summary>
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using Microsoft.Agents.AI.DurableTask;
using Microsoft.Azure.Functions.Worker.Core.FunctionMetadata;
using Microsoft.Extensions.Logging.Abstractions;
@@ -21,10 +22,8 @@ public sealed class DurableAgentFunctionMetadataTransformerTests
int expectedMetadataCount)
{
// Arrange
Dictionary<string, Func<IServiceProvider, AIAgent>> agents = new()
{
{ "testAgent", _ => new TestAgent("testAgent", "Test agent description") }
};
DurableAgentsOptions durableAgentsOptions = new();
durableAgentsOptions.AddAIAgentFactory("testAgent", _ => new TestAgent("testAgent", "Test agent description"));
FunctionsAgentOptions options = new();
@@ -39,7 +38,7 @@ public sealed class DurableAgentFunctionMetadataTransformerTests
List<IFunctionMetadata> metadataList = BuildFunctionMetadataList(initialMetadataEntryCount);
DurableAgentFunctionMetadataTransformer transformer = new(
agents,
durableAgentsOptions,
NullLogger<DurableAgentFunctionMetadataTransformer>.Instance,
new FakeServiceProvider(),
agentOptionsProvider);
@@ -74,12 +73,11 @@ public sealed class DurableAgentFunctionMetadataTransformerTests
public void Transform_AddsTriggers_ForMultipleAgents()
{
// Arrange
Dictionary<string, Func<IServiceProvider, AIAgent>> agents = new()
{
{ "agentA", _ => new TestAgent("testAgentA", "Test agent description") },
{ "agentB", _ => new TestAgent("testAgentB", "Test agent description") },
{ "agentC", _ => new TestAgent("testAgentC", "Test agent description") }
};
string[] agentNames = ["agentA", "agentB", "agentC"];
DurableAgentsOptions durableAgentsOptions = new();
durableAgentsOptions.AddAIAgentFactory("agentA", _ => new TestAgent("testAgentA", "Test agent description"));
durableAgentsOptions.AddAIAgentFactory("agentB", _ => new TestAgent("testAgentB", "Test agent description"));
durableAgentsOptions.AddAIAgentFactory("agentC", _ => new TestAgent("testAgentC", "Test agent description"));
// Helper to create options with configurable triggers
static FunctionsAgentOptions CreateFunctionsAgentOptions(bool httpEnabled, bool mcpEnabled)
@@ -103,7 +101,7 @@ public sealed class DurableAgentFunctionMetadataTransformerTests
IFunctionsAgentOptionsProvider agentOptionsProvider = new FakeOptionsProvider(functionsAgentOptions);
DurableAgentFunctionMetadataTransformer transformer = new(
agents,
durableAgentsOptions,
NullLogger<DurableAgentFunctionMetadataTransformer>.Instance,
new FakeServiceProvider(),
agentOptionsProvider);
@@ -115,9 +113,9 @@ public sealed class DurableAgentFunctionMetadataTransformerTests
transformer.Transform(metadataList);
// Assert
Assert.Equal(InitialMetadataEntryCount + (agents.Count * 2) + 2, metadataList.Count);
Assert.Equal(InitialMetadataEntryCount + (agentNames.Length * 2) + 2, metadataList.Count);
foreach (string agentName in agents.Keys)
foreach (string agentName in agentNames)
{
// The agent's entity trigger name is prefixed with "dafx-"
DefaultFunctionMetadata entityMeta =