Merge branch 'main' into crickman/workflows-declarative-nocodegen

This commit is contained in:
Chris
2026-03-25 12:57:37 -07:00
committed by GitHub
556 changed files with 15650 additions and 12859 deletions
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,226 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates how the ChatClientAgent persists chat history after each individual
// call to the AI service.
// When an agent uses tools, FunctionInvokingChatClient may loop multiple times
// (service call → tool execution → service call), and intermediate messages (tool calls and
// results) are persisted after each service call. This allows you to inspect or recover them
// even if the process is interrupted mid-loop, but may also result in chat history that is not
// yet finalized (e.g., tool calls without results) being persisted, which may be undesirable in some cases.
//
// To opt into end-of-run persistence instead (atomic run semantics), set
// PersistChatHistoryAtEndOfRun = true on ChatClientAgentOptions.
//
// The sample runs two multi-turn conversations: one using non-streaming (RunAsync) and one
// using streaming (RunStreamingAsync), to demonstrate correct behavior in both modes.
using System.ComponentModel;
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI.Responses;
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
var store = Environment.GetEnvironmentVariable("AZURE_OPENAI_RESPONSES_STORE") ?? "false";
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
AzureOpenAIClient openAIClient = new(new Uri(endpoint), new DefaultAzureCredential());
// Define multiple tools so the model makes several tool calls in a single run.
[Description("Get the current weather for a city.")]
static string GetWeather([Description("The city name.")] string city) =>
city.ToUpperInvariant() switch
{
"SEATTLE" => "Seattle: 55°F, cloudy with light rain.",
"NEW YORK" => "New York: 72°F, sunny and warm.",
"LONDON" => "London: 48°F, overcast with fog.",
"DUBLIN" => "Dublin: 43°F, overcast with fog.",
_ => $"{city}: weather data not available."
};
[Description("Get the current time in a city.")]
static string GetTime([Description("The city name.")] string city) =>
city.ToUpperInvariant() switch
{
"SEATTLE" => "Seattle: 9:00 AM PST",
"NEW YORK" => "New York: 12:00 PM EST",
"LONDON" => "London: 5:00 PM GMT",
"DUBLIN" => "Dublin: 5:00 PM GMT",
_ => $"{city}: time data not available."
};
// Create the agent — per-service-call persistence is the default behavior.
// The in-memory ChatHistoryProvider is used by default when the service does not require service stored chat
// history, so for those cases, we can inspect the chat history via session.TryGetInMemoryChatHistory().
IChatClient chatClient = string.Equals(store, "TRUE", StringComparison.OrdinalIgnoreCase) ?
openAIClient.GetResponsesClient().AsIChatClient(deploymentName) :
openAIClient.GetResponsesClient().AsIChatClientWithStoredOutputDisabled(deploymentName);
AIAgent agent = chatClient.AsAIAgent(
new ChatClientAgentOptions
{
Name = "WeatherAssistant",
ChatOptions = new()
{
Instructions = "You are a helpful assistant. When asked about multiple cities, call the appropriate tool for each city.",
Tools = [AIFunctionFactory.Create(GetWeather), AIFunctionFactory.Create(GetTime)]
},
});
await RunNonStreamingAsync();
await RunStreamingAsync();
async Task RunNonStreamingAsync()
{
int lastChatHistorySize = 0;
string lastConversationId = string.Empty;
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine("\n=== Non-Streaming Mode ===");
Console.ResetColor();
AgentSession session = await agent.CreateSessionAsync();
// First turn — ask about multiple cities so the model calls tools.
const string Prompt = "What's the weather and time in Seattle, New York, and London?";
PrintUserMessage(Prompt);
var response = await agent.RunAsync(Prompt, session);
PrintAgentResponse(response.Text);
PrintChatHistory(session, "After run", ref lastChatHistorySize, ref lastConversationId);
// Second turn — follow-up to verify chat history is correct.
const string FollowUp1 = "And Dublin?";
PrintUserMessage(FollowUp1);
response = await agent.RunAsync(FollowUp1, session);
PrintAgentResponse(response.Text);
PrintChatHistory(session, "After second run", ref lastChatHistorySize, ref lastConversationId);
// Third turn — follow-up to verify chat history is correct.
const string FollowUp2 = "Which city is the warmest?";
PrintUserMessage(FollowUp2);
response = await agent.RunAsync(FollowUp2, session);
PrintAgentResponse(response.Text);
PrintChatHistory(session, "After third run", ref lastChatHistorySize, ref lastConversationId);
}
async Task RunStreamingAsync()
{
int lastChatHistorySize = 0;
string lastConversationId = string.Empty;
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine("\n=== Streaming Mode ===");
Console.ResetColor();
AgentSession session = await agent.CreateSessionAsync();
// First turn — ask about multiple cities so the model calls tools.
const string Prompt = "What's the weather and time in Seattle, New York, and London?";
PrintUserMessage(Prompt);
Console.ForegroundColor = ConsoleColor.Cyan;
Console.Write("\n[Agent] ");
Console.ResetColor();
await foreach (var update in agent.RunStreamingAsync(Prompt, session))
{
Console.Write(update);
// During streaming we should be able to see updates to the chat history
// before the full run completes, as each service call is made and persisted.
PrintChatHistory(session, "During run", ref lastChatHistorySize, ref lastConversationId);
}
Console.WriteLine();
PrintChatHistory(session, "After run", ref lastChatHistorySize, ref lastConversationId);
// Second turn — follow-up to verify chat history is correct.
const string FollowUp1 = "And Dublin?";
PrintUserMessage(FollowUp1);
Console.ForegroundColor = ConsoleColor.Cyan;
Console.Write("\n[Agent] ");
Console.ResetColor();
await foreach (var update in agent.RunStreamingAsync(FollowUp1, session))
{
Console.Write(update);
// During streaming we should be able to see updates to the chat history
// before the full run completes, as each service call is made and persisted.
PrintChatHistory(session, "During second run", ref lastChatHistorySize, ref lastConversationId);
}
Console.WriteLine();
PrintChatHistory(session, "After second run", ref lastChatHistorySize, ref lastConversationId);
// Third turn — follow-up to verify chat history is correct.
const string FollowUp2 = "Which city is the warmest?";
PrintUserMessage(FollowUp2);
Console.ForegroundColor = ConsoleColor.Cyan;
Console.Write("\n[Agent] ");
Console.ResetColor();
await foreach (var update in agent.RunStreamingAsync(FollowUp2, session))
{
Console.Write(update);
// During streaming we should be able to see updates to the chat history
// before the full run completes, as each service call is made and persisted.
PrintChatHistory(session, "During third run", ref lastChatHistorySize, ref lastConversationId);
}
Console.WriteLine();
PrintChatHistory(session, "After third run", ref lastChatHistorySize, ref lastConversationId);
}
void PrintUserMessage(string message)
{
Console.ForegroundColor = ConsoleColor.Cyan;
Console.Write("\n[User] ");
Console.ResetColor();
Console.WriteLine(message);
}
void PrintAgentResponse(string? text)
{
Console.ForegroundColor = ConsoleColor.Cyan;
Console.Write("\n[Agent] ");
Console.ResetColor();
Console.WriteLine(text);
}
// Helper to print the current chat history from the session.
void PrintChatHistory(AgentSession session, string label, ref int lastChatHistorySize, ref string lastConversationId)
{
if (session.TryGetInMemoryChatHistory(out var history) && history.Count != lastChatHistorySize)
{
Console.ForegroundColor = ConsoleColor.DarkGray;
Console.WriteLine($"\n [{label} — Chat history: {history.Count} message(s)]");
foreach (var msg in history)
{
var preview = msg.Text?.Length > 80 ? msg.Text[..80] + "…" : msg.Text;
var contentTypes = string.Join(", ", msg.Contents.Select(c => c.GetType().Name));
Console.WriteLine($" {msg.Role,-12} | {(string.IsNullOrWhiteSpace(preview) ? $"[{contentTypes}]" : preview)}");
}
Console.ResetColor();
lastChatHistorySize = history.Count;
}
if (session is ChatClientAgentSession ccaSession && ccaSession.ConversationId is not null && ccaSession.ConversationId != lastConversationId)
{
Console.ForegroundColor = ConsoleColor.DarkGray;
Console.WriteLine($" [{label} — Conversation ID: {ccaSession.ConversationId}]");
Console.ResetColor();
lastConversationId = ccaSession.ConversationId;
}
}
@@ -0,0 +1,63 @@
# In-Function-Loop Checkpointing
This sample demonstrates how `ChatClientAgent` persists chat history after each individual call to the AI service by default. This per-service-call persistence ensures intermediate progress is saved during the function invocation loop.
## What This Sample Shows
When an agent uses tools, the `FunctionInvokingChatClient` loops multiple times (service call → tool execution → service call → …). By default, chat history is persisted after each service call via the `ChatHistoryPersistingChatClient` decorator:
- A `ChatHistoryPersistingChatClient` decorator is automatically inserted into the chat client pipeline
- After each service call, the decorator notifies the `ChatHistoryProvider` (and any `AIContextProvider` instances) with the new messages
- Only **new** messages are sent to providers on each notification — messages that were already persisted in an earlier call within the same run are deduplicated automatically
To opt into end-of-run persistence instead (atomic run semantics), set `PersistChatHistoryAtEndOfRun = true` on `ChatClientAgentOptions`. In that mode, the decorator marks messages with metadata rather than persisting them immediately, and `ChatClientAgent` persists only the marked messages at the end of the run.
Per-service-call persistence is useful for:
- **Crash recovery** — if the process is interrupted mid-loop, the intermediate tool calls and results are already persisted
- **Observability** — you can inspect the chat history while the agent is still running (e.g., during streaming)
- **Long-running tool loops** — agents with many sequential tool calls benefit from incremental persistence
## How It Works
The sample asks the agent about the weather and time in three cities. The model calls the `GetWeather` and `GetTime` tools for each city, resulting in multiple service calls within a single `RunStreamingAsync` invocation. After the run completes, the sample prints the full chat history to show all the intermediate messages that were persisted along the way.
### Pipeline Architecture
```
ChatClientAgent
└─ FunctionInvokingChatClient (handles tool call loop)
└─ ChatHistoryPersistingChatClient (persists after each service call)
└─ Leaf IChatClient (Azure OpenAI)
```
## Prerequisites
- .NET 10 SDK or later
- Azure OpenAI service endpoint and model deployment
- Azure CLI installed and authenticated
**Note**: This sample uses `DefaultAzureCredential`. Sign in with `az login` before running. For production, prefer a specific credential such as `ManagedIdentityCredential`. For more information, see the [Azure CLI authentication documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively).
## Environment Variables
```powershell
$env:AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" # Required
$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini
```
## Running the Sample
```powershell
cd dotnet/samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing
dotnet run
```
## Expected Behavior
The sample runs two conversation turns:
1. **First turn** — asks about weather and time in three cities. The model calls `GetWeather` and `GetTime` tools (potentially in parallel or sequentially), then provides a summary. The chat history dump after the run shows all the intermediate tool call and result messages.
2. **Second turn** — asks a follow-up question ("Which city is the warmest?") that uses the persisted conversation context. The chat history dump shows the full accumulated conversation.
The chat history printout uses `session.TryGetInMemoryChatHistory()` to inspect the in-memory storage.
@@ -45,6 +45,7 @@ Before you begin, ensure you have the following prerequisites:
|[Declarative agent](./Agent_Step16_Declarative/)|This sample demonstrates how to declaratively define an agent.|
|[Providing additional AI Context to an agent using multiple AIContextProviders](./Agent_Step17_AdditionalAIContext/)|This sample demonstrates how to inject additional AI context into a ChatClientAgent using multiple custom AIContextProvider components that are attached to the agent.|
|[Using compaction pipeline with an agent](./Agent_Step18_CompactionPipeline/)|This sample demonstrates how to use a compaction pipeline to efficiently limit the size of the conversation history for an agent.|
|[In-function-loop checkpointing](./Agent_Step19_InFunctionLoopCheckpointing/)|This sample demonstrates how to persist chat history after each service call during a tool-calling loop, enabling crash recovery and mid-run observability.|
## Running the samples from the console
@@ -9,7 +9,7 @@ using Microsoft.Extensions.AI;
namespace WorkflowAsAnAgentSample;
/// <summary>
/// This sample introduces the concepts workflows as agents, where a workflow can be
/// This sample introduces the concept of workflows as agents, where a workflow can be
/// treated as an <see cref="AIAgent"/>. This allows you to interact with a workflow
/// as if it were a single agent.
///
@@ -18,6 +18,14 @@ namespace WorkflowAsAnAgentSample;
///
/// You will interact with the workflow in an interactive loop, sending messages and receiving
/// streaming responses from the workflow as if it were an agent who responds in both languages.
///
/// This sample also demonstrates <see cref="IResettableExecutor"/>, which is required
/// for stateful executors that are shared across multiple workflow runs. Each iteration
/// of the interactive loop triggers a new workflow run against the same workflow instance.
/// Between runs, the framework automatically calls <see cref="IResettableExecutor.ResetAsync"/>
/// on shared executors so that accumulated state (e.g., collected messages) is cleared
/// before the next run begins. See <c>WorkflowFactory.ConcurrentAggregationExecutor</c>
/// for the implementation.
/// </summary>
/// <remarks>
/// Pre-requisites:
@@ -39,7 +47,10 @@ public static class Program
var agent = workflow.AsAIAgent("workflow-agent", "Workflow Agent");
var session = await agent.CreateSessionAsync();
// Start an interactive loop to interact with the workflow as if it were an agent
// Start an interactive loop to interact with the workflow as if it were an agent.
// Each iteration runs the workflow again on the same workflow instance. Between runs,
// the framework calls IResettableExecutor.ResetAsync() on shared stateful executors
// (like ConcurrentAggregationExecutor) to clear accumulated state from the previous run.
while (true)
{
Console.WriteLine();
@@ -10,6 +10,14 @@ internal static class WorkflowFactory
{
/// <summary>
/// Creates a workflow that uses two language agents to process input concurrently.
///
/// In this workflow, the <c>Start</c> <see cref="ChatForwardingExecutor"/> and the
/// <see cref="ConcurrentAggregationExecutor"/> are provided as shared instances, meaning
/// the same executor objects are reused across multiple workflow runs. The language agents
/// (French and English) are created via a factory and instantiated per workflow run.
/// Stateful shared executors must implement <see cref="IResettableExecutor"/> so the
/// framework can clear their state between runs. Framework-provided executors like
/// <see cref="ChatForwardingExecutor"/> already implement this interface.
/// </summary>
/// <param name="chatClient">The chat client to use for the agents</param>
/// <returns>A workflow that processes input using two language agents</returns>
@@ -40,6 +48,16 @@ internal static class WorkflowFactory
/// <summary>
/// Executor that aggregates the results from the concurrent agents.
///
/// This executor is stateful — it accumulates messages in <see cref="_messages"/>
/// as they arrive from each agent. Because it is provided as a shared instance
/// (not via a factory), the same object is reused across workflow runs. Implementing
/// <see cref="IResettableExecutor"/> allows the framework to call <see cref="ResetAsync"/>
/// between runs, clearing accumulated state so each run starts fresh.
///
/// Without <see cref="IResettableExecutor"/>, attempting to reuse a workflow containing
/// shared executor instances that do not implement this interface would throw an
/// <see cref="InvalidOperationException"/>.
/// </summary>
[YieldsOutput(typeof(string))]
private sealed class ConcurrentAggregationExecutor() :
@@ -65,7 +83,11 @@ internal static class WorkflowFactory
}
}
/// <inheritdoc/>
/// <summary>
/// Resets the executor state between workflow runs by clearing accumulated messages.
/// The framework calls this automatically when a workflow run completes, before the
/// workflow can be used for another run.
/// </summary>
public ValueTask ResetAsync()
{
this._messages.Clear();
@@ -0,0 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
<AzureFunctionsVersion>v4</AzureFunctionsVersion>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<!-- The Functions build tools don't like namespaces that start with a number -->
<AssemblyName>WorkflowMcpTool</AssemblyName>
<RootNamespace>WorkflowMcpTool</RootNamespace>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<!-- Azure Functions packages -->
<ItemGroup>
<PackageReference Include="Microsoft.Azure.Functions.Worker" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask.AzureManaged" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" />
</ItemGroup>
<!-- Local projects that should be switched to package references when using the sample outside of this MAF repo -->
<!--
<ItemGroup>
<PackageReference Include="Microsoft.Agents.AI.Hosting.AzureFunctions" />
</ItemGroup>
-->
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AzureFunctions\Microsoft.Agents.AI.Hosting.AzureFunctions.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,59 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows;
namespace WorkflowMcpTool;
internal sealed class TranslateText() : Executor<string, TranslationResult>("TranslateText")
{
public override ValueTask<TranslationResult> 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<TranslationResult, string>("FormatOutput")
{
public override ValueTask<string> 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 class LookupOrder() : Executor<string, OrderInfo>("LookupOrder")
{
public override ValueTask<OrderInfo> HandleAsync(
string message,
IWorkflowContext context,
CancellationToken cancellationToken = default)
{
Console.WriteLine($"[Activity] LookupOrder: '{message}'");
return ValueTask.FromResult(new OrderInfo(message, "Alice Johnson", "Wireless Headphones", Quantity: 2, UnitPrice: 49.99m));
}
}
internal sealed class EnrichOrder() : Executor<OrderInfo, OrderSummary>("EnrichOrder")
{
public override ValueTask<OrderSummary> HandleAsync(
OrderInfo message,
IWorkflowContext context,
CancellationToken cancellationToken = default)
{
Console.WriteLine($"[Activity] EnrichOrder: '{message.OrderId}'");
return ValueTask.FromResult(new OrderSummary(message, TotalPrice: message.Quantity * message.UnitPrice, Status: "Confirmed"));
}
}
internal sealed record TranslationResult(string Original, string Translated);
internal sealed record OrderInfo(string OrderId, string CustomerName, string Product, int Quantity, decimal UnitPrice);
internal sealed record OrderSummary(OrderInfo Order, decimal TotalPrice, string Status);
@@ -0,0 +1,44 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates how to expose a durable workflow as an MCP (Model Context Protocol) tool.
// When using AddWorkflow with exposeMcpToolTrigger: true, the Functions host will automatically
// generate a remote MCP endpoint for the app at /runtime/webhooks/mcp with a workflow-specific
// tool name. MCP-compatible clients can then invoke the workflow as a tool.
using Microsoft.Agents.AI.Hosting.AzureFunctions;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Azure.Functions.Worker.Builder;
using Microsoft.Extensions.Hosting;
using WorkflowMcpTool;
// Define executors
TranslateText translateText = new();
FormatOutput formatOutput = new();
LookupOrder lookupOrder = new();
EnrichOrder enrichOrder = new();
// Build a simple workflow: TranslateText -> FormatOutput
Workflow translateWorkflow = new WorkflowBuilder(translateText)
.WithName("Translate")
.WithDescription("Translate text to uppercase and format the result")
.AddEdge(translateText, formatOutput)
.Build();
// Build a workflow that returns a POCO: LookupOrder -> EnrichOrder
Workflow orderLookupWorkflow = new WorkflowBuilder(lookupOrder)
.WithName("OrderLookup")
.WithDescription("Look up an order by ID and return enriched order details")
.AddEdge(lookupOrder, enrichOrder)
.Build();
using IHost app = FunctionsApplication
.CreateBuilder(args)
.ConfigureFunctionsWebApplication()
.ConfigureDurableWorkflows(workflows =>
{
// Expose both workflows as MCP tool triggers.
workflows.AddWorkflow(translateWorkflow, exposeStatusEndpoint: false, exposeMcpToolTrigger: true);
workflows.AddWorkflow(orderLookupWorkflow, exposeStatusEndpoint: false, exposeMcpToolTrigger: true);
})
.Build();
app.Run();
@@ -0,0 +1,81 @@
# Workflow as MCP Tool Sample
This sample demonstrates how to expose durable workflows as [MCP (Model Context Protocol)](https://modelcontextprotocol.io/) tools, enabling MCP-compatible clients to invoke workflows directly.
## Key Concepts Demonstrated
- **Workflow as MCP Tool**: Expose workflows as callable MCP tools using `exposeMcpToolTrigger: true`
- **MCP Server Hosting**: The Azure Functions host automatically generates a remote MCP endpoint at `/runtime/webhooks/mcp`
- **String and POCO Results**: Shows workflows returning both plain strings and structured JSON objects
## Sample Architecture
The sample creates two workflows exposed as MCP tools:
### Translate Workflow (returns a string)
| Executor | Input | Output | Description |
|----------|-------|--------|-------------|
| **TranslateText** | `string` | `TranslationResult` | Converts input text to uppercase |
| **FormatOutput** | `TranslationResult` | `string` | Formats the result into a readable string |
### OrderLookup Workflow (returns a POCO)
| Executor | Input | Output | Description |
|----------|-------|--------|-------------|
| **LookupOrder** | `string` | `OrderInfo` | Looks up an order by ID |
| **EnrichOrder** | `OrderInfo` | `OrderSummary` | Adds computed fields (total price, status) |
## 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
For this sample, you'll also need [Node.js](https://nodejs.org/en/download) to use the [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector).
## Running the Sample
1. **Start the Function App**:
```bash
cd dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool
func start
```
2. **Note the MCP Server Endpoint**: When the app starts, you'll see the MCP server endpoint in the terminal output:
```text
MCP server endpoint: http://localhost:7071/runtime/webhooks/mcp
```
## Invoking Workflows via MCP Inspector
1. Install and run the [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector):
```bash
npx @modelcontextprotocol/inspector
```
2. Connect to the MCP server endpoint:
- For **Transport Type**, select **"Streamable HTTP"**
- For **URL**, enter `http://localhost:7071/runtime/webhooks/mcp`
- Click the **Connect** button
3. Click the **List Tools** button. You should see two tools: `Translate` and `OrderLookup`.
4. Test the **Translate** tool (returns a plain string):
- Select the `Translate` tool
- Set `hello world` as the `input` parameter
- Click **Run Tool**
- Expected result: `Original: hello world => Translated: HELLO WORLD`
5. Test the **OrderLookup** tool (returns a JSON object):
- Select the `OrderLookup` tool
- Set `ORD-2025-42` as the `input` parameter
- Click **Run Tool**
- Expected result: A JSON object containing order details such as `OrderId`, `CustomerName`, `Product`, `TotalPrice`, and `Status`
You'll see the workflow executor activities logged in the terminal where you ran `func start`.
@@ -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"
}
}
}
}
@@ -0,0 +1,8 @@
{
"IsEncrypted": false,
"Values": {
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None"
}
}
@@ -0,0 +1,42 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
<AzureFunctionsVersion>v4</AzureFunctionsVersion>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<!-- The Functions build tools don't like namespaces that start with a number -->
<AssemblyName>WorkflowAndAgents</AssemblyName>
<RootNamespace>WorkflowAndAgents</RootNamespace>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<!-- Azure Functions packages -->
<ItemGroup>
<PackageReference Include="Microsoft.Azure.Functions.Worker" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask.AzureManaged" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
</ItemGroup>
<!-- Local projects that should be switched to package references when using the sample outside of this MAF repo -->
<!--
<ItemGroup>
<PackageReference Include="Microsoft.Agents.AI.Hosting.AzureFunctions" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" />
</ItemGroup>
-->
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AzureFunctions\Microsoft.Agents.AI.Hosting.AzureFunctions.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,31 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows;
namespace WorkflowAndAgents;
internal sealed class TranslateText() : Executor<string, TranslationResult>("TranslateText")
{
public override ValueTask<TranslationResult> 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<TranslationResult, string>("FormatOutput")
{
public override ValueTask<string> 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);
@@ -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();
@@ -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.
@@ -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"
}
}
}
}
@@ -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_ENDPOINT>",
"AZURE_OPENAI_DEPLOYMENT_NAME": "<AZURE_OPENAI_DEPLOYMENT_NAME>"
}
}
@@ -48,3 +48,4 @@ $env:DURABLE_TASK_SCHEDULER_CONNECTION_STRING = "AccountEndpoint=http://localhos
| [01_SequentialWorkflow](AzureFunctions/01_SequentialWorkflow/) | Sequential workflow hosted in Azure Functions |
| [02_ConcurrentWorkflow](AzureFunctions/02_ConcurrentWorkflow/) | Concurrent workflow hosted in Azure Functions |
| [03_WorkflowHITL](AzureFunctions/03_WorkflowHITL/) | Human-in-the-loop workflow hosted in Azure Functions |
| [04_WorkflowMcpTool](AzureFunctions/04_WorkflowMcpTool/) | Workflow exposed as an MCP tool |