// Copyright (c) Microsoft. All rights reserved. using Microsoft.Agents.AI.DurableTask; using Microsoft.Agents.AI.DurableTask.Workflows; using Microsoft.Agents.AI.Workflows; using Microsoft.Azure.Functions.Worker.Core.FunctionMetadata; using Microsoft.Extensions.Logging; namespace Microsoft.Agents.AI.Hosting.AzureFunctions; /// /// Transforms function metadata by dynamically registering Azure Functions triggers /// for each configured durable workflow and its executors. /// /// /// For each workflow, this transformer registers: /// /// An HTTP trigger function to start the workflow orchestration via HTTP. /// An orchestration trigger function to run the workflow orchestration. /// An activity trigger function for each non-agent executor in the workflow. /// An entity trigger function for each AI agent executor in the workflow. /// /// When multiple workflows share the same executor, the corresponding function is registered only once. /// internal sealed class DurableWorkflowsFunctionMetadataTransformer : IFunctionMetadataTransformer { private readonly ILogger _logger; private readonly FunctionsDurableOptions _options; /// /// Initializes a new instance of the class. /// /// The logger instance for diagnostic output. /// The durable options containing workflow configurations. public DurableWorkflowsFunctionMetadataTransformer( ILogger logger, FunctionsDurableOptions durableOptions) { this._logger = logger ?? throw new ArgumentNullException(nameof(logger)); ArgumentNullException.ThrowIfNull(durableOptions); this._options = durableOptions; } /// public string Name => nameof(DurableWorkflowsFunctionMetadataTransformer); /// public void Transform(IList original) { int initialCount = original.Count; this._logger.LogTransformingFunctionMetadata(initialCount); // Track registered function names to avoid duplicates when workflows share executors. HashSet registeredFunctions = []; DurableWorkflowOptions workflowOptions = this._options.Workflows; foreach (var workflow in workflowOptions.Workflows) { string httpFunctionName = $"{BuiltInFunctions.HttpPrefix}{workflow.Key}"; if (this._logger.IsEnabled(LogLevel.Information)) { this._logger.LogInformation("Registering durable workflow functions for workflow '{WorkflowKey}' with HTTP trigger function name '{HttpFunctionName}'", workflow.Key, httpFunctionName); } // Register an orchestration function for the workflow. string orchestrationFunctionName = WorkflowNamingHelper.ToOrchestrationFunctionName(workflow.Key); if (registeredFunctions.Add(orchestrationFunctionName)) { this._logger.LogRegisteringWorkflowTrigger(workflow.Key, orchestrationFunctionName, "orchestration"); original.Add(FunctionMetadataFactory.CreateOrchestrationTrigger( orchestrationFunctionName, BuiltInFunctions.RunWorkflowOrchestrationFunctionEntryPoint)); } // Register an HTTP trigger so users can start this workflow via HTTP. if (registeredFunctions.Add(httpFunctionName)) { this._logger.LogRegisteringWorkflowTrigger(workflow.Key, httpFunctionName, "http"); original.Add(FunctionMetadataFactory.CreateHttpTrigger( workflow.Key, $"workflows/{workflow.Key}/run", BuiltInFunctions.RunWorkflowOrchestrationHttpFunctionEntryPoint)); } // Register a status endpoint if opted in via AddWorkflow(exposeStatusEndpoint: true). if (this._options.IsStatusEndpointEnabled(workflow.Key)) { string statusFunctionName = $"{BuiltInFunctions.HttpPrefix}{workflow.Key}-status"; if (registeredFunctions.Add(statusFunctionName)) { this._logger.LogRegisteringWorkflowTrigger(workflow.Key, statusFunctionName, "http-status"); original.Add(FunctionMetadataFactory.CreateHttpTrigger( $"{workflow.Key}-status", $"workflows/{workflow.Key}/status/{{runId}}", BuiltInFunctions.GetWorkflowStatusHttpFunctionEntryPoint, methods: "\"get\"")); } } // Register a respond endpoint when the workflow contains RequestPort nodes. bool hasRequestPorts = workflow.Value.ReflectExecutors().Values.Any(b => b is RequestPortBinding); if (hasRequestPorts) { string respondFunctionName = $"{BuiltInFunctions.HttpPrefix}{workflow.Key}-respond"; if (registeredFunctions.Add(respondFunctionName)) { this._logger.LogRegisteringWorkflowTrigger(workflow.Key, respondFunctionName, "http-respond"); original.Add(FunctionMetadataFactory.CreateHttpTrigger( $"{workflow.Key}-respond", $"workflows/{workflow.Key}/respond/{{runId}}", BuiltInFunctions.RespondToWorkflowHttpFunctionEntryPoint)); } } // Register an MCP tool trigger if opted in via AddWorkflow(exposeMcpToolTrigger: true). if (this._options.IsMcpToolTriggerEnabled(workflow.Key)) { string mcpToolFunctionName = $"{BuiltInFunctions.McpToolPrefix}{workflow.Key}"; if (registeredFunctions.Add(mcpToolFunctionName)) { this._logger.LogRegisteringWorkflowTrigger(workflow.Key, mcpToolFunctionName, "mcpTool"); original.Add(FunctionMetadataFactory.CreateWorkflowMcpToolTrigger(workflow.Key, workflow.Value.Description)); } } // Register activity or entity functions for each executor in the workflow. // ReflectExecutors() returns all executors across the graph; no need to manually traverse edges. foreach (KeyValuePair entry in workflow.Value.ReflectExecutors()) { // Sub-workflow and RequestPort bindings use specialized dispatch, not activities. if (entry.Value is SubworkflowBinding or RequestPortBinding) { continue; } string executorName = WorkflowNamingHelper.GetExecutorName(entry.Key); // AI agent executors are backed by durable entities; other executors use activity triggers. if (entry.Value is AIAgentBinding) { string entityName = AgentSessionId.ToEntityName(executorName); if (registeredFunctions.Add(entityName)) { this._logger.LogRegisteringWorkflowTrigger(workflow.Key, entityName, "entity"); original.Add(FunctionMetadataFactory.CreateEntityTrigger(executorName)); } } else { string functionName = WorkflowNamingHelper.ToOrchestrationFunctionName(executorName); if (registeredFunctions.Add(functionName)) { this._logger.LogRegisteringWorkflowTrigger(workflow.Key, functionName, "activity"); original.Add(FunctionMetadataFactory.CreateActivityTrigger(functionName)); } } } } this._logger.LogTransformationComplete(original.Count - initialCount, original.Count); } }