This commit is contained in:
Shyju Krishnankutty
2026-01-21 10:49:10 -08:00
Unverified
parent 00650f2525
commit 588e0bc0b2
13 changed files with 258 additions and 43 deletions
@@ -20,22 +20,31 @@ Func<string, string> orderParserFunc = input =>
};
var orderParserExecutor = orderParserFunc.BindAsExecutor("OrderParser");
Func<string, string> cancelOrderFunc = input => $"Cancelled order:{input}";
var orderArchiver = orderParserFunc.BindAsExecutor("OrderArchiver");
OrderLookupExecutor orderLookupExecutor = new();
OrderEnricherExecutor orderEnricherExeecutor = new();
PaymentProcessorExecutor paymentProcessorExecutor = new();
Workflow workflow = new WorkflowBuilder(orderParserExecutor)
Workflow processOrder = 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)
.WithOutputFrom(paymentProcessorExecutor)
.Build();
Workflow cancelOrder = new WorkflowBuilder(orderParserExecutor)
.WithName("CancelOrder")
.WithDescription("Cancel an order")
.AddEdge(orderParserExecutor, orderLookupExecutor)
.AddEdge(orderLookupExecutor, orderArchiver)
.Build();
var host = FunctionsApplication.CreateBuilder(args)
.ConfigureFunctionsWebApplication()
.ConfigureDurableOptions(options => options.Workflows.AddWorkflow(workflow))
.ConfigureDurableOptions(options => options.Workflows.AddWorkflow([processOrder, cancelOrder]))
.Build();
host.Run();
@@ -8,7 +8,7 @@ Content-Type: text/plain
QWERTY80853
### Look up a short order id
POST {{authority}}/api/workflows/FulfillOrder/run
POST {{authority}}/api/workflows/CancelOrder/run
Content-Type: text/plain
12345
@@ -43,7 +43,8 @@ var workflow = new WorkflowBuilder(startExecutor)
FunctionsApplication.CreateBuilder(args)
.ConfigureFunctionsWebApplication()
.ConfigureDurableOptions(options =>
{
// Configure workflows
options.Workflows.AddWorkflow(workflow);
}).Build().Run();
{
// Configure workflows
options.Workflows.AddWorkflow(workflow, enableMcpToolTrigger: true);
})
.Build().Run();
@@ -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");
@@ -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,40 @@ 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));
}
// 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;
}
@@ -19,12 +19,14 @@ internal static class BuiltInFunctions
{
internal const string HttpPrefix = "http-";
internal const string McpToolPrefix = "mcptool-";
internal const string WorkflowMcpToolPrefix = "mcptool-workflow-";
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);
@@ -220,6 +222,41 @@ 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,
new DurableWorkflowRunRequest { WorkflowName = workflowName, Input = 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>();
}
#pragma warning disable DURTASK001 // Durable analyzer complained
public static Task<DurableWorkflowRunResult> WorkflowRunnerOrchestrationAsync(TaskOrchestrationContext context, DurableWorkflowRunRequest input)
{
@@ -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;
@@ -141,12 +141,17 @@ public static class DurableAgentsOptionsExtensions
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, timeToLive);
s_agentOptions[name] = agentOptions;
return options;
}
@@ -53,16 +53,23 @@ public static class DurableOptionsExtensions
private static void RegisterServices(FunctionsApplicationBuilder builder, DurableOptions options)
{
builder.Services.TryAddSingleton(options);
builder.Services.TryAddSingleton(options.Agents); // backward compatibility. can be removed in future.
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 options.Agents.GetAgentFactories())
foreach (KeyValuePair<string, Func<IServiceProvider, AIAgent>> agentFactory in agentFactories)
{
bool isWorkflowOnly = options.Agents.IsWorkflowOnly(agentFactory.Key);
@@ -25,6 +25,9 @@ internal sealed class DurableWorkflowFunctionMetadataTransformer : IFunctionMeta
{
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);
@@ -38,9 +41,17 @@ internal sealed class DurableWorkflowFunctionMetadataTransformer : IFunctionMeta
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
var executorIds = new HashSet<string> { workflow.Value.StartExecutorId };
HashSet<string> executorIds = new() { workflow.Value.StartExecutorId };
var reflectedEdges = workflow.Value.ReflectEdges();
foreach (var (sourceId, edgeSet) in reflectedEdges)
@@ -64,6 +75,13 @@ internal sealed class DurableWorkflowFunctionMetadataTransformer : IFunctionMeta
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 (WorkflowHelper.IsAgentExecutorType(executorInfo.ExecutorType))
{
@@ -132,19 +150,20 @@ internal sealed class DurableWorkflowFunctionMetadataTransformer : IFunctionMeta
};
}
//private static DefaultFunctionMetadata CreateAgentTrigger(string functionName)
//{
// return new DefaultFunctionMetadata()
// {
// Name = functionName,
// Language = "dotnet-isolated",
// RawBindings =
// [
// """{"name":"encodedEntityRequest","type":"entityTrigger","direction":"In"}""",
// """{"name":"client","type":"durableClient","direction":"In"}"""
// ],
// EntryPoint = BuiltInFunctions.RunAgentEntityFunctionEntryPoint,
// ScriptFile = BuiltInFunctions.ScriptFile,
// };
//}
private static DefaultFunctionMetadata CreateMcpToolTrigger(string workflowName, string? description)
{
return new DefaultFunctionMetadata
{
Name = $"{BuiltInFunctions.WorkflowMcpToolPrefix}{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,
};
}
}
@@ -12,32 +12,50 @@ namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
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,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);
}