diff --git a/dotnet/nuget.config b/dotnet/nuget.config index 76d943ce16..49f96479ca 100644 --- a/dotnet/nuget.config +++ b/dotnet/nuget.config @@ -3,10 +3,14 @@ + + + + \ No newline at end of file diff --git a/dotnet/samples/AzureFunctions/09_Workflow/OrchFunction.cs b/dotnet/samples/AzureFunctions/09_Workflow/OrchFunction.cs deleted file mode 100644 index c20fa550b4..0000000000 --- a/dotnet/samples/AzureFunctions/09_Workflow/OrchFunction.cs +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Azure.Functions.Worker; -using Microsoft.Azure.Functions.Worker.Http; -using Microsoft.DurableTask; -using Microsoft.DurableTask.Client; -using Microsoft.Extensions.Logging; - -namespace SingleAgent; - -public static class OrchFunction -{ - [Function(nameof(OrchFunction))] - public static async Task> RunOrchestratorAsync( - [OrchestrationTrigger] TaskOrchestrationContext context) - { - ILogger logger = context.CreateReplaySafeLogger(nameof(OrchFunction)); - logger.LogInformation("Saying hello."); - var outputs = new List(); - - outputs.Add("Tokyo"); - - return outputs; - } - - [Function("OrchFunction_HttpStart")] - public static async Task HttpStartAsync( - [HttpTrigger(AuthorizationLevel.Anonymous, "get", "post")] HttpRequestData req, - [DurableClient] DurableTaskClient client, - FunctionContext executionContext) - { - // Function input comes from the request content. - string instanceId = await client.ScheduleNewOrchestrationInstanceAsync( - nameof(OrchFunction)); - - // Returns an HTTP 202 response with an instance management payload. - // See https://learn.microsoft.com/azure/azure-functions/durable/durable-functions-http-api#start-orchestration - return await client.CreateCheckStatusResponseAsync(req, instanceId); - } -} diff --git a/dotnet/samples/AzureFunctions/09_Workflow/Program.cs b/dotnet/samples/AzureFunctions/09_Workflow/Program.cs index 87120d9d49..cab0b996bb 100644 --- a/dotnet/samples/AzureFunctions/09_Workflow/Program.cs +++ b/dotnet/samples/AzureFunctions/09_Workflow/Program.cs @@ -41,16 +41,16 @@ builder.AddEdge(agent2, reverse).WithOutputFrom(agent2); var workflow = builder.WithName("MyTestWorkflow").Build(); -// Configure the function app to host the AI agent. -// This will automatically generate HTTP API endpoints for the agent. +// Configure the function app to host AI agents and workflows in a unified way. +// This will automatically generate HTTP API endpoints for agents and workflows. using IHost app = FunctionsApplication .CreateBuilder(args) .ConfigureFunctionsWebApplication() - .ConfigureDurableAgents(options => options.AddAIAgent(agent, timeToLive: TimeSpan.FromHours(1))) - .AddDurableWorkflows(options => + //.ConfigureDurableAgents(op => op.AddAIAgent(agent, timeToLive: TimeSpan.FromHours(1))) + .ConfigureDurableOptions(options => { - // Configure durable workflow options here if needed. - options.AddWorkflow(workflow); + // Configure workflows - agents referenced in workflows are automatically registered! + options.Workflows.AddWorkflow(workflow); }) .Build(); app.Run(); diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentsOptions.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentsOptions.cs index cefcad323a..f08d114121 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentsOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentsOptions.cs @@ -11,7 +11,10 @@ public sealed class DurableAgentsOptions private readonly Dictionary> _agentFactories = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _agentTimeToLive = new(StringComparer.OrdinalIgnoreCase); - internal DurableAgentsOptions() + /// + /// Initializes a new instance of the class. + /// + public DurableAgentsOptions() { } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableWorkflowOptions.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableWorkflowOptions.cs index c3f7b6c75d..ab8bfcd4b1 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableWorkflowOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableWorkflowOptions.cs @@ -10,11 +10,25 @@ namespace Microsoft.Agents.AI.DurableTask; public sealed class DurableWorkflowOptions { private readonly Dictionary _workflows = new(StringComparer.OrdinalIgnoreCase); + private readonly object? _parentOptions; + + /// + /// Initializes a new instance of the class. + /// + /// Optional parent options container for accessing related configuration. + public DurableWorkflowOptions(object? parentOptions = null) + { + this._parentOptions = parentOptions; + } /// /// Adds a workflow to the collection for processing or execution. /// /// The workflow instance to add. Cannot be null. + /// + /// When a workflow is added, any AI agent executors in the workflow will be automatically + /// registered with the DurableAgentsOptions if it was provided during construction. + /// public void AddWorkflow(Workflow workflow) { ArgumentNullException.ThrowIfNull(workflow); @@ -25,6 +39,13 @@ public sealed class DurableWorkflowOptions } this._workflows[workflow.Name] = workflow; + + // Register any agentic executors with DurableAgentsOptions if available through parent + DurableAgentsOptions? agentOptions = this.TryGetAgentOptions(); + if (agentOptions is not null) + { + RegisterAgenticExecutors(workflow, agentOptions); + } } /// @@ -34,4 +55,46 @@ public sealed class DurableWorkflowOptions /// Changes to the underlying workflow collection are immediately visible through this property. Accessing a /// workflow by name that does not exist will result in a KeyNotFoundException. public IReadOnlyDictionary Workflows => this._workflows; + + [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("ReflectionAnalysis", "IL2075", + Justification = "Reflection is used to access Agents property from parent options container for automatic agent registration.")] + private DurableAgentsOptions? TryGetAgentOptions() + { + // Try to extract DurableAgentsOptions from the parent container + // This uses reflection to access the Agents property if available + if (this._parentOptions is null) + { + return null; + } + + // Check if parent has an Agents property (DurableOptions pattern) + System.Reflection.PropertyInfo? agentsProperty = this._parentOptions.GetType() + .GetProperty("Agents", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance); + + if (agentsProperty?.PropertyType == typeof(DurableAgentsOptions)) + { + return agentsProperty.GetValue(this._parentOptions) as DurableAgentsOptions; + } + + // If parent is directly DurableAgentsOptions (for backward compatibility) + return this._parentOptions as DurableAgentsOptions; + } + + private static void RegisterAgenticExecutors(Workflow workflow, DurableAgentsOptions agentOptions) + { + // Use the public EnumerateAgentExecutors method to get all AIAgent instances + foreach (AIAgent agent in workflow.EnumerateAgentExecutors()) + { + try + { + // Register the agent with DurableAgentsOptions + agentOptions.AddAIAgent(agent); + } + catch (ArgumentException) + { + // Agent with this name is already registered, skip it + // This is expected behavior when multiple workflows use the same agent + } + } + } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctionExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctionExecutor.cs index 47c2599499..6733615875 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctionExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctionExecutor.cs @@ -5,7 +5,6 @@ using Microsoft.Azure.Functions.Worker.Context.Features; using Microsoft.Azure.Functions.Worker.Extensions.Mcp; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Azure.Functions.Worker.Invocation; -using Microsoft.DurableTask; using Microsoft.DurableTask.Client; namespace Microsoft.Agents.AI.Hosting.AzureFunctions; @@ -26,17 +25,9 @@ internal sealed class BuiltInFunctionExecutor : IFunctionExecutor IFunctionInputBindingFeature? functionInputBindingFeature = context.Features.Get() ?? throw new InvalidOperationException("Function input binding feature is not available on the current context."); - if (context.FunctionDefinition.EntryPoint == BuiltInFunctions.RunWorkflowOrechstrtationFunctionEntryPoint) + if (context.FunctionDefinition.EntryPoint == BuiltInFunctions.InvokeWorkflowActivityFunctionEntryPoint) { - var triggerBinding = context.FunctionDefinition.InputBindings.Values.FirstOrDefault(b => b.Type == "orchestrationTrigger"); - var taskOrechstrationContextBinding = context.BindInputAsync(triggerBinding!); - - if (taskOrechstrationContextBinding.IsCompletedSuccessfully) - { - var t = taskOrechstrationContextBinding.Result.Value; - context.GetInvocationResult().Value = await BuiltInFunctions.RunWorkflowOrchestratorAsync("todo", context); - } - + context.GetInvocationResult().Value = await BuiltInFunctions.InvokeWorkflowActivityAsync("aa", context); return; } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs index 275be12f86..e146425adf 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs @@ -36,25 +36,17 @@ internal static class BuiltInFunctions [ActivityTrigger] string input, FunctionContext functionContext) { - return Task.FromResult($"Hello from activity with input: {input}"); + string activityFunctionName = functionContext.FunctionDefinition.Name; + + DurableWorkflowRunner runner = functionContext.InstanceServices.GetRequiredService(); + return runner.ExecuteActivityAsync(activityFunctionName, input, functionContext); } - //[Function("my-Orchestration")] - //public static async Task> RunOrchestrator1Async( - //[OrchestrationTrigger] TaskOrchestrationContext context) - //{ - // ILogger logger = context.CreateReplaySafeLogger(nameof(Function)); - // logger.LogInformation("Saying hello."); - - // // returns ["Hello Tokyo!", "Hello Seattle!", "Hello London!"] - // return new List(); - //} - public static async Task> RunWorkflowOrchestratorAsync(string taskOrchestrationContext, FunctionContext functionsContext) { var logger = functionsContext.GetLogger("BuiltInFunctions"); var outputs = new List(); - const string WorkflowName = "MyTestWorkflow"; // to do: get from TaskOrchestrtionContext + const string WorkflowName = "MyTestWorkflow"; // to do: get from TaskOrchestrationContext if (logger.IsEnabled(LogLevel.Information)) { logger.LogInformation("Orchestrator {WorkflowName} is executing. Input: {Input}", WorkflowName, taskOrchestrationContext); @@ -95,10 +87,10 @@ internal static class BuiltInFunctions FunctionContext context) { // to do: Retrieve the workflow and execute it. - var workflowName = context.FunctionDefinition.Name.Replace("http", "dafx"); - + var workflowName = context.FunctionDefinition.Name.Replace("http-", ""); + var inputMessage = await req.ReadAsStringAsync(); //string instanceId = await client.ScheduleNewOrchestrationInstanceAsync("dafx-MyTestWorkflow"); - string instanceId = await client.ScheduleNewOrchestrationInstanceAsync("dafx-MyTestWorkflow"); //OrchFunction"); // dafx-MyTestWorkflow"); + string instanceId = await client.ScheduleNewOrchestrationInstanceAsync("WorkflowRunnerOrchestration", new DuableWorkflowRunRequest { WorkflowName = workflowName, Input = inputMessage! }); //OrchFunction"); // dafx-MyTestWorkflow"); HttpResponseData response = req.CreateResponse(HttpStatusCode.OK); await response.WriteStringAsync($"InvokeWorkflowOrechstrtationAsync is invoked for {workflowName}.{instanceId}"); @@ -240,6 +232,26 @@ internal static class BuiltInFunctions return agentResponse.Text; } +#pragma warning disable DURTASK001 // Durable analyzer complained + public static Task> WorkflowRunnerOrchestrationAsync(TaskOrchestrationContext context, DuableWorkflowRunRequest input) + { + ArgumentNullException.ThrowIfNull(context); + + ILogger logger = context.CreateReplaySafeLogger("BuiltInFunctions"); + logger.LogInformation("WorkflowRunnerOrchestrationAsync function called."); + + FunctionContext? functionContext = context.GetFunctionContext(); + if (functionContext == null) + { + throw new InvalidOperationException("FunctionContext is not available in the orchestration context."); + } + + var workFlowName = input.WorkflowName; + + return Task.FromResult(new List() { workFlowName }); + } +#pragma warning restore DURTASK001 + /// /// Creates an error response with the specified status code and error message. /// diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DuableWorkflowRunRequest.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DuableWorkflowRunRequest.cs new file mode 100644 index 0000000000..325fed1af5 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DuableWorkflowRunRequest.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Hosting.AzureFunctions; + +/// +/// Represents a request to run a workflow in the Duable system. +/// +public sealed class DuableWorkflowRunRequest +{ + /// + /// Gets or sets the name of the workflow. + /// + [JsonPropertyName("workflowName")] + public string WorkflowName { get; set; } = string.Empty; + + /// + /// Gets or sets the input string to be processed or analyzed. + /// + [JsonPropertyName("input")] + public string Input { get; set; } = string.Empty; +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableOptions.cs new file mode 100644 index 0000000000..71f6b741ff --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableOptions.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.DurableTask; + +namespace Microsoft.Agents.AI.Hosting.AzureFunctions; + +/// +/// Provides configuration options for durable features in Azure Functions. +/// +public sealed class DurableOptions +{ + /// + /// Gets the configuration options for durable agents. + /// + public DurableAgentsOptions Agents { get; } = new(); + + /// + /// Gets the configuration options for durable workflows. + /// + public DurableWorkflowOptions Workflows { get; } + + /// + /// Initializes a new instance of the class. + /// + internal DurableOptions() + { + this.Workflows = new DurableWorkflowOptions(this); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableOptionsExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableOptionsExtensions.cs new file mode 100644 index 0000000000..4a4b9b157c --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableOptionsExtensions.cs @@ -0,0 +1,97 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.DurableTask; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Builder; +using Microsoft.Azure.Functions.Worker.Core.FunctionMetadata; +using Microsoft.DurableTask; +using Microsoft.DurableTask.Worker; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; + +namespace Microsoft.Agents.AI.Hosting.AzureFunctions; + +/// +/// Extension methods for configuring durable options (agents and workflows). +/// +public static class DurableOptionsExtensions +{ + /// + /// Configures durable agents and workflows in a unified way. + /// + /// The Functions application builder. + /// A delegate to configure the durable options. + /// The Functions application builder for method chaining. + /// + /// This method provides a unified configuration point for both durable agents and workflows. + /// Agents configured here will be automatically registered when referenced in workflows. + /// + public static FunctionsApplicationBuilder ConfigureDurableOptions( + this FunctionsApplicationBuilder builder, + Action configure) + { + ArgumentNullException.ThrowIfNull(configure); + + DurableOptions options = new(); + configure(options); + + // Register the unified DurableOptions for components that need both agents and workflows + builder.Services.AddSingleton(options); + + // Register AgentsOption for backward compatibility. Can be removed if needed, after syncing with team. + builder.Services.AddSingleton(options.Agents); + + // Configure agents using the existing infrastructure + builder.Services.ConfigureDurableAgents(agentOpts => + { + // Copy agent registrations from the unified options to the service-level options + foreach (KeyValuePair> agentFactory in options.Agents.GetAgentFactories()) + { + agentOpts.AddAIAgentFactory( + agentFactory.Key, + agentFactory.Value, + options.Agents.GetTimeToLive(agentFactory.Key)); + } + + // Copy TTL settings + agentOpts.DefaultTimeToLive = options.Agents.DefaultTimeToLive; + agentOpts.MinimumTimeToLiveSignalDelay = options.Agents.MinimumTimeToLiveSignalDelay; + }); + + builder.Services.TryAddSingleton(_ => + new DefaultFunctionsAgentOptionsProvider(DurableAgentsOptionsExtensions.GetAgentOptionsSnapshot())); + + builder.Services.AddSingleton(); + + // Handling of built-in function execution for Agent HTTP, MCP tool, or Entity invocations. + builder.UseWhen(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.RunWorkflowOrechstrtationFunctionEntryPoint, StringComparison.Ordinal) || + string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunWorkflowOrechstrtationHttpFunctionEntryPoint, StringComparison.Ordinal) || + string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.InvokeWorkflowActivityFunctionEntryPoint, StringComparison.Ordinal) || + string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunAgentEntityFunctionEntryPoint, StringComparison.Ordinal)); + builder.Services.AddSingleton(); + + builder.Services.AddSingleton(); + + builder.ConfigureDurableWorker().AddTasks(t => t.AddOrchestratorFunc>( + "WorkflowRunnerOrchestration", + async (tc, inputBindingData) => + { + FunctionContext? functionContext = tc.GetFunctionContext(); + if (functionContext == null) + { + throw new InvalidOperationException("FunctionContext is not available in the orchestration context."); + } + + DurableWorkflowRunner runner = functionContext.InstanceServices.GetRequiredService(); + return await runner.RunWorkflowOrchestrationAsync(tc, inputBindingData).ConfigureAwait(false); + })); + + builder.Services.AddSingleton(); + + return builder; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableWorkflowFunctionMetadataTransformer.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableWorkflowFunctionMetadataTransformer.cs index 3b4e541eec..5d02405ea5 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableWorkflowFunctionMetadataTransformer.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableWorkflowFunctionMetadataTransformer.cs @@ -12,10 +12,11 @@ internal sealed class DurableWorkflowFunctionMetadataTransformer : IFunctionMeta private readonly ILogger _logger; private readonly DurableWorkflowOptions _options; - public DurableWorkflowFunctionMetadataTransformer(ILogger logger, DurableWorkflowOptions durableWorkflowOptions) + public DurableWorkflowFunctionMetadataTransformer(ILogger logger, DurableOptions durableOptions) { this._logger = logger ?? throw new ArgumentNullException(nameof(logger)); - this._options = durableWorkflowOptions ?? throw new ArgumentNullException(nameof(durableWorkflowOptions)); + ArgumentNullException.ThrowIfNull(durableOptions); + this._options = durableOptions.Workflows; } public string Name => nameof(DurableWorkflowFunctionMetadataTransformer); @@ -28,7 +29,7 @@ internal sealed class DurableWorkflowFunctionMetadataTransformer : IFunctionMeta { this._logger.LogAddingWorkflowFunction(workflow.Key); - original.Add(CreateOrchestrationTrigger(workflow.Key)); + //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); @@ -57,7 +58,8 @@ internal sealed class DurableWorkflowFunctionMetadataTransformer : IFunctionMeta { if (executorInfos.TryGetValue(executorId, out ExecutorInfo? executorInfo)) { - string functionName = $"{AgentSessionId.ToEntityName(workflow.Key)}_{executorId}"; + // string functionName = $"{AgentSessionId.ToEntityName(workflow.Key)}-{executorId}"; //.Split("_")[0] + string functionName = $"{AgentSessionId.ToEntityName(workflow.Key)}-{executorId.Split("_")[0]}"; //.Split("_")[0] // Check if the executor type is an agent-related type if (IsAgentExecutorType(executorInfo.ExecutorType)) @@ -106,22 +108,22 @@ internal sealed class DurableWorkflowFunctionMetadataTransformer : IFunctionMeta }; } - 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"}""", + //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, - }; - } + // ], + // EntryPoint = BuiltInFunctions.RunWorkflowOrechstrtationFunctionEntryPoint, + // ScriptFile = BuiltInFunctions.ScriptFile, + // }; + //} private static DefaultFunctionMetadata CreateActivityTrigger(string functionName) { diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableWorkflowRunner.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableWorkflowRunner.cs index 5a8c80c33d..43e4483ef7 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableWorkflowRunner.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableWorkflowRunner.cs @@ -2,6 +2,8 @@ using Microsoft.Agents.AI.DurableTask; using Microsoft.Agents.AI.Workflows; +using Microsoft.Agents.AI.Workflows.Checkpointing; +using Microsoft.Azure.Functions.Worker; using Microsoft.DurableTask; using Microsoft.Extensions.Logging; @@ -11,40 +13,149 @@ internal sealed class DurableWorkflowRunner { private readonly DurableWorkflowOptions _options; private readonly ILogger _logger; - public DurableWorkflowRunner(ILogger logger, DurableWorkflowOptions durableWorkflowOptions) + + public DurableWorkflowRunner(ILogger logger, DurableOptions durableOptions) { this._logger = logger; - this._options = durableWorkflowOptions; + ArgumentNullException.ThrowIfNull(durableOptions); + this._options = durableOptions.Workflows; } - internal async Task RunAsync( - TaskOrchestrationContext taskOrchestrationContext, - string workflowName, - object? initialInput = null, - CancellationToken cancellationToken = default) + internal async Task> RunWorkflowOrchestrationAsync(TaskOrchestrationContext context, DuableWorkflowRunRequest input) { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(input); + + string workflowName = input.WorkflowName; + this._logger.LogAttemptingToRunWorkflow(workflowName); - if (this._options.Workflows.TryGetValue(workflowName, out Workflow? wf)) - { - this._logger.LogRunningWorkflow(wf.Name); - - await this.RunExecutorsInWorkFlowAsync(taskOrchestrationContext, wf, initialInput, cancellationToken).ConfigureAwait(false); - } - else + if (!this._options.Workflows.TryGetValue(workflowName, out Workflow? wf)) { throw new InvalidOperationException($"Workflow '{workflowName}' not found."); } + + this._logger.LogRunningWorkflow(wf.Name); + + var result = await this.RunExecutorsInWorkFlowAsync(context, wf, input.Input).ConfigureAwait(false); + + return [result]; } - private Task RunExecutorsInWorkFlowAsync( + private async Task RunExecutorsInWorkFlowAsync( TaskOrchestrationContext taskOrchestrationContext, - Workflow wf, - object? initialInput, - CancellationToken cancellationToken) + Workflow workflow, + string initialInput) { - // Extract edeges and executors from the workflow and execute them in order/based on the pattern as Durable entities. + List executorResult = []; - return Task.CompletedTask; + if (this._logger.IsEnabled(LogLevel.Information)) + { + foreach (WorkflowExecutorInfo executorInfo in WorkflowHelper.GetExecutorsFromWorkflowInOrder(workflow)) + { + string triggerName = this.BuildTriggerName(workflow.Name!, executorInfo.ExecutorId); + this._logger.LogInformation( + " Scheduling executor '{ExecutorId}' (IsAgentic: {IsAgentic}) with trigger name '{TriggerName}'", + executorInfo.ExecutorId, + executorInfo.IsAgenticExecutor, + triggerName); + + string input = executorResult.Count == 0 ? initialInput : executorResult.Last()!; + if (!executorInfo.IsAgenticExecutor) + { + var result = await taskOrchestrationContext.CallActivityAsync(triggerName, input); + executorResult.Add(result); + } + else + { + string AgentName = this.GetAgentNameFromExecutorId(workflow.Name!, executorInfo.ExecutorId); + this._logger.LogInformation( + " Invoking agentic executor '{ExecutorId}'", + AgentName); + DurableAIAgent agent = taskOrchestrationContext.GetAgent(AgentName); + if (agent != null) + { + AgentThread destinationThread = agent.GetNewThread(); + var agentResponse = await agent.RunAsync(input, destinationThread); + executorResult.Add(agentResponse.Text); + this._logger.LogInformation( + "Agentic executor '{ExecutorId}' completed with response: {AgentResponse}", + AgentName, + agentResponse); + } + } + } + + // return final result + return executorResult.Last(); + } + + return string.Empty; + } + + private string GetAgentNameFromExecutorId(string workflowName, string executorId) + { + // Example: "InspirationBot_edaac621050849efb1a62805fa03d3f8" + var parts = executorId.Split('_'); + return parts.Length > 0 ? parts[0] : executorId; + } + + private string BuildTriggerName(string workflowName, string executorName) + { + return $"dafx-{workflowName}-{executorName}"; + } + + internal async Task ExecuteActivityAsync(string activityFunctionName, string input, FunctionContext functionContext) + { + ArgumentNullException.ThrowIfNull(activityFunctionName); + ArgumentNullException.ThrowIfNull(input); + ArgumentNullException.ThrowIfNull(functionContext); + + // Parse the activity function name to extract workflow name and executor name + // Format: "dafx-{workflowName}-{executorName}" + if (!activityFunctionName.StartsWith("dafx-", StringComparison.Ordinal)) + { + throw new InvalidOperationException($"Activity function name '{activityFunctionName}' does not start with 'dafx-' prefix."); + } + + string nameWithoutPrefix = activityFunctionName["dafx-".Length..]; + string[] parts = nameWithoutPrefix.Split('-', 2); + + if (parts.Length != 2) + { + throw new InvalidOperationException($"Activity function name '{activityFunctionName}' is not in the expected format 'dafx-{{workflowName}}-{{executorName}}'."); + } + + string workflowName = parts[0]; + string executorName = parts[1]; + + this._logger.LogAttemptingToExecuteActivity(workflowName, executorName); + + // Get the workflow + if (!this._options.Workflows.TryGetValue(workflowName, out Workflow? workflow)) + { + throw new InvalidOperationException($"Workflow '{workflowName}' not found."); + } + + // Get the executor info + Dictionary executorInfos = workflow.ReflectExecutors(); + + // Find the executor by matching the executor name (which may have a GUID suffix) + KeyValuePair executorPair = executorInfos.FirstOrDefault(e => + e.Key.StartsWith(executorName + "_", StringComparison.Ordinal) || + string.Equals(e.Key, executorName, StringComparison.Ordinal)); + + if (executorPair.Key == null) + { + throw new InvalidOperationException($"Executor '{executorName}' not found in workflow '{workflowName}'."); + } + + this._logger.LogExecutingActivity(executorPair.Key, executorPair.Value.ExecutorType.TypeName); + + const string result = "Many types are internal."; + + this._logger.LogActivityExecuted(executorPair.Key, result); + + return result; } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableWorkflowRunnerLogs.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableWorkflowRunnerLogs.cs index fbec81f77d..037d76f170 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableWorkflowRunnerLogs.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableWorkflowRunnerLogs.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.Logging; @@ -20,4 +20,19 @@ internal static partial class DurableWorkflowRunnerLogs Level = LogLevel.Information, Message = "Running workflow: {WorkflowName}")] public static partial void LogRunningWorkflow(this ILogger logger, string? workflowName); + + [LoggerMessage( + Level = LogLevel.Information, + Message = "Attempting to execute activity in workflow '{WorkflowName}' for executor '{ExecutorName}'")] + public static partial void LogAttemptingToExecuteActivity(this ILogger logger, string workflowName, string executorName); + + [LoggerMessage( + Level = LogLevel.Information, + Message = "Executing activity for executor '{ExecutorId}' of type '{ExecutorType}'")] + public static partial void LogExecutingActivity(this ILogger logger, string executorId, string executorType); + + [LoggerMessage( + Level = LogLevel.Information, + Message = "Activity executed for executor '{ExecutorId}' with result: {Result}")] + public static partial void LogActivityExecuted(this ILogger logger, string executorId, string result); } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsApplicationBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsApplicationBuilderExtensions.cs index fbae490782..a6458873df 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsApplicationBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsApplicationBuilderExtensions.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using Microsoft.Agents.AI.DurableTask; using Microsoft.Azure.Functions.Worker.Builder; @@ -14,23 +14,6 @@ namespace Microsoft.Agents.AI.Hosting.AzureFunctions; /// public static class FunctionsApplicationBuilderExtensions { - /// - /// Adds support for durable workflows to the specified Functions application builder. - /// - /// The Functions application builder to configure with durable workflow capabilities. - /// - /// The same instance of to allow for method chaining. - public static FunctionsApplicationBuilder AddDurableWorkflows(this FunctionsApplicationBuilder builder, Action configure) - { - var options = new DurableWorkflowOptions(); - configure(options); - builder.Services.AddSingleton(options); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - - return builder; - } - /// /// Configures the application to use durable agents with a builder pattern. /// @@ -57,6 +40,7 @@ public static class FunctionsApplicationBuilderExtensions string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunAgentMcpToolFunctionEntryPoint, StringComparison.Ordinal) || string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunWorkflowOrechstrtationFunctionEntryPoint, StringComparison.Ordinal) || string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunWorkflowOrechstrtationHttpFunctionEntryPoint, StringComparison.Ordinal) || + string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.InvokeWorkflowActivityFunctionEntryPoint, StringComparison.Ordinal) || string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunAgentEntityFunctionEntryPoint, StringComparison.Ordinal)); builder.Services.AddSingleton(); diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Microsoft.Agents.AI.Hosting.AzureFunctions.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Microsoft.Agents.AI.Hosting.AzureFunctions.csproj index ce67c9621e..ad044b6a71 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Microsoft.Agents.AI.Hosting.AzureFunctions.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Microsoft.Agents.AI.Hosting.AzureFunctions.csproj @@ -4,7 +4,7 @@ $(TargetFrameworksCore) enable - $(NoWarn);CA2007 + $(NoWarn);CA2007;AD0001 diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/WorkflowHelper.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/WorkflowHelper.cs new file mode 100644 index 0000000000..0fa3e946af --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/WorkflowHelper.cs @@ -0,0 +1,139 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.Workflows; +using Microsoft.Agents.AI.Workflows.Checkpointing; + +namespace Microsoft.Agents.AI.Hosting.AzureFunctions; + +/// +/// Represents an executor in the workflow with its metadata. +/// +/// The unique identifier of the executor. +/// Indicates whether this executor is an agentic executor. +internal sealed record WorkflowExecutorInfo(string ExecutorId, bool IsAgenticExecutor); + +internal static class WorkflowHelper +{ + /// + /// Accepts a workflow instance and returns a list of executors with metadata in the order they should be executed. + /// + /// The workflow instance to analyze. + /// A list of executor information in topological order (execution order). + public static List GetExecutorsFromWorkflowInOrder(Workflow workflow) + { + ArgumentNullException.ThrowIfNull(workflow); + + Dictionary executors = workflow.ReflectExecutors(); + Dictionary> edges = workflow.ReflectEdges(); + + // Build adjacency list and in-degree map + Dictionary> adjacencyList = new(); + Dictionary inDegree = new(); + + // Initialize all executors with in-degree 0 + foreach (string executorId in executors.Keys) + { + adjacencyList[executorId] = new List(); + inDegree[executorId] = 0; + } + + // Build the graph from edges + foreach (KeyValuePair> edgeGroup in edges) + { + string sourceId = edgeGroup.Key; + + foreach (EdgeInfo edge in edgeGroup.Value) + { + // For each sink (target) in this edge + foreach (string sinkId in edge.Connection.SinkIds) + { + // Add edge from source to sink + adjacencyList[sourceId].Add(sinkId); + + // Increment in-degree of the sink + if (inDegree.TryGetValue(sinkId, out int currentDegree)) + { + inDegree[sinkId] = currentDegree + 1; + } + } + } + } + + // Perform topological sort using Kahn's algorithm + List orderedExecutorIds = new(); + Queue queue = new(); + + // Start with the workflow's starting executor + queue.Enqueue(workflow.StartExecutorId); + + // Also add any other executors with in-degree 0 (shouldn't be any if workflow is well-formed) + foreach (KeyValuePair kvp in inDegree) + { + if (kvp.Value == 0 && kvp.Key != workflow.StartExecutorId) + { + queue.Enqueue(kvp.Key); + } + } + + while (queue.Count > 0) + { + string current = queue.Dequeue(); + orderedExecutorIds.Add(current); + + // For each neighbor of the current executor + foreach (string neighbor in adjacencyList[current]) + { + inDegree[neighbor]--; + + // If in-degree becomes 0, add to queue + if (inDegree[neighbor] == 0) + { + queue.Enqueue(neighbor); + } + } + } + + // If result doesn't contain all executors, there might be a cycle or disconnected components + if (orderedExecutorIds.Count != executors.Count) + { + // Add any remaining executors that weren't reached + foreach (string executorId in executors.Keys) + { + if (!orderedExecutorIds.Contains(executorId)) + { + orderedExecutorIds.Add(executorId); + } + } + } + + // Convert to WorkflowExecutorInfo with agentic executor detection + List result = new(); + foreach (string executorId in orderedExecutorIds) + { + if (executors.TryGetValue(executorId, out ExecutorInfo? executorInfo)) + { + bool isAgentic = IsAgentExecutorType(executorInfo.ExecutorType); + result.Add(new WorkflowExecutorInfo(executorId, isAgentic)); + } + } + + return result; + } + + /// + /// Determines whether the specified executor type is an agentic executor. + /// + /// The executor type to check. + /// true if the executor is an agentic executor; otherwise, false. + private static bool IsAgentExecutorType(TypeId 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.TypeName; + string assemblyName = executorType.AssemblyName; + + return typeName.Contains("AIAgentHostExecutor", StringComparison.OrdinalIgnoreCase) && + assemblyName.Contains("Microsoft.Agents.AI", StringComparison.OrdinalIgnoreCase); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Workflow.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Workflow.cs index e41f2a642a..47009f8d9d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Workflow.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Workflow.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System; using System.Collections.Generic; @@ -204,4 +204,24 @@ public class Workflow .ConfigureAwait(false); return startExecutor.DescribeProtocol(); } + + /// + /// Enumerates all AIAgent instances that are directly bound in this workflow. + /// + /// + /// This method only returns agents that are directly bound as executor values. + /// It does not include agents created by factory functions, as those require + /// an IServiceProvider to instantiate. + /// + /// An enumerable collection of AIAgent instances found in the workflow. + public IEnumerable EnumerateAgentExecutors() + { + foreach (ExecutorBinding binding in this.ExecutorBindings.Values) + { + if (binding.RawValue is AIAgent agent) + { + yield return agent; + } + } + } }