diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 8b1b00fd2b..bed8bebe96 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -34,6 +34,7 @@ + diff --git a/dotnet/samples/AzureFunctions/01_SingleAgent/01_SingleAgent.csproj b/dotnet/samples/AzureFunctions/01_SingleAgent/01_SingleAgent.csproj index 99f78cc1ab..eedb524a38 100644 --- a/dotnet/samples/AzureFunctions/01_SingleAgent/01_SingleAgent.csproj +++ b/dotnet/samples/AzureFunctions/01_SingleAgent/01_SingleAgent.csproj @@ -6,8 +6,8 @@ enable enable - SingleAgent - SingleAgent + Workflow + Workflow diff --git a/dotnet/samples/AzureFunctions/01_SingleAgent/demo.http b/dotnet/samples/AzureFunctions/01_SingleAgent/demo.http index 3b741adf31..aaf939c6cd 100644 --- a/dotnet/samples/AzureFunctions/01_SingleAgent/demo.http +++ b/dotnet/samples/AzureFunctions/01_SingleAgent/demo.http @@ -2,7 +2,7 @@ @authority=http://localhost:7071 ### Prompt the agent -POST {{authority}}/api/agents/Joker/run +POST {{authority}}/api/workflows/MyTestWorkflow/run Content-Type: text/plain -Tell me a joke about a pirate. +Hello world diff --git a/dotnet/samples/AzureFunctions/09_Workflow/09_Workflow.csproj b/dotnet/samples/AzureFunctions/09_Workflow/09_Workflow.csproj new file mode 100644 index 0000000000..ec1dca7683 --- /dev/null +++ b/dotnet/samples/AzureFunctions/09_Workflow/09_Workflow.csproj @@ -0,0 +1,44 @@ + + + net10.0 + v4 + Exe + enable + enable + + SingleAgent + SingleAgent + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dotnet/samples/AzureFunctions/09_Workflow/OrchFunction.cs b/dotnet/samples/AzureFunctions/09_Workflow/OrchFunction.cs new file mode 100644 index 0000000000..c20fa550b4 --- /dev/null +++ b/dotnet/samples/AzureFunctions/09_Workflow/OrchFunction.cs @@ -0,0 +1,40 @@ +// 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 new file mode 100644 index 0000000000..87ce302293 --- /dev/null +++ b/dotnet/samples/AzureFunctions/09_Workflow/Program.cs @@ -0,0 +1,53 @@ +// 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.Hosting; +using OpenAI.Chat; + +// 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()); + +// Set up an AI agent following the standard Microsoft Agent Framework pattern. +const string JokerName = "Joker"; +const string JokerInstructions = "You are good at telling jokes."; + +AIAgent agent = client.GetChatClient(deploymentName).CreateAIAgent(JokerInstructions, JokerName); + +Func uppercaseFunc = s => s.ToUpperInvariant(); +var uppercase = uppercaseFunc.BindAsExecutor("UppercaseExecutor"); + +Func reverseTextFunc = s => s.ToUpperInvariant(); +var reverse = reverseTextFunc.BindAsExecutor("ReverseTextExecutor"); + +WorkflowBuilder builder = new(uppercase); +builder.AddEdge(uppercase, reverse).WithOutputFrom(reverse); +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. +using IHost app = FunctionsApplication + .CreateBuilder(args) + .ConfigureFunctionsWebApplication() + .ConfigureDurableAgents(options => options.AddAIAgent(agent, timeToLive: TimeSpan.FromHours(1))) + .AddDurableWorkflows(options => + { + // Configure durable workflow options here if needed. + options.AddWorkflow(workflow); + }) + .Build(); +app.Run(); diff --git a/dotnet/samples/AzureFunctions/09_Workflow/README.md b/dotnet/samples/AzureFunctions/09_Workflow/README.md new file mode 100644 index 0000000000..d4ac968978 --- /dev/null +++ b/dotnet/samples/AzureFunctions/09_Workflow/README.md @@ -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 + } + } +} +``` diff --git a/dotnet/samples/AzureFunctions/09_Workflow/demo.http b/dotnet/samples/AzureFunctions/09_Workflow/demo.http new file mode 100644 index 0000000000..3b741adf31 --- /dev/null +++ b/dotnet/samples/AzureFunctions/09_Workflow/demo.http @@ -0,0 +1,8 @@ +# Default endpoint address for local testing +@authority=http://localhost:7071 + +### Prompt the agent +POST {{authority}}/api/agents/Joker/run +Content-Type: text/plain + +Tell me a joke about a pirate. diff --git a/dotnet/samples/AzureFunctions/09_Workflow/host.json b/dotnet/samples/AzureFunctions/09_Workflow/host.json new file mode 100644 index 0000000000..9384a0a583 --- /dev/null +++ b/dotnet/samples/AzureFunctions/09_Workflow/host.json @@ -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" + } + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableWorkflowOptions.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableWorkflowOptions.cs new file mode 100644 index 0000000000..c3f7b6c75d --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableWorkflowOptions.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.Workflows; + +namespace Microsoft.Agents.AI.DurableTask; + +/// +/// Provides configuration options for managing durable workflows within an application. +/// +public sealed class DurableWorkflowOptions +{ + private readonly Dictionary _workflows = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Adds a workflow to the collection for processing or execution. + /// + /// The workflow instance to add. Cannot be null. + 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; + } + + /// + /// Gets the collection of workflows available in the current context, keyed by their unique names. + /// + /// The returned dictionary is read-only and reflects the current set of registered workflows. + /// 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; +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Microsoft.Agents.AI.DurableTask.csproj b/dotnet/src/Microsoft.Agents.AI.DurableTask/Microsoft.Agents.AI.DurableTask.csproj index 43ebe9c61f..782004ec6e 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Microsoft.Agents.AI.DurableTask.csproj +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Microsoft.Agents.AI.DurableTask.csproj @@ -24,6 +24,7 @@ + diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctionExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctionExecutor.cs index fa0b9ef287..dc1221d4b9 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctionExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctionExecutor.cs @@ -5,6 +5,7 @@ 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; @@ -25,6 +26,20 @@ 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) + { + 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(t!); + } + + return; + } + FunctionInputBindingResult? inputBindingResults = await functionInputBindingFeature.BindFunctionInputAsync(context); if (inputBindingResults is not { Values: { } values }) { @@ -35,6 +50,7 @@ internal sealed class BuiltInFunctionExecutor : IFunctionExecutor string? encodedEntityRequest = null; DurableTaskClient? durableTaskClient = null; ToolInvocationContext? mcpToolInvocationContext = null; + //string? encodedTaskOrchestrationContext = null; foreach (var binding in values) { @@ -52,10 +68,13 @@ internal sealed class BuiltInFunctionExecutor : IFunctionExecutor case ToolInvocationContext toolContext: mcpToolInvocationContext = toolContext; break; + //case string orchestrationContext: + // encodedTaskOrchestrationContext = orchestrationContext; + // break; } } - if (durableTaskClient is null) + if (durableTaskClient is null && context.FunctionDefinition.EntryPoint != BuiltInFunctions.RunWorkflowOrechstrtationFunctionEntryPoint) { // This is not expected to happen since all built-in functions are // expected to have a Durable Task client binding. @@ -71,7 +90,7 @@ internal sealed class BuiltInFunctionExecutor : IFunctionExecutor context.GetInvocationResult().Value = await BuiltInFunctions.RunAgentHttpAsync( httpRequestData, - durableTaskClient, + durableTaskClient!, context); return; } @@ -84,7 +103,7 @@ internal sealed class BuiltInFunctionExecutor : IFunctionExecutor } context.GetInvocationResult().Value = await BuiltInFunctions.InvokeAgentAsync( - durableTaskClient, + durableTaskClient!, encodedEntityRequest, context); return; @@ -98,7 +117,40 @@ internal sealed class BuiltInFunctionExecutor : IFunctionExecutor } context.GetInvocationResult().Value = - await BuiltInFunctions.RunMcpToolAsync(mcpToolInvocationContext, durableTaskClient, context); + await BuiltInFunctions.RunMcpToolAsync(mcpToolInvocationContext, durableTaskClient!, context); + return; + } + + if (context.FunctionDefinition.EntryPoint == BuiltInFunctions.RunWorkflowOrechstrtationHttpFunctionEntryPoint) + { + //if (httpRequestData == null) + //{ + // throw new InvalidOperationException($"HTTP request data binding is missing for the invocation {context.InvocationId}."); + //} + + 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; + } + if (context.FunctionDefinition.EntryPoint == BuiltInFunctions.RunWorkflowOrechstrtationFunctionEntryPoint) + { + 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(t!); + } + + //context.GetInvocationResult().Value = await BuiltInFunctions.RunWorkflowOrchestratorAsync(null); return; } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs index edde523271..0463d4ecc9 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs @@ -6,6 +6,7 @@ using Microsoft.Agents.AI.DurableTask; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Extensions.Mcp; using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.DurableTask; using Microsoft.DurableTask.Client; using Microsoft.DurableTask.Worker.Grpc; using Microsoft.Extensions.AI; @@ -20,8 +21,47 @@ 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 RunWorkflowOrechstrtationFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RunWorkflowOrchestratorAsync)}"; + internal static readonly string InvokeWorkflowActivityFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(InvokeWorkflowActivityAsync)}"; internal static readonly string RunAgentMcpToolFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RunMcpToolAsync)}"; +#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 InvokeWorkflowActivityAsync( + [ActivityTrigger] string input, + FunctionContext functionContext) + { + return Task.FromResult($"Hello from activity with input: {input}"); + } + + //[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(); + //} + + //[Function("dafx-Orchestration")] + public static async Task> RunWorkflowOrchestratorAsync(TaskOrchestrationContext taskOrchestrationContext) + { + //ILogger logger = context.CreateReplaySafeLogger(nameof(Function)); + //logger.LogInformation("Invoking RunWorkflowOrchestrator"); + var outputs = new List(); + + await Task.Delay(1); + outputs.Add("to do - call get executor result"); + + return outputs; + } + // Exposed as an entity trigger via AgentFunctionsProvider public static Task InvokeAgentAsync( [DurableClient] DurableTaskClient client, @@ -43,6 +83,25 @@ internal static class BuiltInFunctions return GrpcEntityRunner.LoadAndRunAsync(encodedEntityRequest, entity, combinedServiceProvider); } + /// + /// Invokes a workflow orchestration in response to an HTTP request. + /// + public static async Task RunWorkflowOrechstrtationHttpTriggerAsync( + [HttpTrigger] HttpRequestData req, + [DurableClient] DurableTaskClient client, + FunctionContext context) + { + // to do: Retrieve the workflow and execute it. + var workflowName = context.FunctionDefinition.Name.Replace("http", "dafx"); + + //string instanceId = await client.ScheduleNewOrchestrationInstanceAsync("dafx-MyTestWorkflow"); + string instanceId = await client.ScheduleNewOrchestrationInstanceAsync("OrchFunction"); // dafx-MyTestWorkflow"); + + HttpResponseData response = req.CreateResponse(HttpStatusCode.OK); + await response.WriteStringAsync($"InvokeWorkflowOrechstrtationAsync is invoked for {workflowName}.{instanceId}"); + return response; + } + public static async Task RunAgentHttpAsync( [HttpTrigger] HttpRequestData req, [DurableClient] DurableTaskClient client, diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableWorkflowFunctionMetadataTransformer.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableWorkflowFunctionMetadataTransformer.cs new file mode 100644 index 0000000000..1e0690a7a2 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableWorkflowFunctionMetadataTransformer.cs @@ -0,0 +1,132 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.DurableTask; +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 _logger; + private readonly DurableWorkflowOptions _options; + + public DurableWorkflowFunctionMetadataTransformer(ILogger logger, DurableWorkflowOptions durableWorkflowOptions) + { + this._logger = logger ?? throw new ArgumentNullException(nameof(logger)); + this._options = durableWorkflowOptions ?? throw new ArgumentNullException(nameof(durableWorkflowOptions)); + } + + public string Name => nameof(DurableWorkflowFunctionMetadataTransformer); + + public void Transform(IList original) + { + if (this._logger.IsEnabled(LogLevel.Information)) + { + this._logger.LogInformation("Transforming function metadata to add durable workflow functions. Initial function count: {FunctionCount}", original.Count); + } + + foreach (var workflow in this._options.Workflows) + { + if (this._logger.IsEnabled(LogLevel.Information)) + { + this._logger.LogInformation("Adding durable workflow function for workflow: {WorkflowName}", workflow.Key); + } + + original.Add(CreateOrchestrationTrigger(workflow.Key)); + // We also want to create an HTTP trigge for this orchestration so users can start it via HTTP. + if (this._logger.IsEnabled(LogLevel.Information)) + { + this._logger.LogInformation("Adding HTTP trigger function for workflow: {WorkflowName}", workflow.Key); + var httpTriggerMetadata = CreateHttpTrigger(workflow.Key, $"workflows/{workflow.Key}/run"); + original.Add(httpTriggerMetadata); + } + + // Create activity functions for each executor in the workflow + // Extract executor IDs from edges and start executor (since ExecutorBindings is internal) + var executorIds = new HashSet { 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); + } + } + } + + foreach (var executorId in executorIds) + { + if (this._logger.IsEnabled(LogLevel.Information)) + { + this._logger.LogInformation( + "Adding activity function for executor: {ExecutorId} in workflow: {WorkflowName}", + executorId, + workflow.Key); + } + + original.Add(CreateActivityTrigger(workflow.Key, executorId)); + } + } + + if (this._logger.IsEnabled(LogLevel.Information)) + { + this._logger.LogInformation("Transform finished. Updated function count: {FunctionCount}", 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 workflowName, string executorId) + { + string functionName = $"{AgentSessionId.ToEntityName(workflowName)}_{executorId}"; + + return new DefaultFunctionMetadata() + { + Name = functionName, + Language = "dotnet-isolated", + RawBindings = + [ + """{"name":"input","type":"activityTrigger","direction":"In","dataType":"String"}""", + ], + EntryPoint = BuiltInFunctions.InvokeWorkflowActivityFunctionEntryPoint, + ScriptFile = BuiltInFunctions.ScriptFile, + }; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsApplicationBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsApplicationBuilderExtensions.cs index e13c6008ea..c9f4a3bc4a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsApplicationBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsApplicationBuilderExtensions.cs @@ -14,6 +14,22 @@ 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(); + + return builder; + } + /// /// Configures the application to use durable agents with a builder pattern. /// @@ -38,6 +54,8 @@ public static class FunctionsApplicationBuilderExtensions 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.RunAgentEntityFunctionEntryPoint, StringComparison.Ordinal)); builder.Services.AddSingleton();