diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index ee71fa729a..ff815348c7 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -77,6 +77,7 @@ + diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/05_WorkflowAndAgents.csproj b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/05_WorkflowAndAgents.csproj new file mode 100644 index 0000000000..517dd323a7 --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/05_WorkflowAndAgents.csproj @@ -0,0 +1,42 @@ + + + net10.0 + v4 + Exe + enable + enable + + WorkflowAndAgents + WorkflowAndAgents + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/Executors.cs b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/Executors.cs new file mode 100644 index 0000000000..727379b482 --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/Executors.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.Workflows; + +namespace WorkflowAndAgents; + +internal sealed class TranslateText() : Executor("TranslateText") +{ + public override ValueTask HandleAsync( + string message, + IWorkflowContext context, + CancellationToken cancellationToken = default) + { + Console.WriteLine($"[Activity] TranslateText: '{message}'"); + return ValueTask.FromResult(new TranslationResult(message, message.ToUpperInvariant())); + } +} + +internal sealed class FormatOutput() : Executor("FormatOutput") +{ + public override ValueTask HandleAsync( + TranslationResult message, + IWorkflowContext context, + CancellationToken cancellationToken = default) + { + Console.WriteLine("[Activity] FormatOutput: Formatting result"); + return ValueTask.FromResult($"Original: {message.Original} => Translated: {message.Translated}"); + } +} + +internal sealed record TranslationResult(string Original, string Translated); diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/Program.cs b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/Program.cs new file mode 100644 index 0000000000..51b9fb4d7f --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/Program.cs @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample demonstrates using ConfigureDurableOptions to register BOTH agents AND workflows +// in a single Azure Functions app. It uses a workflow to translate text and a standalone AI agent +// accessible via HTTP and MCP tool triggers. + +#pragma warning disable IDE0002 // Simplify Member Access + +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; +using WorkflowAndAgents; + +// 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_NAME") + ?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT_NAME is not set."); + +// Use Azure Key Credential if provided, otherwise use Azure CLI Credential. +string? azureOpenAiKey = Environment.GetEnvironmentVariable("AZURE_OPENAI_API_KEY"); +AzureOpenAIClient client = !string.IsNullOrEmpty(azureOpenAiKey) + ? new AzureOpenAIClient(new Uri(endpoint), new AzureKeyCredential(azureOpenAiKey)) + : new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()); + +ChatClient chatClient = client.GetChatClient(deploymentName); + +// Define a standalone AI agent +AIAgent assistant = chatClient.AsAIAgent( + "You are a helpful assistant. Answer questions clearly and concisely.", + "Assistant", + description: "A general-purpose helpful assistant."); + +// Define workflow executors +TranslateText translateText = new(); +FormatOutput formatOutput = new(); + +// Build a workflow: TranslateText -> FormatOutput +Workflow translateWorkflow = new WorkflowBuilder(translateText) + .WithName("Translate") + .WithDescription("Translate text to uppercase and format the result") + .AddEdge(translateText, formatOutput) + .Build(); + +// Use ConfigureDurableOptions to register both agents and workflows together +using IHost app = FunctionsApplication + .CreateBuilder(args) + .ConfigureFunctionsWebApplication() + .ConfigureDurableOptions(options => + { + // Register the standalone agent with HTTP and MCP tool triggers + options.Agents.AddAIAgent(assistant, enableHttpTrigger: true, enableMcpToolTrigger: true); + + // Register the workflow with an HTTP endpoint and MCP tool trigger + options.Workflows.AddWorkflow(translateWorkflow, exposeStatusEndpoint: false, exposeMcpToolTrigger: true); + }) + .Build(); +app.Run(); diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/README.md b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/README.md new file mode 100644 index 0000000000..37841777cc --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/README.md @@ -0,0 +1,76 @@ +# Workflow and Agents Sample + +This sample demonstrates how to use `ConfigureDurableOptions` to register **both** AI agents **and** workflows in a single Azure Functions app. This is the recommended approach when your application needs both standalone agents and orchestrated workflows. + +## Key Concepts Demonstrated + +- **Unified Configuration**: Use `ConfigureDurableOptions` to register agents and workflows together +- **Standalone Agent**: An AI agent accessible via HTTP and MCP tool triggers +- **Workflow**: A simple text translation workflow also exposed as an MCP tool +- **Mixed Triggers**: Both agents and workflows coexist in the same Functions host + +## Sample Architecture + +### Standalone Agent + +| Agent | Description | +|-------|-------------| +| **Assistant** | A general-purpose AI assistant accessible via HTTP (`/agents/Assistant/run`) and as an MCP tool | + +### Translate Workflow + +| Executor | Input | Output | Description | +|----------|-------|--------|-------------| +| **TranslateText** | `string` | `TranslationResult` | Converts input text to uppercase | +| **FormatOutput** | `TranslationResult` | `string` | Formats the result into a readable string | + +## Environment Setup + +See the [README.md](../../README.md) file in the parent directory for complete setup instructions, including: + +- Prerequisites installation +- Durable Task Scheduler setup +- Storage emulator configuration + +This sample also requires Azure OpenAI credentials. Set the following in `local.settings.json`: + +- `AZURE_OPENAI_ENDPOINT`: Your Azure OpenAI endpoint URL +- `AZURE_OPENAI_DEPLOYMENT_NAME`: Your chat model deployment name +- `AZURE_OPENAI_API_KEY` (optional): If not set, Azure CLI credential is used + +## Running the Sample + +1. **Start the Function App**: + + ```bash + cd dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents + func start + ``` + +2. **Expected Functions**: When the app starts, you should see functions for both the agent and the workflow: + + - `dafx-Assistant` (entity trigger for the agent) + - `http-Assistant` (HTTP trigger for the agent) + - `mcptool-Assistant` (MCP tool trigger for the agent) + - `wf-Translate` (orchestration trigger for the workflow) + - `mcptool-wf-Translate` (MCP tool trigger for the workflow) + +## Invoking the Agent via HTTP + +```bash +curl -X POST http://localhost:7071/agents/Assistant/run \ + -H "Content-Type: application/json" \ + -d '{"query": "What is the capital of France?"}' +``` + +## Invoking via MCP Inspector + +1. Install and run the [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector): + + ```bash + npx @modelcontextprotocol/inspector + ``` + +2. Connect to `http://localhost:7071/runtime/webhooks/mcp` using **Streamable HTTP** transport. + +3. Click **List Tools** to see both the `Assistant` agent tool and the `Translate` workflow tool. diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/host.json b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/host.json new file mode 100644 index 0000000000..9384a0a583 --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/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/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/local.settings.json b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/local.settings.json new file mode 100644 index 0000000000..5f6d7d3340 --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/local.settings.json @@ -0,0 +1,10 @@ +{ + "IsEncrypted": false, + "Values": { + "FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated", + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None", + "AZURE_OPENAI_ENDPOINT": "", + "AZURE_OPENAI_DEPLOYMENT_NAME": "" + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsApplicationBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsApplicationBuilderExtensions.cs index 2e9cc801c7..959ffab2f6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsApplicationBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsApplicationBuilderExtensions.cs @@ -67,6 +67,13 @@ public static class FunctionsApplicationBuilderExtensions builder.Services.ConfigureDurableOptions(configure); + if (sharedOptions.Agents.GetAgentFactories().Count > 0) + { + builder.Services.TryAddSingleton(_ => + new DefaultFunctionsAgentOptionsProvider(DurableAgentsOptionsExtensions.GetAgentOptionsSnapshot())); + builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton()); + } + if (sharedOptions.Workflows.Workflows.Count > 0) { builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton()); @@ -102,6 +109,7 @@ 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.RunAgentEntityFunctionEntryPoint, StringComparison.Ordinal) || string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunWorkflowOrchestrationHttpFunctionEntryPoint, StringComparison.Ordinal) || string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunWorkflowOrchestrationFunctionEntryPoint, StringComparison.Ordinal) || diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/WorkflowSamplesValidation.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/WorkflowSamplesValidation.cs index 64e35ecc88..af62436ea4 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/WorkflowSamplesValidation.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/WorkflowSamplesValidation.cs @@ -293,6 +293,58 @@ public sealed class WorkflowSamplesValidation(ITestOutputHelper outputHelper) : }); } + [Fact] + public async Task WorkflowAndAgentsSampleValidationAsync() + { + string samplePath = Path.Combine(s_samplesPath, "05_WorkflowAndAgents"); + await this.RunSampleTestAsync(samplePath, requiresOpenAI: true, async (logs) => + { + // Connect to the MCP endpoint exposed by the Azure Functions host + IClientTransport clientTransport = new HttpClientTransport(new() + { + Endpoint = new Uri($"http://localhost:{AzureFunctionsPort}/runtime/webhooks/mcp") + }); + + await using McpClient mcpClient = await McpClient.CreateAsync(clientTransport); + + // Verify both the agent and workflow tools are listed + IList tools = await mcpClient.ListToolsAsync(); + this._outputHelper.WriteLine($"MCP tools found: {string.Join(", ", tools.Select(t => t.Name))}"); + + Assert.Single(tools, t => t.Name == "Assistant"); + Assert.Single(tools, t => t.Name == "Translate"); + + // Invoke the Translate workflow via MCP tool + this._outputHelper.WriteLine("Invoking MCP tool 'Translate'..."); + CallToolResult translateResult = await mcpClient.CallToolAsync( + "Translate", + arguments: new Dictionary { { "input", "hello world" } }); + + Assert.NotEmpty(translateResult.Content); + string translateResponse = Assert.IsType(translateResult.Content[0]).Text; + this._outputHelper.WriteLine($"Translate MCP tool response: {translateResponse}"); + Assert.Contains("HELLO WORLD", translateResponse); + + // Invoke the Assistant agent via MCP tool + this._outputHelper.WriteLine("Invoking MCP tool 'Assistant'..."); + CallToolResult assistantResult = await mcpClient.CallToolAsync( + "Assistant", + arguments: new Dictionary { { "query", "What is 2 + 2?" } }); + + Assert.NotEmpty(assistantResult.Content); + string assistantResponse = Assert.IsType(assistantResult.Content[0]).Text; + this._outputHelper.WriteLine($"Assistant MCP tool response: {assistantResponse}"); + Assert.NotEmpty(assistantResponse); + + // Verify workflow executor activities ran in the logs + lock (logs) + { + Assert.True(logs.Any(log => log.Message.Contains("[Activity] TranslateText:")), "TranslateText activity not found in logs."); + Assert.True(logs.Any(log => log.Message.Contains("[Activity] FormatOutput:")), "FormatOutput activity not found in logs."); + } + }); + } + [Fact] public async Task ConcurrentWorkflowSampleValidationAsync() {