Merge branch 'main' into copilot/process-workflow-output-event

This commit is contained in:
Chris
2025-11-14 11:44:48 -08:00
committed by GitHub
Unverified
434 changed files with 34329 additions and 4689 deletions
+4
View File
@@ -0,0 +1,4 @@
# Code ownership assignments
# https://docs.github.com/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
python/packages/azurefunctions/ @microsoft/agentframework-durabletask-developers
@@ -0,0 +1,36 @@
name: Azure Functions Integration Test Setup
description: Prepare local emulators and tools for Azure Functions integration tests
runs:
using: "composite"
steps:
- name: Start Durable Task Scheduler Emulator
shell: bash
run: |
if [ "$(docker ps -aq -f name=dts-emulator)" ]; then
echo "Stopping and removing existing Durable Task Scheduler Emulator"
docker rm -f dts-emulator
fi
echo "Starting Durable Task Scheduler Emulator"
docker run -d --name dts-emulator -p 8080:8080 -p 8082:8082 mcr.microsoft.com/dts/dts-emulator:latest
echo "Waiting for Durable Task Scheduler Emulator to be ready"
timeout 30 bash -c 'until curl --silent http://localhost:8080/healthz; do sleep 1; done'
echo "Durable Task Scheduler Emulator is ready"
- name: Start Azurite (Azure Storage emulator)
shell: bash
run: |
if [ "$(docker ps -aq -f name=azurite)" ]; then
echo "Stopping and removing existing Azurite (Azure Storage emulator)"
docker rm -f azurite
fi
echo "Starting Azurite (Azure Storage emulator)"
docker run -d --name azurite -p 10000:10000 -p 10001:10001 -p 10002:10002 mcr.microsoft.com/azure-storage/azurite
echo "Waiting for Azurite (Azure Storage emulator) to be ready"
timeout 30 bash -c 'until curl --silent http://localhost:10000/devstoreaccount1; do sleep 1; done'
echo "Azurite (Azure Storage emulator) is ready"
- name: Install Azure Functions Core Tools
shell: bash
run: |
echo "Installing Azure Functions Core Tools"
npm install -g azure-functions-core-tools@4 --unsafe-perm true
func --version
@@ -151,6 +151,14 @@ jobs:
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
# This setup action is required for both Durable Task and Azure Functions integration tests.
# We only run it on Ubuntu since the Durable Task and Azure Functions features are not available
# on .NET Framework (net472) which is what we use the Windows runner for.
- name: Set up Durable Task and Azure Functions Integration Test Emulators
if: github.event_name != 'pull_request' && matrix.integration-tests && matrix.os == 'ubuntu-latest'
uses: ./.github/actions/azure-functions-integration-setup
id: azure-functions-setup
- name: Run Integration Tests
shell: bash
if: github.event_name != 'pull_request' && matrix.integration-tests
@@ -172,6 +180,9 @@ jobs:
OpenAI__ApiKey: ${{ secrets.OPENAI__APIKEY }}
OpenAI__ChatModelId: ${{ vars.OPENAI__CHATMODELID }}
OpenAI__ChatReasoningModelId: ${{ vars.OPENAI__CHATREASONINGMODELID }}
# Azure OpenAI Models
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }}
AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }}
# Azure AI Foundry
AzureAI__Endpoint: ${{ secrets.AZUREAI__ENDPOINT }}
AzureAI__DeploymentName: ${{ vars.AZUREAI__DEPLOYMENTNAME }}
+5 -1
View File
@@ -28,6 +28,8 @@ jobs:
UV_PYTHON: ${{ matrix.python-version }}
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Set up python and install the project
id: python-setup
uses: ./.github/actions/python-setup
@@ -46,4 +48,6 @@ jobs:
with:
extra_args: --config python/.pre-commit-config.yaml --all-files
- name: Run Mypy
run: uv run poe mypy
env:
GITHUB_BASE_REF: ${{ github.event.pull_request.base.ref || github.base_ref || 'main' }}
run: uv run poe ci-mypy
+8
View File
@@ -66,6 +66,11 @@ jobs:
AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }}
LOCAL_MCP_URL: ${{ vars.LOCAL_MCP__URL }}
# For Azure Functions integration tests
FUNCTIONS_WORKER_RUNTIME: "python"
DURABLE_TASK_SCHEDULER_CONNECTION_STRING: "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None"
AzureWebJobsStorage: "UseDevelopmentStorage=true"
defaults:
run:
working-directory: python
@@ -87,6 +92,9 @@ jobs:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Set up Azure Functions Integration Test Emulators
uses: ./.github/actions/azure-functions-integration-setup
id: azure-functions-setup
- name: Test with pytest
timeout-minutes: 10
run: uv run poe all-tests -n logical --dist loadfile --dist worksteal --timeout 300 --retries 3 --retry-delay 10
+13 -1
View File
@@ -205,10 +205,22 @@ agents.md
.claude/
WARP.md
# Azurite storage emulator files
*/__azurite_db_blob__.json
*/__azurite_db_blob_extent__.json
*/__azurite_db_queue__.json
*/__azurite_db_queue_extent__.json
*/__azurite_db_table__.json
*/__blobstorage__/
*/__queuestorage__/
# Azure Functions local settings
local.settings.json
# Frontend
**/frontend/node_modules/
**/frontend/.vite/
**/frontend/dist/
# Database files
*.db
*.db
+14
View File
@@ -97,6 +97,20 @@
<PackageVersion Include="Microsoft.Bot.ObjectModel.Json" Version="1.2025.1003.2" />
<PackageVersion Include="Microsoft.Bot.ObjectModel.PowerFx" Version="1.2025.1003.2" />
<PackageVersion Include="Microsoft.PowerFx.Interpreter" Version="1.4.0" />
<!-- Durable Task -->
<PackageVersion Include="Microsoft.DurableTask.Client" Version="1.16.2" />
<PackageVersion Include="Microsoft.DurableTask.Client.AzureManaged" Version="1.16.2-preview.1" />
<PackageVersion Include="Microsoft.DurableTask.Worker" Version="1.16.2" />
<PackageVersion Include="Microsoft.DurableTask.Worker.AzureManaged" Version="1.16.2-preview.1" />
<!-- Azure Functions -->
<PackageVersion Include="Microsoft.Azure.Functions.Worker" Version="2.50.0" />
<PackageVersion Include="Microsoft.Azure.Functions.Worker.ApplicationInsights" Version="2.50.0" />
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask" Version="1.9.0" />
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask.AzureManaged" Version="1.0.0" />
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.Http" Version="3.3.0" />
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" Version="2.1.0" />
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.Mcp" Version="1.0.0" />
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Sdk" Version="2.0.7" />
<!-- Community -->
<PackageVersion Include="System.Linq.Async" Version="6.0.3" />
<!-- Test -->
+23
View File
@@ -23,6 +23,17 @@
<Project Path="samples/AGUIClientServer/AGUIDojoServer/AGUIDojoServer.csproj" />
<Project Path="samples/AGUIClientServer/AGUIServer/AGUIServer.csproj" />
</Folder>
<Folder Name="/Samples/AzureFunctions/">
<File Path="samples/AzureFunctions/.editorconfig" />
<File Path="samples/AzureFunctions/README.md" />
<Project Path="samples/AzureFunctions/01_SingleAgent/01_SingleAgent.csproj" />
<Project Path="samples/AzureFunctions/02_AgentOrchestration_Chaining/02_AgentOrchestration_Chaining.csproj" />
<Project Path="samples/AzureFunctions/03_AgentOrchestration_Concurrency/03_AgentOrchestration_Concurrency.csproj" />
<Project Path="samples/AzureFunctions/04_AgentOrchestration_Conditionals/04_AgentOrchestration_Conditionals.csproj" />
<Project Path="samples/AzureFunctions/05_AgentOrchestration_HITL/05_AgentOrchestration_HITL.csproj" />
<Project Path="samples/AzureFunctions/06_LongRunningTools/06_LongRunningTools.csproj" />
<Project Path="samples/AzureFunctions/07_AgentAsMcpTool/07_AgentAsMcpTool.csproj" />
</Folder>
<Folder Name="/Samples/GettingStarted/">
<File Path="samples/GettingStarted/README.md" />
</Folder>
@@ -80,6 +91,9 @@
<Project Path="samples/GettingStarted/AgentWithOpenAI/Agent_OpenAI_Step01_Running/Agent_OpenAI_Step01_Running.csproj" />
<Project Path="samples/GettingStarted/AgentWithOpenAI/Agent_OpenAI_Step02_Reasoning/Agent_OpenAI_Step02_Reasoning.csproj" />
</Folder>
<Folder Name="/Samples/Purview/AgentWithPurview/">
<Project Path="samples/Purview/AgentWithPurview/AgentWithPurview.csproj" />
</Folder>
<Folder Name="/Samples/GettingStarted/AgentWithRAG/">
<File Path="samples/GettingStarted/AgentWithRAG/README.md" />
<Project Path="samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/AgentWithRAG_Step01_BasicTextRAG.csproj" />
@@ -162,6 +176,7 @@
<Folder Name="/Samples/HostedAgents/">
<Project Path="samples/HostedAgents/AgentsInWorkflows/AgentsInWorkflows.csproj" />
<Project Path="samples/HostedAgents/AgentWithTextSearchRag/AgentWithTextSearchRag.csproj" />
<Project Path="samples/HostedAgents/AgentWithHostedMCP/AgentWithHostedMCP.csproj" />
</Folder>
<Folder Name="/Solution Items/">
<File Path=".editorconfig" />
@@ -289,13 +304,16 @@
<Project Path="src/Microsoft.Agents.AI.AzureAI.Persistent/Microsoft.Agents.AI.AzureAI.Persistent.csproj" />
<Project Path="src/Microsoft.Agents.AI.CopilotStudio/Microsoft.Agents.AI.CopilotStudio.csproj" />
<Project Path="src/Microsoft.Agents.AI.DevUI/Microsoft.Agents.AI.DevUI.csproj" />
<Project Path="src/Microsoft.Agents.AI.DurableTask/Microsoft.Agents.AI.DurableTask.csproj" />
<Project Path="src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj" />
<Project Path="src/Microsoft.Agents.AI.Hosting.A2A/Microsoft.Agents.AI.Hosting.A2A.csproj" />
<Project Path="src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
<Project Path="src/Microsoft.Agents.AI.Hosting.AzureFunctions/Microsoft.Agents.AI.Hosting.AzureFunctions.csproj" />
<Project Path="src/Microsoft.Agents.AI.Hosting.OpenAI/Microsoft.Agents.AI.Hosting.OpenAI.csproj" />
<Project Path="src/Microsoft.Agents.AI.Hosting/Microsoft.Agents.AI.Hosting.csproj" />
<Project Path="src/Microsoft.Agents.AI.Mem0/Microsoft.Agents.AI.Mem0.csproj" />
<Project Path="src/Microsoft.Agents.AI.OpenAI/Microsoft.Agents.AI.OpenAI.csproj" />
<Project Path="src/Microsoft.Agents.AI.Purview/Microsoft.Agents.AI.Purview.csproj" />
<Project Path="src/Microsoft.Agents.AI.Workflows.Declarative/Microsoft.Agents.AI.Workflows.Declarative.csproj" />
<Project Path="src/Microsoft.Agents.AI.Workflows/Microsoft.Agents.AI.Workflows.csproj" />
<Project Path="src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj" />
@@ -305,7 +323,9 @@
<Project Path="tests/AgentConformance.IntegrationTests/AgentConformance.IntegrationTests.csproj" />
<Project Path="tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistent.IntegrationTests.csproj" />
<Project Path="tests/CopilotStudio.IntegrationTests/CopilotStudio.IntegrationTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/Microsoft.Agents.AI.DurableTask.IntegrationTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Mem0.IntegrationTests/Microsoft.Agents.AI.Mem0.IntegrationTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.csproj" />
<Project Path="tests/OpenAIAssistant.IntegrationTests/OpenAIAssistant.IntegrationTests.csproj" />
@@ -317,12 +337,15 @@
<Project Path="tests/Microsoft.Agents.AI.Abstractions.UnitTests/Microsoft.Agents.AI.Abstractions.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.AGUI.UnitTests/Microsoft.Agents.AI.AGUI.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.AzureAI.Persistent.UnitTests/Microsoft.Agents.AI.AzureAI.Persistent.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.DurableTask.UnitTests/Microsoft.Agents.AI.DurableTask.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Microsoft.Agents.AI.Hosting.A2A.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Hosting.UnitTests/Microsoft.Agents.AI.Hosting.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Mem0.UnitTests/Microsoft.Agents.AI.Mem0.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.OpenAI.UnitTests/Microsoft.Agents.AI.OpenAI.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Purview.UnitTests/Microsoft.Agents.AI.Purview.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.UnitTests/Microsoft.Agents.AI.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Workflows.UnitTests/Microsoft.Agents.AI.Workflows.UnitTests.csproj" />
+3 -3
View File
@@ -2,9 +2,9 @@
<PropertyGroup>
<!-- Central version prefix - applies to all nuget packages. -->
<VersionPrefix>1.0.0</VersionPrefix>
<PackageVersion Condition="'$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).251110.2</PackageVersion>
<PackageVersion Condition="'$(VersionSuffix)' == ''">$(VersionPrefix)-preview.251110.2</PackageVersion>
<GitTag>1.0.0-preview.251110.2</GitTag>
<PackageVersion Condition="'$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).251113.1</PackageVersion>
<PackageVersion Condition="'$(VersionSuffix)' == ''">$(VersionPrefix)-preview.251113.1</PackageVersion>
<GitTag>1.0.0-preview.251113.1</GitTag>
<Configurations>Debug;Release;Publish</Configurations>
<IsPackable>true</IsPackable>
@@ -0,0 +1,10 @@
# .editorconfig
[*.cs]
# See https://github.com/Azure/azure-functions-durable-extension/issues/3173
dotnet_diagnostic.DURABLE0001.severity = none
dotnet_diagnostic.DURABLE0002.severity = none
dotnet_diagnostic.DURABLE0003.severity = none
dotnet_diagnostic.DURABLE0004.severity = none
dotnet_diagnostic.DURABLE0005.severity = none
dotnet_diagnostic.DURABLE0006.severity = none
@@ -0,0 +1,42 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<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>SingleAgent</AssemblyName>
<RootNamespace>SingleAgent</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,37 @@
// 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.Azure.Functions.Worker.Builder;
using Microsoft.Extensions.Hosting;
using OpenAI;
// 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);
// 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))
.Build();
app.Run();
@@ -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=@dafx-joker@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": "@dafx-joker@your-thread-id",
"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
}
}
}
```
@@ -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.
@@ -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": "<AZURE_OPENAI_DEPLOYMENT>"
}
}
@@ -0,0 +1,42 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<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>AgentOrchestration_Chaining</AssemblyName>
<RootNamespace>AgentOrchestration_Chaining</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,92 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Net;
using System.Text.Json;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.DurableTask;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.DurableTask;
using Microsoft.DurableTask.Client;
namespace AgentOrchestration_Chaining;
public static class FunctionTriggers
{
public sealed record TextResponse(string Text);
[Function(nameof(RunOrchestrationAsync))]
public static async Task<string> RunOrchestrationAsync([OrchestrationTrigger] TaskOrchestrationContext context)
{
DurableAIAgent writer = context.GetAgent("WriterAgent");
AgentThread writerThread = writer.GetNewThread();
AgentRunResponse<TextResponse> initial = await writer.RunAsync<TextResponse>(
message: "Write a concise inspirational sentence about learning.",
thread: writerThread);
AgentRunResponse<TextResponse> refined = await writer.RunAsync<TextResponse>(
message: $"Improve this further while keeping it under 25 words: {initial.Result.Text}",
thread: writerThread);
return refined.Result.Text;
}
// POST /singleagent/run
[Function(nameof(StartOrchestrationAsync))]
public static async Task<HttpResponseData> StartOrchestrationAsync(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "singleagent/run")] HttpRequestData req,
[DurableClient] DurableTaskClient client)
{
string instanceId = await client.ScheduleNewOrchestrationInstanceAsync(
orchestratorName: nameof(RunOrchestrationAsync));
HttpResponseData response = req.CreateResponse(HttpStatusCode.Accepted);
await response.WriteAsJsonAsync(new
{
message = "Single-agent orchestration started.",
instanceId,
statusQueryGetUri = GetStatusQueryGetUri(req, instanceId),
});
return response;
}
// GET /singleagent/status/{instanceId}
[Function(nameof(GetOrchestrationStatusAsync))]
public static async Task<HttpResponseData> GetOrchestrationStatusAsync(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "singleagent/status/{instanceId}")] HttpRequestData req,
string instanceId,
[DurableClient] DurableTaskClient client)
{
OrchestrationMetadata? status = await client.GetInstanceAsync(
instanceId,
getInputsAndOutputs: true,
req.FunctionContext.CancellationToken);
if (status is null)
{
HttpResponseData notFound = req.CreateResponse(HttpStatusCode.NotFound);
await notFound.WriteAsJsonAsync(new { error = "Instance not found" });
return notFound;
}
HttpResponseData response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new
{
instanceId = status.InstanceId,
runtimeStatus = status.RuntimeStatus.ToString(),
input = status.SerializedInput is not null ? (object)status.ReadInputAs<JsonElement>() : null,
output = status.SerializedOutput is not null ? (object)status.ReadOutputAs<JsonElement>() : null,
failureDetails = status.FailureDetails
});
return response;
}
private static string GetStatusQueryGetUri(HttpRequestData req, string instanceId)
{
// NOTE: This can be made more robust by considering the value of
// request headers like "X-Forwarded-Host" and "X-Forwarded-Proto".
string authority = $"{req.Url.Scheme}://{req.Url.Authority}";
return $"{authority}/api/singleagent/status/{instanceId}";
}
}
@@ -0,0 +1,40 @@
// 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.Azure.Functions.Worker.Builder;
using Microsoft.Extensions.Hosting;
using OpenAI;
// 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());
// Single agent used by the orchestration to demonstrate sequential calls on the same thread.
const string WriterName = "WriterAgent";
const string WriterInstructions =
"""
You refine short pieces of text. When given an initial sentence you enhance it;
when given an improved sentence you polish it further.
""";
AIAgent writerAgent = client.GetChatClient(deploymentName).CreateAIAgent(WriterInstructions, WriterName);
using IHost app = FunctionsApplication
.CreateBuilder(args)
.ConfigureFunctionsWebApplication()
.ConfigureDurableAgents(options => options.AddAIAgent(writerAgent))
.Build();
app.Run();
@@ -0,0 +1,59 @@
# Single Agent Orchestration Sample
This sample demonstrates how to use the Durable Agent Framework (DAFx) to create a simple Azure Functions app that orchestrates sequential calls to a single AI agent using the same conversation thread for context continuity.
## Key Concepts Demonstrated
- Orchestrating multiple interactions with the same agent in a deterministic order
- Using the same `AgentThread` across multiple calls to maintain conversational context
- Durable orchestration with automatic checkpointing and resumption from failures
- HTTP API integration for starting and monitoring orchestrations
## 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 start the orchestration.
You can use the `demo.http` file to start the orchestration, or a command line tool like `curl` as shown below:
Bash (Linux/macOS/WSL):
```bash
curl -X POST http://localhost:7071/api/singleagent/run
```
PowerShell:
```powershell
Invoke-RestMethod -Method Post -Uri http://localhost:7071/api/singleagent/run
```
The response will be a JSON object that looks something like the following, which indicates that the orchestration has started.
```json
{
"message": "Single-agent orchestration started.",
"instanceId": "86313f1d45fb42eeb50b1852626bf3ff",
"statusQueryGetUri": "http://localhost:7071/api/singleagent/status/86313f1d45fb42eeb50b1852626bf3ff"
}
```
The orchestration will proceed to run the WriterAgent twice in sequence:
1. First, it writes an inspirational sentence about learning
2. Then, it refines the initial output using the same conversation thread
Once the orchestration has completed, you can get the status of the orchestration by sending a GET request to the `statusQueryGetUri` URL. The response will be a JSON object that looks something like the following:
```json
{
"failureDetails": null,
"input": null,
"instanceId": "86313f1d45fb42eeb50b1852626bf3ff",
"output": "Learning serves as the key, opening doors to boundless opportunities and a brighter future.",
"runtimeStatus": "Completed"
}
```
@@ -0,0 +1,3 @@
### Start the single-agent orchestration
POST http://localhost:7071/api/singleagent/run
@@ -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": "<AZURE_OPENAI_DEPLOYMENT>"
}
}
@@ -0,0 +1,42 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<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>AgentOrchestration_Concurrency</AssemblyName>
<RootNamespace>AgentOrchestration_Concurrency</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,116 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Net;
using System.Text.Json;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.DurableTask;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.DurableTask;
using Microsoft.DurableTask.Client;
namespace AgentOrchestration_Concurrency;
public static class FunctionsTriggers
{
public sealed record TextResponse(string Text);
[Function(nameof(RunOrchestrationAsync))]
public static async Task<object> RunOrchestrationAsync([OrchestrationTrigger] TaskOrchestrationContext context)
{
// Get the prompt from the orchestration input
string prompt = context.GetInput<string>() ?? throw new InvalidOperationException("Prompt is required");
// Get both agents
DurableAIAgent physicist = context.GetAgent("PhysicistAgent");
DurableAIAgent chemist = context.GetAgent("ChemistAgent");
// Start both agent runs concurrently
Task<AgentRunResponse<TextResponse>> physicistTask = physicist.RunAsync<TextResponse>(prompt);
Task<AgentRunResponse<TextResponse>> chemistTask = chemist.RunAsync<TextResponse>(prompt);
// Wait for both tasks to complete using Task.WhenAll
await Task.WhenAll(physicistTask, chemistTask);
// Get the results
TextResponse physicistResponse = (await physicistTask).Result;
TextResponse chemistResponse = (await chemistTask).Result;
// Return the result as a structured, anonymous type
return new
{
physicist = physicistResponse.Text,
chemist = chemistResponse.Text,
};
}
// POST /multiagent/run
[Function(nameof(StartOrchestrationAsync))]
public static async Task<HttpResponseData> StartOrchestrationAsync(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "multiagent/run")] HttpRequestData req,
[DurableClient] DurableTaskClient client)
{
// Read the prompt from the request body
string? prompt = await req.ReadAsStringAsync();
if (string.IsNullOrWhiteSpace(prompt))
{
HttpResponseData badRequestResponse = req.CreateResponse(HttpStatusCode.BadRequest);
await badRequestResponse.WriteAsJsonAsync(new { error = "Prompt is required" });
return badRequestResponse;
}
string instanceId = await client.ScheduleNewOrchestrationInstanceAsync(
orchestratorName: nameof(RunOrchestrationAsync),
input: prompt);
HttpResponseData response = req.CreateResponse(HttpStatusCode.Accepted);
await response.WriteAsJsonAsync(new
{
message = "Multi-agent concurrent orchestration started.",
prompt,
instanceId,
statusQueryGetUri = GetStatusQueryGetUri(req, instanceId),
});
return response;
}
// GET /multiagent/status/{instanceId}
[Function(nameof(GetOrchestrationStatusAsync))]
public static async Task<HttpResponseData> GetOrchestrationStatusAsync(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "multiagent/status/{instanceId}")] HttpRequestData req,
string instanceId,
[DurableClient] DurableTaskClient client)
{
OrchestrationMetadata? status = await client.GetInstanceAsync(
instanceId,
getInputsAndOutputs: true,
req.FunctionContext.CancellationToken);
if (status is null)
{
HttpResponseData notFound = req.CreateResponse(HttpStatusCode.NotFound);
await notFound.WriteAsJsonAsync(new { error = "Instance not found" });
return notFound;
}
HttpResponseData response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new
{
instanceId = status.InstanceId,
runtimeStatus = status.RuntimeStatus.ToString(),
input = status.SerializedInput is not null ? (object)status.ReadInputAs<JsonElement>() : null,
output = status.SerializedOutput is not null ? (object)status.ReadOutputAs<JsonElement>() : null,
failureDetails = status.FailureDetails
});
return response;
}
private static string GetStatusQueryGetUri(HttpRequestData req, string instanceId)
{
// NOTE: This can be made more robust by considering the value of
// request headers like "X-Forwarded-Host" and "X-Forwarded-Proto".
string authority = $"{req.Url.Scheme}://{req.Url.Authority}";
return $"{authority}/api/multiagent/status/{instanceId}";
}
}
@@ -0,0 +1,44 @@
// 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.Azure.Functions.Worker.Builder;
using Microsoft.Extensions.Hosting;
using OpenAI;
// 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());
// Two agents used by the orchestration to demonstrate concurrent execution.
const string PhysicistName = "PhysicistAgent";
const string PhysicistInstructions = "You are an expert in physics. You answer questions from a physics perspective.";
const string ChemistName = "ChemistAgent";
const string ChemistInstructions = "You are an expert in chemistry. You answer questions from a chemistry perspective.";
AIAgent physicistAgent = client.GetChatClient(deploymentName).CreateAIAgent(PhysicistInstructions, PhysicistName);
AIAgent chemistAgent = client.GetChatClient(deploymentName).CreateAIAgent(ChemistInstructions, ChemistName);
using IHost app = FunctionsApplication
.CreateBuilder(args)
.ConfigureFunctionsWebApplication()
.ConfigureDurableAgents(options =>
{
options.AddAIAgent(physicistAgent);
options.AddAIAgent(chemistAgent);
})
.Build();
app.Run();
@@ -0,0 +1,65 @@
# Multi-Agent Concurrent Orchestration Sample
This sample demonstrates how to use the Durable Agent Framework (DAFx) to create an Azure Functions app that orchestrates concurrent execution of multiple AI agents, each with specialized expertise, to provide comprehensive answers to complex questions.
## Key Concepts Demonstrated
- Multi-agent orchestration with specialized AI agents (physics and chemistry)
- Concurrent execution using the fan-out/fan-in pattern for improved performance and distributed processing
- Response aggregation from multiple agents into a unified result
- Durable orchestration with automatic checkpointing and resumption from failures
## 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 with a custom prompt to the orchestration.
You can use the `demo.http` file to send a message to the agents, or a command line tool like `curl` as shown below:
Bash (Linux/macOS/WSL):
```bash
curl -X POST http://localhost:7071/api/multiagent/run \
-H "Content-Type: text/plain" \
-d "What is temperature?"
```
PowerShell:
```powershell
Invoke-RestMethod -Method Post `
-Uri http://localhost:7071/api/multiagent/run `
-ContentType text/plain `
-Body "What is temperature?"
```
The response will be a JSON object that looks something like the following, which indicates that the orchestration has started.
```json
{
"message": "Multi-agent concurrent orchestration started.",
"prompt": "What is temperature?",
"instanceId": "e7e29999b6b8424682b3539292afc9ed",
"statusQueryGetUri": "http://localhost:7071/api/multiagent/status/e7e29999b6b8424682b3539292afc9ed"
}
```
The orchestration will run both the PhysicistAgent and ChemistAgent concurrently, asking them the same question. Their responses will be combined to provide a comprehensive answer covering both physical and chemical aspects.
Once the orchestration has completed, you can get the status of the orchestration by sending a GET request to the `statusQueryGetUri` URL. The response will be a JSON object that looks something like the following:
```json
{
"failureDetails": null,
"input": "What is temperature?",
"instanceId": "e7e29999b6b8424682b3539292afc9ed",
"output": {
"physicist": "Temperature is a measure of the average kinetic energy of particles in a system. From a physics perspective, it represents the thermal energy and determines the direction of heat flow between objects.",
"chemist": "From a chemistry perspective, temperature is crucial for chemical reactions as it affects reaction rates through the Arrhenius equation. It influences the equilibrium position of reversible reactions and determines the physical state of substances."
},
"runtimeStatus": "Completed"
}
```
@@ -0,0 +1,5 @@
### Start the multi-agent concurrent orchestration
POST http://localhost:7071/api/multiagent/run
Content-Type: text/plain
What is temperature?
@@ -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": "<AZURE_OPENAI_DEPLOYMENT>"
}
}
@@ -0,0 +1,42 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<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>AgentOrchestration_Conditionals</AssemblyName>
<RootNamespace>AgentOrchestration_Conditionals</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,143 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Net;
using System.Text.Json;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.DurableTask;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.DurableTask;
using Microsoft.DurableTask.Client;
namespace AgentOrchestration_Conditionals;
public static class FunctionTriggers
{
[Function(nameof(RunOrchestrationAsync))]
public static async Task<string> RunOrchestrationAsync([OrchestrationTrigger] TaskOrchestrationContext context)
{
// Get the email from the orchestration input
Email email = context.GetInput<Email>() ?? throw new InvalidOperationException("Email is required");
// Get the spam detection agent
DurableAIAgent spamDetectionAgent = context.GetAgent("SpamDetectionAgent");
AgentThread spamThread = spamDetectionAgent.GetNewThread();
// Step 1: Check if the email is spam
AgentRunResponse<DetectionResult> spamDetectionResponse = await spamDetectionAgent.RunAsync<DetectionResult>(
message:
$"""
Analyze this email for spam content and return a JSON response with 'is_spam' (boolean) and 'reason' (string) fields:
Email ID: {email.EmailId}
Content: {email.EmailContent}
""",
thread: spamThread);
DetectionResult result = spamDetectionResponse.Result;
// Step 2: Conditional logic based on spam detection result
if (result.IsSpam)
{
// Handle spam email
return await context.CallActivityAsync<string>(nameof(HandleSpamEmail), result.Reason);
}
// Generate and send response for legitimate email
DurableAIAgent emailAssistantAgent = context.GetAgent("EmailAssistantAgent");
AgentThread emailThread = emailAssistantAgent.GetNewThread();
AgentRunResponse<EmailResponse> emailAssistantResponse = await emailAssistantAgent.RunAsync<EmailResponse>(
message:
$"""
Draft a professional response to this email. Return a JSON response with a 'response' field containing the reply:
Email ID: {email.EmailId}
Content: {email.EmailContent}
""",
thread: emailThread);
EmailResponse emailResponse = emailAssistantResponse.Result;
return await context.CallActivityAsync<string>(nameof(SendEmail), emailResponse.Response);
}
[Function(nameof(HandleSpamEmail))]
public static string HandleSpamEmail([ActivityTrigger] string reason)
{
return $"Email marked as spam: {reason}";
}
[Function(nameof(SendEmail))]
public static string SendEmail([ActivityTrigger] string message)
{
return $"Email sent: {message}";
}
// POST /spamdetection/run
[Function(nameof(StartOrchestrationAsync))]
public static async Task<HttpResponseData> StartOrchestrationAsync(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "spamdetection/run")] HttpRequestData req,
[DurableClient] DurableTaskClient client)
{
// Read the email from the request body
Email? email = await req.ReadFromJsonAsync<Email>();
if (email is null || string.IsNullOrWhiteSpace(email.EmailContent))
{
HttpResponseData badRequestResponse = req.CreateResponse(HttpStatusCode.BadRequest);
await badRequestResponse.WriteAsJsonAsync(new { error = "Email with content is required" });
return badRequestResponse;
}
string instanceId = await client.ScheduleNewOrchestrationInstanceAsync(
orchestratorName: nameof(RunOrchestrationAsync),
input: email);
HttpResponseData response = req.CreateResponse(HttpStatusCode.Accepted);
await response.WriteAsJsonAsync(new
{
message = "Spam detection orchestration started.",
emailId = email.EmailId,
instanceId,
statusQueryGetUri = GetStatusQueryGetUri(req, instanceId),
});
return response;
}
// GET /spamdetection/status/{instanceId}
[Function(nameof(GetOrchestrationStatusAsync))]
public static async Task<HttpResponseData> GetOrchestrationStatusAsync(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "spamdetection/status/{instanceId}")] HttpRequestData req,
string instanceId,
[DurableClient] DurableTaskClient client)
{
OrchestrationMetadata? status = await client.GetInstanceAsync(
instanceId,
getInputsAndOutputs: true,
req.FunctionContext.CancellationToken);
if (status is null)
{
HttpResponseData notFound = req.CreateResponse(HttpStatusCode.NotFound);
await notFound.WriteAsJsonAsync(new { error = "Instance not found" });
return notFound;
}
HttpResponseData response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new
{
instanceId = status.InstanceId,
runtimeStatus = status.RuntimeStatus.ToString(),
input = status.SerializedInput is not null ? (object)status.ReadInputAs<JsonElement>() : null,
output = status.SerializedOutput is not null ? (object)status.ReadOutputAs<JsonElement>() : null,
failureDetails = status.FailureDetails
});
return response;
}
private static string GetStatusQueryGetUri(HttpRequestData req, string instanceId)
{
// NOTE: This can be made more robust by considering the value of
// request headers like "X-Forwarded-Host" and "X-Forwarded-Proto".
string authority = $"{req.Url.Scheme}://{req.Url.Authority}";
return $"{authority}/api/spamdetection/status/{instanceId}";
}
}
@@ -0,0 +1,38 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace AgentOrchestration_Conditionals;
/// <summary>
/// Represents an email input for spam detection and response generation.
/// </summary>
public sealed class Email
{
[JsonPropertyName("email_id")]
public string EmailId { get; set; } = string.Empty;
[JsonPropertyName("email_content")]
public string EmailContent { get; set; } = string.Empty;
}
/// <summary>
/// Represents the result of spam detection analysis.
/// </summary>
public sealed class DetectionResult
{
[JsonPropertyName("is_spam")]
public bool IsSpam { get; set; }
[JsonPropertyName("reason")]
public string Reason { get; set; } = string.Empty;
}
/// <summary>
/// Represents a generated email response.
/// </summary>
public sealed class EmailResponse
{
[JsonPropertyName("response")]
public string Response { get; set; } = string.Empty;
}
@@ -0,0 +1,47 @@
// 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.Azure.Functions.Worker.Builder;
using Microsoft.Extensions.Hosting;
using OpenAI;
// 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());
// Two agents used by the orchestration to demonstrate conditional logic.
const string SpamDetectionName = "SpamDetectionAgent";
const string SpamDetectionInstructions = "You are a spam detection assistant that identifies spam emails.";
const string EmailAssistantName = "EmailAssistantAgent";
const string EmailAssistantInstructions = "You are an email assistant that helps users draft responses to emails with professionalism.";
AIAgent spamDetectionAgent = client.GetChatClient(deploymentName)
.CreateAIAgent(SpamDetectionInstructions, SpamDetectionName);
AIAgent emailAssistantAgent = client.GetChatClient(deploymentName)
.CreateAIAgent(EmailAssistantInstructions, EmailAssistantName);
using IHost app = FunctionsApplication
.CreateBuilder(args)
.ConfigureFunctionsWebApplication()
.ConfigureDurableAgents(options =>
{
options.AddAIAgent(spamDetectionAgent);
options.AddAIAgent(emailAssistantAgent);
})
.Build();
app.Run();
@@ -0,0 +1,113 @@
# Multi-Agent Orchestration with Conditionals Sample
This sample demonstrates how to use the Durable Agent Framework (DAFx) to create a multi-agent orchestration workflow that includes conditional logic. The workflow implements a spam detection system that processes emails and takes different actions based on whether the email is identified as spam or legitimate.
## Key Concepts Demonstrated
- Multi-agent orchestration with conditional logic and different processing paths
- Spam detection using AI agent analysis
- Structured output from agents for reliable processing
- Activity functions for integrating non-agentic workflow actions
## 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 with email data to the orchestration.
You can use the `demo.http` file to send email data to the agents, or a command line tool like `curl` as shown below:
Bash (Linux/macOS/WSL):
```bash
# Test with a legitimate email
curl -X POST http://localhost:7071/api/spamdetection/run \
-H "Content-Type: application/json" \
-d '{
"email_id": "email-001",
"email_content": "Hi John, I hope you are doing well. I wanted to follow up on our meeting yesterday about the quarterly report. Could you please send me the updated figures by Friday? Thanks!"
}'
# Test with a spam email
curl -X POST http://localhost:7071/api/spamdetection/run \
-H "Content-Type: application/json" \
-d '{
"email_id": "email-002",
"email_content": "URGENT! You have won $1,000,000! Click here now to claim your prize! Limited time offer! Do not miss out!"
}'
```
PowerShell:
```powershell
# Test with a legitimate email
$body = @{
email_id = "email-001"
email_content = "Hi John, I hope you are doing well. I wanted to follow up on our meeting yesterday about the quarterly report. Could you please send me the updated figures by Friday? Thanks!"
} | ConvertTo-Json
Invoke-RestMethod -Method Post `
-Uri http://localhost:7071/api/spamdetection/run `
-ContentType application/json `
-Body $body
# Test with a spam email
$body = @{
email_id = "email-002"
email_content = "URGENT! You have won $1,000,000! Click here now to claim your prize! Limited time offer! Do not miss out!"
} | ConvertTo-Json
Invoke-RestMethod -Method Post `
-Uri http://localhost:7071/api/spamdetection/run `
-ContentType application/json `
-Body $body
```
The response from either input will be a JSON object that looks something like the following, which indicates that the orchestration has started.
```json
{
"message": "Spam detection orchestration started.",
"emailId": "email-001",
"instanceId": "555dbbb63f75406db2edf9f1f092de95",
"statusQueryGetUri": "http://localhost:7071/api/spamdetection/status/555dbbb63f75406db2edf9f1f092de95"
}
```
The orchestration will:
1. Analyze the email content using the SpamDetectionAgent
2. If spam: Mark the email as spam with a reason
3. If legitimate: Use the EmailAssistantAgent to draft a professional response and "send" it
Once the orchestration has completed, you can get the status of the orchestration by sending a GET request to the `statusQueryGetUri` URL. The response for the legitimate email will be a JSON object that looks something like the following:
```json
{
"failureDetails": null,
"input": {
"email_content": "Hi John, I hope you're doing well. I wanted to follow up on our meeting yesterday about the quarterly report. Could you please send me the updated figures by Friday? Thanks!",
"email_id": "email-001"
},
"instanceId": "555dbbb63f75406db2edf9f1f092de95",
"output": "Email sent: Subject: Re: Follow-Up on Quarterly Report\n\nHi [Recipient's Name],\n\nI hope this message finds you well. Thank you for your patience. I will ensure the updated figures for the quarterly report are sent to you by Friday.\n\nIf you have any further questions or need additional information, please feel free to reach out.\n\nBest regards,\n\nJohn",
"runtimeStatus": "Completed"
}
```
The response for the spam email will be a JSON object that looks something like the following, which indicates that the email was marked as spam:
```json
{
"failureDetails": null,
"input": {
"email_content": "URGENT! You have won $1,000,000! Click here now to claim your prize! Limited time offer! Do not miss out!",
"email_id": "email-002"
},
"instanceId": "555dbbb63f75406db2edf9f1f092de95",
"output": "Email marked as spam: The email contains misleading claims of winning a large sum of money and encourages immediate action, which are common characteristics of spam.",
"runtimeStatus": "Completed"
}
```
@@ -0,0 +1,18 @@
### Test spam detection with a legitimate email
POST http://localhost:7071/api/spamdetection/run
Content-Type: application/json
{
"email_id": "email-001",
"email_content": "Hi John, I hope you're doing well. I wanted to follow up on our meeting yesterday about the quarterly report. Could you please send me the updated figures by Friday? Thanks!"
}
### Test spam detection with a spam email
POST http://localhost:7071/api/spamdetection/run
Content-Type: application/json
{
"email_id": "email-002",
"email_content": "URGENT! You've won $1,000,000! Click here now to claim your prize! Limited time offer! Don't miss out!"
}
@@ -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": "<AZURE_OPENAI_DEPLOYMENT>"
}
}
@@ -0,0 +1,43 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<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>AgentOrchestration_HITL</AssemblyName>
<RootNamespace>AgentOrchestration_HITL</RootNamespace>
<NoWarn>$(NoWarn);DURABLE0001;DURABLE0002;DURABLE0003;DURABLE0004;DURABLE0005;DURABLE0006</NoWarn>
</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,229 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Net;
using System.Text.Json;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.DurableTask;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.DurableTask;
using Microsoft.DurableTask.Client;
using Microsoft.Extensions.Logging;
namespace AgentOrchestration_HITL;
public static class FunctionTriggers
{
[Function(nameof(RunOrchestrationAsync))]
public static async Task<object> RunOrchestrationAsync(
[OrchestrationTrigger] TaskOrchestrationContext context)
{
// Get the input from the orchestration
ContentGenerationInput input = context.GetInput<ContentGenerationInput>()
?? throw new InvalidOperationException("Content generation input is required");
// Get the writer agent
DurableAIAgent writerAgent = context.GetAgent("WriterAgent");
AgentThread writerThread = writerAgent.GetNewThread();
// Set initial status
context.SetCustomStatus($"Starting content generation for topic: {input.Topic}");
// Step 1: Generate initial content
AgentRunResponse<GeneratedContent> writerResponse = await writerAgent.RunAsync<GeneratedContent>(
message: $"Write a short article about '{input.Topic}'.",
thread: writerThread);
GeneratedContent content = writerResponse.Result;
// Human-in-the-loop iteration - we set a maximum number of attempts to avoid infinite loops
int iterationCount = 0;
while (iterationCount++ < input.MaxReviewAttempts)
{
context.SetCustomStatus(
$"Requesting human feedback. Iteration #{iterationCount}. Timeout: {input.ApprovalTimeoutHours} hour(s).");
// Step 2: Notify user to review the content
await context.CallActivityAsync(nameof(NotifyUserForApproval), content);
// Step 3: Wait for human feedback with configurable timeout
HumanApprovalResponse humanResponse;
try
{
humanResponse = await context.WaitForExternalEvent<HumanApprovalResponse>(
eventName: "HumanApproval",
timeout: TimeSpan.FromHours(input.ApprovalTimeoutHours));
}
catch (OperationCanceledException)
{
// Timeout occurred - treat as rejection
context.SetCustomStatus(
$"Human approval timed out after {input.ApprovalTimeoutHours} hour(s). Treating as rejection.");
throw new TimeoutException($"Human approval timed out after {input.ApprovalTimeoutHours} hour(s).");
}
if (humanResponse.Approved)
{
context.SetCustomStatus("Content approved by human reviewer. Publishing content...");
// Step 4: Publish the approved content
await context.CallActivityAsync(nameof(PublishContent), content);
context.SetCustomStatus($"Content published successfully at {context.CurrentUtcDateTime:s}");
return new { content = content.Content };
}
context.SetCustomStatus("Content rejected by human reviewer. Incorporating feedback and regenerating...");
// Incorporate human feedback and regenerate
writerResponse = await writerAgent.RunAsync<GeneratedContent>(
message: $"""
The content was rejected by a human reviewer. Please rewrite the article incorporating their feedback.
Human Feedback: {humanResponse.Feedback}
""",
thread: writerThread);
content = writerResponse.Result;
}
// If we reach here, it means we exhausted the maximum number of iterations
throw new InvalidOperationException(
$"Content could not be approved after {input.MaxReviewAttempts} iterations.");
}
// POST /hitl/run
[Function(nameof(StartOrchestrationAsync))]
public static async Task<HttpResponseData> StartOrchestrationAsync(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "hitl/run")] HttpRequestData req,
[DurableClient] DurableTaskClient client)
{
// Read the input from the request body
ContentGenerationInput? input = await req.ReadFromJsonAsync<ContentGenerationInput>();
if (input is null || string.IsNullOrWhiteSpace(input.Topic))
{
HttpResponseData badRequestResponse = req.CreateResponse(HttpStatusCode.BadRequest);
await badRequestResponse.WriteAsJsonAsync(new { error = "Topic is required" });
return badRequestResponse;
}
string instanceId = await client.ScheduleNewOrchestrationInstanceAsync(
orchestratorName: nameof(RunOrchestrationAsync),
input: input);
HttpResponseData response = req.CreateResponse(HttpStatusCode.Accepted);
await response.WriteAsJsonAsync(new
{
message = "HITL content generation orchestration started.",
topic = input.Topic,
instanceId,
statusQueryGetUri = GetStatusQueryGetUri(req, instanceId),
});
return response;
}
// POST /hitl/approve/{instanceId}
[Function(nameof(SendHumanApprovalAsync))]
public static async Task<HttpResponseData> SendHumanApprovalAsync(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "hitl/approve/{instanceId}")] HttpRequestData req,
string instanceId,
[DurableClient] DurableTaskClient client)
{
// Read the approval response from the request body
HumanApprovalResponse? approvalResponse = await req.ReadFromJsonAsync<HumanApprovalResponse>();
if (approvalResponse is null)
{
HttpResponseData badRequestResponse = req.CreateResponse(HttpStatusCode.BadRequest);
await badRequestResponse.WriteAsJsonAsync(new { error = "Approval response is required" });
return badRequestResponse;
}
// Send the approval event to the orchestration
await client.RaiseEventAsync(instanceId, "HumanApproval", approvalResponse);
HttpResponseData response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new
{
message = "Human approval sent to orchestration.",
instanceId,
approved = approvalResponse.Approved
});
return response;
}
// GET /hitl/status/{instanceId}
[Function(nameof(GetOrchestrationStatusAsync))]
public static async Task<HttpResponseData> GetOrchestrationStatusAsync(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "hitl/status/{instanceId}")] HttpRequestData req,
string instanceId,
[DurableClient] DurableTaskClient client)
{
OrchestrationMetadata? status = await client.GetInstanceAsync(
instanceId,
getInputsAndOutputs: true,
req.FunctionContext.CancellationToken);
if (status is null)
{
HttpResponseData notFound = req.CreateResponse(HttpStatusCode.NotFound);
await notFound.WriteAsJsonAsync(new { error = "Instance not found" });
return notFound;
}
HttpResponseData response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new
{
instanceId = status.InstanceId,
runtimeStatus = status.RuntimeStatus.ToString(),
workflowStatus = status.SerializedCustomStatus is not null ? (object)status.ReadCustomStatusAs<JsonElement>() : null,
input = status.SerializedInput is not null ? (object)status.ReadInputAs<JsonElement>() : null,
output = status.SerializedOutput is not null ? (object)status.ReadOutputAs<JsonElement>() : null,
failureDetails = status.FailureDetails
});
return response;
}
[Function(nameof(NotifyUserForApproval))]
public static void NotifyUserForApproval(
[ActivityTrigger] GeneratedContent content,
FunctionContext functionContext)
{
ILogger logger = functionContext.GetLogger(nameof(NotifyUserForApproval));
// In a real implementation, this would send notifications via email, SMS, etc.
logger.LogInformation(
"""
NOTIFICATION: Please review the following content for approval:
Title: {Title}
Content: {Content}
Use the approval endpoint to approve or reject this content.
""",
content.Title,
content.Content);
}
[Function(nameof(PublishContent))]
public static void PublishContent(
[ActivityTrigger] GeneratedContent content,
FunctionContext functionContext)
{
ILogger logger = functionContext.GetLogger(nameof(PublishContent));
// In a real implementation, this would publish to a CMS, website, etc.
logger.LogInformation(
"""
PUBLISHING: Content has been published successfully.
Title: {Title}
Content: {Content}
""",
content.Title,
content.Content);
}
private static string GetStatusQueryGetUri(HttpRequestData req, string instanceId)
{
// NOTE: This can be made more robust by considering the value of
// request headers like "X-Forwarded-Host" and "X-Forwarded-Proto".
string authority = $"{req.Url.Scheme}://{req.Url.Authority}";
return $"{authority}/api/hitl/status/{instanceId}";
}
}
@@ -0,0 +1,44 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace AgentOrchestration_HITL;
/// <summary>
/// Represents the input for the Human-in-the-Loop content generation workflow.
/// </summary>
public sealed class ContentGenerationInput
{
[JsonPropertyName("topic")]
public string Topic { get; set; } = string.Empty;
[JsonPropertyName("max_review_attempts")]
public int MaxReviewAttempts { get; set; } = 3;
[JsonPropertyName("approval_timeout_hours")]
public float ApprovalTimeoutHours { get; set; } = 72;
}
/// <summary>
/// Represents the content generated by the writer agent.
/// </summary>
public sealed class GeneratedContent
{
[JsonPropertyName("title")]
public string Title { get; set; } = string.Empty;
[JsonPropertyName("content")]
public string Content { get; set; } = string.Empty;
}
/// <summary>
/// Represents the human approval response.
/// </summary>
public sealed class HumanApprovalResponse
{
[JsonPropertyName("approved")]
public bool Approved { get; set; }
[JsonPropertyName("feedback")]
public string Feedback { get; set; } = string.Empty;
}
@@ -0,0 +1,40 @@
// 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.Azure.Functions.Worker.Builder;
using Microsoft.Extensions.Hosting;
using OpenAI;
// 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());
// Single agent used by the orchestration to demonstrate human-in-the-loop workflow.
const string WriterName = "WriterAgent";
const string WriterInstructions =
"""
You are a professional content writer who creates high-quality articles on various topics.
You write engaging, informative, and well-structured content that follows best practices for readability and accuracy.
""";
AIAgent writerAgent = client.GetChatClient(deploymentName).CreateAIAgent(WriterInstructions, WriterName);
using IHost app = FunctionsApplication
.CreateBuilder(args)
.ConfigureFunctionsWebApplication()
.ConfigureDurableAgents(options => options.AddAIAgent(writerAgent))
.Build();
app.Run();
@@ -0,0 +1,126 @@
# Multi-Agent Orchestration with Human-in-the-Loop Sample
This sample demonstrates how to use the Durable Agent Framework (DAFx) to create a human-in-the-loop (HITL) workflow using a single AI agent. The workflow uses a writer agent to generate content and requires human approval on every iteration, emphasizing the human-in-the-loop pattern.
## Key Concepts Demonstrated
- Single-agent orchestration
- Human-in-the-loop feedback loop using external events (`WaitForExternalEvent`)
- Activity functions for non-agentic workflow steps
- Iterative content refinement based on human feedback
- Custom status tracking for workflow visibility
- Error handling with maximum retry attempts and timeout handling for human approval
## 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 with a topic to start the content generation workflow.
You can use the `demo.http` file to send a topic to the agents, or a command line tool like `curl` as shown below:
Bash (Linux/macOS/WSL):
```bash
curl -X POST http://localhost:7071/api/hitl/run \
-H "Content-Type: application/json" \
-d '{
"topic": "The Future of Artificial Intelligence",
"max_review_attempts": 3,
"timeout_minutes": 5
}'
```
PowerShell:
```powershell
$body = @{
topic = "The Future of Artificial Intelligence"
max_review_attempts = 3
timeout_minutes = 5
} | ConvertTo-Json
Invoke-RestMethod -Method Post `
-Uri http://localhost:7071/api/hitl/run `
-ContentType application/json `
-Body $body
```
The response will be a JSON object that looks something like the following, which indicates that the orchestration has started.
```json
{
"message": "HITL content generation orchestration started.",
"topic": "The Future of Artificial Intelligence",
"instanceId": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6",
"statusQueryGetUri": "http://localhost:7071/api/hitl/status/a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6"
}
```
The orchestration will:
1. Generate initial content using the WriterAgent
2. Notify the user to review the content
3. Wait for human feedback via external event (configurable timeout)
4. If approved by human, publish the content
5. If rejected by human, incorporate feedback and regenerate content
6. If approval timeout occurs, treat as rejection and fail the orchestration
7. Repeat until human approval is received or maximum loop iterations are reached
Once the orchestration is waiting for human approval, you can send approval or rejection using the approval endpoint:
Bash (Linux/macOS/WSL):
```bash
# Approve the content
curl -X POST http://localhost:7071/api/hitl/approve/a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6 \
-H "Content-Type: application/json" \
-d '{
"approved": true,
"feedback": "Great article! The content is well-structured and informative."
}'
# Reject the content with feedback
curl -X POST http://localhost:7071/api/hitl/approve/a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6 \
-H "Content-Type: application/json" \
-d '{
"approved": false,
"feedback": "The article needs more technical depth and better examples."
}'
```
PowerShell:
```powershell
# Approve the content
Invoke-RestMethod -Method Post `
-Uri http://localhost:7071/api/hitl/approve/a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6 `
-ContentType application/json `
-Body '{ "approved": true, "feedback": "Great article! The content is well-structured and informative." }'
# Reject the content with feedback
Invoke-RestMethod -Method Post `
-Uri http://localhost:7071/api/hitl/approve/a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6 `
-ContentType application/json `
-Body '{ "approved": false, "feedback": "The article needs more technical depth and better examples." }'
```
Once the orchestration has completed, you can get the status by sending a GET request to the `statusQueryGetUri` URL. The response will be a JSON object that looks something like the following:
```json
{
"failureDetails": null,
"input": {
"topic": "The Future of Artificial Intelligence",
"max_review_attempts": 3
},
"instanceId": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6",
"output": {
"content": "The Future of Artificial Intelligence is..."
},
"runtimeStatus": "Completed",
"workflowStatus": "Content published successfully at 2025-10-15T12:00:00Z"
}
```
@@ -0,0 +1,44 @@
### Start the HITL content generation orchestration with default timeout (30 days)
POST http://localhost:7071/api/hitl/run
Content-Type: application/json
{
"topic": "The Future of Artificial Intelligence",
"max_review_attempts": 3
}
### Start the HITL content generation orchestration with very short timeout for demonstration (~4 seconds)
POST http://localhost:7071/api/hitl/run
Content-Type: application/json
{
"topic": "The Future of Artificial Intelligence",
"max_review_attempts": 3,
"approval_timeout_hours": 0.001
}
### Copy/paste the instanceId from the response above
@instanceId=INSTANCE_ID_GOES_HERE
### Check the status of the orchestration (replace {instanceId} with the actual instance ID from the response above)
GET http://localhost:7071/api/hitl/status/{{instanceId}}
### Send human approval (replace {instanceId} with the actual instance ID)
POST http://localhost:7071/api/hitl/approve/{{instanceId}}
Content-Type: application/json
{
"approved": true,
"feedback": "Great article! The content is well-structured and informative."
}
### Send human rejection with feedback (replace {instanceId} with the actual instance ID)
POST http://localhost:7071/api/hitl/approve/{{instanceId}}
Content-Type: application/json
{
"approved": false,
"feedback": "The article needs more technical depth and better examples. Please add more specific use cases and implementation details."
}
@@ -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": "<AZURE_OPENAI_DEPLOYMENT>"
}
}
@@ -0,0 +1,42 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<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>LongRunningTools</AssemblyName>
<RootNamespace>LongRunningTools</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,151 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.DurableTask;
using Microsoft.Azure.Functions.Worker;
using Microsoft.DurableTask;
using Microsoft.Extensions.Logging;
namespace LongRunningTools;
public static class FunctionTriggers
{
[Function(nameof(RunOrchestrationAsync))]
public static async Task<object> RunOrchestrationAsync(
[OrchestrationTrigger] TaskOrchestrationContext context)
{
// Get the input from the orchestration
ContentGenerationInput input = context.GetInput<ContentGenerationInput>()
?? throw new InvalidOperationException("Content generation input is required");
// Get the writer agent
DurableAIAgent writerAgent = context.GetAgent("Writer");
AgentThread writerThread = writerAgent.GetNewThread();
// Set initial status
context.SetCustomStatus($"Starting content generation for topic: {input.Topic}");
// Step 1: Generate initial content
AgentRunResponse<GeneratedContent> writerResponse = await writerAgent.RunAsync<GeneratedContent>(
message: $"Write a short article about '{input.Topic}'.",
thread: writerThread);
GeneratedContent content = writerResponse.Result;
// Human-in-the-loop iteration - we set a maximum number of attempts to avoid infinite loops
int iterationCount = 0;
while (iterationCount++ < input.MaxReviewAttempts)
{
context.SetCustomStatus(
new
{
message = "Requesting human feedback.",
approvalTimeoutHours = input.ApprovalTimeoutHours,
iterationCount,
content
});
// Step 2: Notify user to review the content
await context.CallActivityAsync(nameof(NotifyUserForApproval), content);
// Step 3: Wait for human feedback with configurable timeout
HumanApprovalResponse humanResponse;
try
{
humanResponse = await context.WaitForExternalEvent<HumanApprovalResponse>(
eventName: "HumanApproval",
timeout: TimeSpan.FromHours(input.ApprovalTimeoutHours));
}
catch (OperationCanceledException)
{
// Timeout occurred - treat as rejection
context.SetCustomStatus(
new
{
message = $"Human approval timed out after {input.ApprovalTimeoutHours} hour(s). Treating as rejection.",
iterationCount,
content
});
throw new TimeoutException($"Human approval timed out after {input.ApprovalTimeoutHours} hour(s).");
}
if (humanResponse.Approved)
{
context.SetCustomStatus(new
{
message = "Content approved by human reviewer. Publishing content...",
content
});
// Step 4: Publish the approved content
await context.CallActivityAsync(nameof(PublishContent), content);
context.SetCustomStatus(new
{
message = $"Content published successfully at {context.CurrentUtcDateTime:s}",
humanFeedback = humanResponse,
content
});
return new { content = content.Content };
}
context.SetCustomStatus(new
{
message = "Content rejected by human reviewer. Incorporating feedback and regenerating...",
humanFeedback = humanResponse,
content
});
// Incorporate human feedback and regenerate
writerResponse = await writerAgent.RunAsync<GeneratedContent>(
message: $"""
The content was rejected by a human reviewer. Please rewrite the article incorporating their feedback.
Human Feedback: {humanResponse.Feedback}
""",
thread: writerThread);
content = writerResponse.Result;
}
// If we reach here, it means we exhausted the maximum number of iterations
throw new InvalidOperationException(
$"Content could not be approved after {input.MaxReviewAttempts} iterations.");
}
[Function(nameof(NotifyUserForApproval))]
public static void NotifyUserForApproval(
[ActivityTrigger] GeneratedContent content,
FunctionContext functionContext)
{
ILogger logger = functionContext.GetLogger(nameof(NotifyUserForApproval));
// In a real implementation, this would send notifications via email, SMS, etc.
logger.LogInformation(
"""
NOTIFICATION: Please review the following content for approval:
Title: {Title}
Content: {Content}
Use the approval endpoint to approve or reject this content.
""",
content.Title,
content.Content);
}
[Function(nameof(PublishContent))]
public static void PublishContent(
[ActivityTrigger] GeneratedContent content,
FunctionContext functionContext)
{
ILogger logger = functionContext.GetLogger(nameof(PublishContent));
// In a real implementation, this would publish to a CMS, website, etc.
logger.LogInformation(
"""
PUBLISHING: Content has been published successfully.
Title: {Title}
Content: {Content}
""",
content.Title,
content.Content);
}
}
@@ -0,0 +1,44 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace LongRunningTools;
/// <summary>
/// Represents the input for the content generation workflow.
/// </summary>
public sealed class ContentGenerationInput
{
[JsonPropertyName("topic")]
public string Topic { get; set; } = string.Empty;
[JsonPropertyName("max_review_attempts")]
public int MaxReviewAttempts { get; set; } = 3;
[JsonPropertyName("approval_timeout_hours")]
public float ApprovalTimeoutHours { get; set; } = 72;
}
/// <summary>
/// Represents the content generated by the writer agent.
/// </summary>
public sealed class GeneratedContent
{
[JsonPropertyName("title")]
public string Title { get; set; } = string.Empty;
[JsonPropertyName("content")]
public string Content { get; set; } = string.Empty;
}
/// <summary>
/// Represents the human approval response.
/// </summary>
public sealed class HumanApprovalResponse
{
[JsonPropertyName("approved")]
public bool Approved { get; set; }
[JsonPropertyName("feedback")]
public string Feedback { get; set; } = string.Empty;
}
@@ -0,0 +1,73 @@
// Copyright (c) Microsoft. All rights reserved.
using Azure;
using Azure.AI.OpenAI;
using Azure.Identity;
using LongRunningTools;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Hosting.AzureFunctions;
using Microsoft.Azure.Functions.Worker.Builder;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using OpenAI;
// 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());
// Agent used by the orchestration to write content.
const string WriterAgentName = "Writer";
const string WriterAgentInstructions =
"""
You are a professional content writer who creates high-quality articles on various topics.
You write engaging, informative, and well-structured content that follows best practices for readability and accuracy.
""";
AIAgent writerAgent = client.GetChatClient(deploymentName).CreateAIAgent(WriterAgentInstructions, WriterAgentName);
// Agent that can start content generation workflows using tools
const string PublisherAgentName = "Publisher";
const string PublisherAgentInstructions =
"""
You are a publishing agent that can manage content generation workflows.
You have access to tools to start, monitor, and raise events for content generation workflows.
""";
using IHost app = FunctionsApplication
.CreateBuilder(args)
.ConfigureFunctionsWebApplication()
.ConfigureDurableAgents(options =>
{
// Add the writer agent used by the orchestration
options.AddAIAgent(writerAgent);
// Define the agent that can start orchestrations from tool calls
options.AddAIAgentFactory(PublisherAgentName, sp =>
{
// Initialize the tools to be used by the agent.
Tools publisherTools = new(sp.GetRequiredService<ILogger<Tools>>());
return client.GetChatClient(deploymentName).CreateAIAgent(
instructions: PublisherAgentInstructions,
name: PublisherAgentName,
services: sp,
tools: [
AIFunctionFactory.Create(publisherTools.StartContentGenerationWorkflow),
AIFunctionFactory.Create(publisherTools.GetWorkflowStatusAsync),
AIFunctionFactory.Create(publisherTools.SubmitHumanApprovalAsync),
]);
});
})
.Build();
app.Run();
@@ -0,0 +1,129 @@
# Long Running Tools Sample
This sample demonstrates how to use the Durable Agent Framework (DAFx) to create agents with long running tools. This sample builds on the [05_AgentOrchestration_HITL](../05_AgentOrchestration_HITL) sample by adding a publisher agent that can start and manage content generation workflows. A key difference is that the publisher agent knows the IDs of the workflows it starts, so it can check the status of the workflows and approve or reject them without being explicitly given the context (instance IDs, etc).
## Key Concepts Demonstrated
The same key concepts as the [05_AgentOrchestration_HITL](../05_AgentOrchestration_HITL) sample are demonstrated, but with the following additional concepts:
- **Long running tools**: Using `DurableAgentContext.Current` to start orchestrations from tool calls
- **Multi-agent orchestration**: Agents can start and manage workflows that orchestrate other agents
- **Human-in-the-loop (with delegation)**: The agent acts as an intermediary between the human and the workflow. The human remains in the loop, but delegates to the agent to start the workflow and approve or reject the content.
## 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 start the agent, which will then trigger the content generation workflow.
You can use the `demo.http` file to send requests to the agent, or a command line tool like `curl` as shown below.
Bash (Linux/macOS/WSL):
```bash
curl -i -X POST http://localhost:7071/api/agents/publisher/run \
-D headers.txt \
-H "Content-Type: text/plain" \
-d 'Start a content generation workflow for the topic \"The Future of Artificial Intelligence\"'
# Save the thread ID to a variable and print it to the terminal
threadId=$(cat headers.txt | grep "x-ms-thread-id" | cut -d' ' -f2)
echo "Thread ID: $threadId"
```
PowerShell:
```powershell
Invoke-RestMethod -Method Post `
-Uri http://localhost:7071/api/agents/publisher/run `
-ResponseHeadersVariable ResponseHeaders `
-ContentType text/plain `
-Body 'Start a content generation workflow for the topic \"The Future of Artificial Intelligence\"' `
# Save the thread ID to a variable and print it to the console
$threadId = $ResponseHeaders['x-ms-thread-id']
Write-Host "Thread ID: $threadId"
```
The response will be a text string that looks something like the following, indicating that the agent request has been received and will be processed:
```http
HTTP/1.1 200 OK
Content-Type: text/plain
x-ms-thread-id: @publisher@351ec855-7f4d-4527-a60d-498301ced36d
```
The `x-ms-thread-id` response header contains the thread ID, which can be used to continue the conversation by passing it as a query parameter (`thread_id`) to the `run` endpoint. The commands above show how to save the thread ID to a `$threadId` variable for use in subsequent requests.
Behind the scenes, the publisher agent will:
1. Start the content generation workflow via a tool call
1. The workflow will generate initial content using the Writer agent and wait for human approval, which will be visible in the logs
Once the workflow is waiting for human approval, you can send approval or rejection by prompting the publisher agent accordingly (e.g. "Approve the content" or "Reject the content with feedback: The article needs more technical depth and better examples."):
Bash (Linux/macOS/WSL):
```bash
# Approve the content
curl -X POST "http://localhost:7071/api/agents/publisher/run?thread_id=$threadId" \
-H "Content-Type: text/plain" \
-d 'Approve the content'
# Reject the content with feedback
curl -X POST "http://localhost:7071/api/agents/publisher/run?thread_id=$threadId" \
-H "Content-Type: text/plain" \
-d 'Reject the content with feedback: The article needs more technical depth and better examples.'
```
PowerShell:
```powershell
# Approve the content
Invoke-RestMethod -Method Post `
-Uri "http://localhost:7071/api/agents/publisher/run?thread_id=$threadId" `
-ContentType text/plain `
-Body 'Approve the content'
# Reject the content with feedback
Invoke-RestMethod -Method Post `
-Uri "http://localhost:7071/api/agents/publisher/run?thread_id=$threadId" `
-ContentType text/plain `
-Body 'Reject the content with feedback: The article needs more technical depth and better examples.'
```
Once the workflow has completed, you can get the status by prompting the publisher agent to give you the status.
Bash (Linux/macOS/WSL):
```bash
curl -X POST "http://localhost:7071/api/agents/publisher/run?thread_id=$threadId" \
-H "Content-Type: text/plain" \
-d 'Get the status of the workflow you previously started'
```
PowerShell:
```powershell
Invoke-RestMethod -Method Post `
-Uri "http://localhost:7071/api/agents/publisher/run?thread_id=$threadId" `
-ContentType text/plain `
-Body 'Get the status of the workflow you previously started'
```
The response from the publisher agent will look something like the following:
```text
The status of the workflow with instance ID **ab1076d6e7ec49d8a2c2474d09b69ded** is as follows:
- **Execution Status:** Completed
- **Workflow Status:** Content published successfully at `2025-10-24T20:42:02`
- **Created At:** `2025-10-24T20:41:40.7531781+00:00`
- **Last Updated At:** `2025-10-24T20:42:02.1410736+00:00`
The content has been successfully published.
```
```
@@ -0,0 +1,84 @@
// Copyright (c) Microsoft. All rights reserved.
using System.ComponentModel;
using Microsoft.Agents.AI.DurableTask;
using Microsoft.DurableTask.Client;
using Microsoft.Extensions.Logging;
namespace LongRunningTools;
/// <summary>
/// Tools that demonstrate starting orchestrations from agent tool calls.
/// </summary>
internal sealed class Tools(ILogger<Tools> logger)
{
private readonly ILogger<Tools> _logger = logger;
[Description("Starts a content generation workflow and returns the instance ID for tracking.")]
public string StartContentGenerationWorkflow([Description("The topic for content generation")] string topic)
{
this._logger.LogInformation("Starting content generation workflow for topic: {Topic}", topic);
const int MaxReviewAttempts = 3;
const float ApprovalTimeoutHours = 72;
// Schedule the orchestration, which will start running after the tool call completes.
string instanceId = DurableAgentContext.Current.ScheduleNewOrchestration(
name: nameof(FunctionTriggers.RunOrchestrationAsync),
input: new ContentGenerationInput
{
Topic = topic,
MaxReviewAttempts = MaxReviewAttempts,
ApprovalTimeoutHours = ApprovalTimeoutHours
});
this._logger.LogInformation(
"Content generation workflow scheduled to be started for topic '{Topic}' with instance ID: {InstanceId}",
topic,
instanceId);
return $"Workflow started with instance ID: {instanceId}";
}
[Description("Gets the status of a workflow orchestration.")]
public async Task<object> GetWorkflowStatusAsync(
[Description("The instance ID of the workflow to check")] string instanceId,
[Description("Whether to include detailed information")] bool includeDetails = true)
{
this._logger.LogInformation("Getting status for workflow instance: {InstanceId}", instanceId);
// Get the current agent context using the thread-static property
OrchestrationMetadata? status = await DurableAgentContext.Current.GetOrchestrationStatusAsync(
instanceId,
includeDetails);
if (status is null)
{
this._logger.LogInformation("Workflow instance '{InstanceId}' not found.", instanceId);
return new
{
instanceId,
error = $"Workflow instance '{instanceId}' not found.",
};
}
return new
{
instanceId = status.InstanceId,
createdAt = status.CreatedAt,
executionStatus = status.RuntimeStatus,
workflowStatus = status.SerializedCustomStatus,
lastUpdatedAt = status.LastUpdatedAt,
failureDetails = status.FailureDetails
};
}
[Description("Raises a feedback event for the content generation workflow.")]
public async Task SubmitHumanApprovalAsync(
[Description("The instance ID of the workflow to submit feedback for")] string instanceId,
[Description("Feedback to submit")] HumanApprovalResponse feedback)
{
this._logger.LogInformation("Submitting human approval for workflow instance: {InstanceId}", instanceId);
await DurableAgentContext.Current.RaiseOrchestrationEventAsync(instanceId, "HumanApproval", feedback);
}
}
@@ -0,0 +1,27 @@
### Run an agent that can schedule orchestrations as tool calls
POST http://localhost:7071/api/agents/publisher/run
Content-Type: text/plain
Start a content generation workflow for the topic 'The Future of Artificial Intelligence'
### Save the session ID from the response to continue the conversation
@threadId = <YOUR_THREAD_ID>
### Check the status of the workflow
POST http://localhost:7071/api/agents/publisher/run?thread_id={{threadId}}
Content-Type: text/plain
Check the status of the workflow you previously started
### Reject content with feedback
POST http://localhost:7071/api/agents/publisher/run?thread_id={{threadId}}
Content-Type: text/plain
Reject the content with feedback: The article needs more technical depth and better examples.
### Approve content
POST http://localhost:7071/api/agents/publisher/run?thread_id={{threadId}}
Content-Type: text/plain
Approve the content
@@ -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": "<AZURE_OPENAI_DEPLOYMENT>"
}
}
@@ -0,0 +1,42 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<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>AgentAsMcpTool</AssemblyName>
<RootNamespace>AgentAsMcpTool</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,53 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates how to configure AI agents to be accessible as MCP tools.
// When using AddAIAgent and enabling MCP tool triggers, the Functions host will automatically
// generate a remote MCP endpoint for the app at /runtime/webhooks/mcp with a agent-specific
// query tool name.
using Azure;
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.DurableTask;
using Microsoft.Agents.AI.Hosting.AzureFunctions;
using Microsoft.Azure.Functions.Worker.Builder;
using Microsoft.Extensions.Hosting;
using OpenAI;
// 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());
// Define three AI agents we are going to use in this application.
AIAgent agent1 = client.GetChatClient(deploymentName).CreateAIAgent("You are good at telling jokes.", "Joker");
AIAgent agent2 = client.GetChatClient(deploymentName)
.CreateAIAgent("Check stock prices.", "StockAdvisor");
AIAgent agent3 = client.GetChatClient(deploymentName)
.CreateAIAgent("Recommend plants.", "PlantAdvisor", description: "Get plant recommendations.");
using IHost app = FunctionsApplication
.CreateBuilder(args)
.ConfigureFunctionsWebApplication()
.ConfigureDurableAgents(options =>
{
options
.AddAIAgent(agent1) // Enables HTTP trigger by default.
.AddAIAgent(agent2, enableHttpTrigger: false, enableMcpToolTrigger: true) // Disable HTTP trigger, enable MCP Tool trigger.
.AddAIAgent(agent3, agentOptions =>
{
agentOptions.McpToolTrigger.IsEnabled = true; // Enable MCP Tool trigger.
});
})
.Build();
app.Run();
@@ -0,0 +1,87 @@
# Agent as MCP Tool Sample
This sample demonstrates how to configure AI agents to be accessible as both HTTP endpoints and [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) tools, enabling flexible integration patterns for AI agent consumption.
## Key Concepts Demonstrated
- **Multi-trigger Agent Configuration**: Configure agents to support HTTP triggers, MCP tool triggers, or both
- **Microsoft Agent Framework Integration**: Use the framework to define AI agents with specific roles and capabilities
- **Flexible Agent Registration**: Register agents with customizable trigger configurations
- **MCP Server Hosting**: Expose agents as MCP tools for consumption by MCP-compatible clients
## Sample Architecture
This sample creates three agents with different trigger configurations:
| Agent | Role | HTTP Trigger | MCP Tool Trigger | Description |
|-------|------|--------------|------------------|-------------|
| **Joker** | Comedy specialist | ✅ Enabled | ❌ Disabled | Accessible only via HTTP requests |
| **StockAdvisor** | Financial data | ❌ Disabled | ✅ Enabled | Accessible only as MCP tool |
| **PlantAdvisor** | Indoor plant recommendations | ✅ Enabled | ✅ Enabled | Accessible via both HTTP and MCP |
## Environment Setup
See the [README.md](../README.md) file in the parent directory for complete setup instructions, including:
- Prerequisites installation
- Azure OpenAI configuration
- Durable Task Scheduler setup
- Storage emulator configuration
For this sample, you'll also need to install [node.js](https://nodejs.org/en/download) in order to use the [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector) tool.
## Configuration
Update your `local.settings.json` with your Azure OpenAI credentials:
```json
{
"Values": {
"AZURE_OPENAI_ENDPOINT": "https://your-resource.openai.azure.com/",
"AZURE_OPENAI_DEPLOYMENT": "your-deployment-name",
"AZURE_OPENAI_KEY": "your-api-key-if-not-using-rbac"
}
}
```
## Running the Sample
1. **Start the Function App**:
```bash
cd dotnet/samples/AzureFunctions/07_AgentAsMcpTool
func start
```
2. **Note the MCP Server Endpoint**: When the app starts, you'll see the MCP server endpoint in the terminal output. It will look like:
```text
MCP server endpoint: http://localhost:7071/runtime/webhooks/mcp
```
## Testing MCP Tool Integration
Any MCP-compatible client can connect to the server endpoint and utilize the exposed agent tools. The agents will appear as callable tools within the MCP protocol.
### Using MCP Inspector
1. Run the [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector) from the command line:
```bash
npx @modelcontextprotocol/inspector
```
1. Connect using the MCP server endpoint from your terminal output
- For **Transport Type**, select **"Streamable HTTP"**
- For **URL**, enter the MCP server endpoint `http://localhost:7071/runtime/webhooks/mcp`
- Click the **Connect** button
1. Click the **List Tools** button to see the available MCP tools. You should see the `StockAdvisor` and `PlantAdvisor` tools.
1. Test the available MCP tools:
- **StockAdvisor** - Set "MSFT ATH" (ATH is "all time high") as the query and click the **Run Tool** button.
- **PlantAdvisor** - Set "Low light in Seattle" as the query and click the **Run Tool** button.
You'll see the results of the tool calls in the MCP Inspector interface under the **Tool Results** section. You should also see the results in the terminal where you ran the `func start` command.
@@ -0,0 +1,19 @@
{
"version": "2.0",
"logging": {
"logLevel": {
"Microsoft.Azure.Functions.DurableAgents": "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": "<AZURE_OPENAI_DEPLOYMENT>"
}
}
+151
View File
@@ -0,0 +1,151 @@
# Azure Functions Samples
This directory contains samples for Azure Functions.
- **[01_SingleAgent](01_SingleAgent)**: A sample that demonstrates how to host a single conversational agent in an Azure Functions app and invoke it directly over HTTP.
- **[02_AgentOrchestration_Chaining](02_AgentOrchestration_Chaining)**: A sample that demonstrates how to host a single conversational agent in an Azure Functions app and invoke it using a durable orchestration.
- **[03_AgentOrchestration_Concurrency](03_AgentOrchestration_Concurrency)**: A sample that demonstrates how to host multiple agents in an Azure Functions app and run them concurrently using a durable orchestration.
- **[04_AgentOrchestration_Conditionals](04_AgentOrchestration_Conditionals)**: A sample that demonstrates how to host multiple agents in an Azure Functions app and run them sequentially using a durable orchestration with conditionals.
- **[05_AgentOrchestration_HITL](05_AgentOrchestration_HITL)**: A sample that demonstrates how to implement a human-in-the-loop workflow using durable orchestration, including external event handling for human approval.
- **[06_LongRunningTools](06_LongRunningTools)**: A sample that demonstrates how agents can start and interact with durable orchestrations from tool calls to enable long-running tool scenarios.
- **[07_AgentAsMcpTool](07_AgentAsMcpTool)**: A sample that demonstrates how to configure durable AI agents to be accessible as Model Context Protocol (MCP) tools.
## Running the Samples
These samples are designed to be run locally in a cloned repository.
### Prerequisites
The following prerequisites are required to run the samples:
- [.NET 9.0 SDK or later](https://dotnet.microsoft.com/download/dotnet)
- [Azure Functions Core Tools](https://learn.microsoft.com/azure/azure-functions/functions-run-local) (version 4.x or later)
- [Azure CLI](https://learn.microsoft.com/cli/azure/install-azure-cli) installed and authenticated (`az login`) or an API key for the Azure OpenAI service
- [Azure OpenAI Service](https://learn.microsoft.com/azure/ai-services/openai/how-to/create-resource) with a deployed model (gpt-4o-mini or better is recommended)
- [Durable Task Scheduler](https://learn.microsoft.com/azure/azure-functions/durable/durable-task-scheduler/develop-with-durable-task-scheduler) (local emulator or Azure-hosted)
- [Docker](https://docs.docker.com/get-docker/) installed if running the Durable Task Scheduler emulator locally
### Configuring RBAC Permissions for Azure OpenAI
These samples are configured to use the Azure OpenAI service with RBAC permissions to access the model. You'll need to configure the RBAC permissions for the Azure OpenAI service to allow the Azure Functions app to access the model.
Below is an example of how to configure the RBAC permissions for the Azure OpenAI service to allow the current user to access the model.
Bash (Linux/macOS/WSL):
```bash
az role assignment create \
--assignee "yourname@contoso.com" \
--role "Cognitive Services OpenAI User" \
--scope /subscriptions/<your-subscription-id>/resourceGroups/<your-resource-group-name>/providers/Microsoft.CognitiveServices/accounts/<your-openai-resource-name>
```
PowerShell:
```powershell
az role assignment create `
--assignee "yourname@contoso.com" `
--role "Cognitive Services OpenAI User" `
--scope /subscriptions/<your-subscription-id>/resourceGroups/<your-resource-group-name>/providers/Microsoft.CognitiveServices/accounts/<your-openai-resource-name>
```
More information on how to configure RBAC permissions for Azure OpenAI can be found in the [Azure OpenAI documentation](https://learn.microsoft.com/azure/ai-services/openai/how-to/create-resource?pivots=cli).
### Setting an API key for the Azure OpenAI service
As an alternative to configuring Azure RBAC permissions, you can set an API key for the Azure OpenAI service by setting the `AZURE_OPENAI_KEY` environment variable.
Bash (Linux/macOS/WSL):
```bash
export AZURE_OPENAI_KEY="your-api-key"
```
PowerShell:
```powershell
$env:AZURE_OPENAI_KEY="your-api-key"
```
### Start Durable Task Scheduler
Most samples use the Durable Task Scheduler (DTS) to support hosted agents and durable orchestrations. DTS also allows you to view the status of orchestrations and their inputs and outputs from a web UI.
To run the Durable Task Scheduler locally, you can use the following `docker` command:
```bash
docker run -d --name dts-emulator -p 8080:8080 -p 8082:8082 mcr.microsoft.com/dts/dts-emulator:latest
```
The DTS dashboard will be available at `http://localhost:8080`.
### Start the Azure Storage Emulator
All Function apps require an Azure Storage account to store functions-specific state. You can use the Azure Storage Emulator to run a local instance of the Azure Storage service.
You can run the Azure Storage emulator locally as a standalone process or via a Docker container.
#### Docker
```bash
docker run -d --name storage-emulator -p 10000:10000 -p 10001:10001 -p 10002:10002 mcr.microsoft.com/azure-storage/azurite
```
#### Standalone
```bash
npm install -g azurite
azurite
```
### Environment Configuration
Each sample has its own `local.settings.json` file that contains the environment variables for the sample. You'll need to update the `local.settings.json` file with the correct values for your Azure OpenAI resource.
```json
{
"Values": {
"AZURE_OPENAI_ENDPOINT": "https://your-resource.openai.azure.com/",
"AZURE_OPENAI_DEPLOYMENT": "your-deployment-name"
}
}
```
Alternatively, you can set the environment variables in the command line.
### Bash (Linux/macOS/WSL)
```bash
export AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/"
export AZURE_OPENAI_DEPLOYMENT="your-deployment-name"
```
### PowerShell
```powershell
$env:AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/"
$env:AZURE_OPENAI_DEPLOYMENT="your-deployment-name"
```
These environment variables, when set, will override the values in the `local.settings.json` file, making it convenient to test the sample without having to update the `local.settings.json` file.
### Start the Azure Functions app
Navigate to the sample directory and start the Azure Functions app:
```bash
cd dotnet/samples/AzureFunctions/01_SingleAgent
func start
```
The Azure Functions app will be available at `http://localhost:7071`.
### Test the Azure Functions app
The README.md file in each sample directory contains instructions for testing the sample. Each sample also includes a `demo.http` file that can be used to test the sample from the command line. These files can be opened in VS Code with the [REST Client](https://marketplace.visualstudio.com/items?itemName=humao.rest-client) extension or in the Visual Studio IDE.
### Viewing the sample output
The Azure Functions app logs are displayed in the terminal where you ran `func start`. This is where most agent output will be displayed. You can adjust logging levels in the `host.json` file as needed.
You can also see the state of agents and orchestrations in the DTS dashboard.
@@ -2,6 +2,7 @@
// This sample demonstrates basic usage of the DevUI in an ASP.NET Core application with AI agents.
using System.ComponentModel;
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
@@ -18,10 +19,11 @@ namespace DevUI_Step01_BasicUsage;
/// <remarks>
/// This sample shows how to:
/// 1. Set up Azure OpenAI as the chat client
/// 2. Register agents and workflows using the hosting packages
/// 3. Map the DevUI endpoint which automatically configures the middleware
/// 4. Map the dynamic OpenAI Responses API for Python DevUI compatibility
/// 5. Access the DevUI in a web browser
/// 2. Create function tools for agents to use
/// 3. Register agents and workflows using the hosting packages with tools
/// 4. Map the DevUI endpoint which automatically configures the middleware
/// 5. Map the dynamic OpenAI Responses API for Python DevUI compatibility
/// 6. Access the DevUI in a web browser
///
/// The DevUI provides an interactive web interface for testing and debugging AI agents.
/// DevUI assets are served from embedded resources within the assembly.
@@ -50,10 +52,30 @@ internal static class Program
builder.Services.AddChatClient(chatClient);
// Register sample agents
builder.AddAIAgent("assistant", "You are a helpful assistant. Answer questions concisely and accurately.");
// Define some example tools
[Description("Get the weather for a given location.")]
static string GetWeather([Description("The location to get the weather for.")] string location)
=> $"The weather in {location} is cloudy with a high of 15°C.";
[Description("Calculate the sum of two numbers.")]
static double Add([Description("The first number.")] double a, [Description("The second number.")] double b)
=> a + b;
[Description("Get the current time.")]
static string GetCurrentTime()
=> DateTime.Now.ToString("HH:mm:ss");
// Register sample agents with tools
builder.AddAIAgent("assistant", "You are a helpful assistant. Answer questions concisely and accurately.")
.WithAITools(
AIFunctionFactory.Create(GetWeather, name: "get_weather"),
AIFunctionFactory.Create(GetCurrentTime, name: "get_current_time")
);
builder.AddAIAgent("poet", "You are a creative poet. Respond to all requests with beautiful poetry.");
builder.AddAIAgent("coder", "You are an expert programmer. Help users with coding questions and provide code examples.");
builder.AddAIAgent("coder", "You are an expert programmer. Help users with coding questions and provide code examples.")
.WithAITool(AIFunctionFactory.Create(Add, name: "add"));
// Register sample workflows
var assistantBuilder = builder.AddAIAgent("workflow-assistant", "You are a helpful assistant in a workflow.");
@@ -0,0 +1,70 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<!--
Disable central package management for this project.
This project requires explicit package references with versions specified inline rather than
inheriting them from Directory.Packages.props. This is necessary because a Docker image will
be created from this project, and the Docker build process only has access to this folder
and cannot access parent folders where Directory.Packages.props resides.
-->
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
<NoWarn>$(NoWarn);MEAI001;OPENAI001</NoWarn>
</PropertyGroup>
<!--
Remove analyzer PackageReference items inherited from Directory.Packages.props.
Note: ManagePackageVersionsCentrally only controls PackageVersion items, not PackageReference items.
Directory.Packages.props contains both PackageVersion and PackageReference entries for analyzers,
and the PackageReference items are always inherited through MSBuild imports regardless of the
ManagePackageVersionsCentrally setting. We must explicitly remove them before adding our own versions.
-->
<ItemGroup>
<PackageReference Remove="Microsoft.CodeAnalysis.NetAnalyzers" />
<PackageReference Remove="Microsoft.VisualStudio.Threading.Analyzers" />
<PackageReference Remove="xunit.analyzers" />
<PackageReference Remove="Moq.Analyzers" />
<PackageReference Remove="Roslynator.Analyzers" />
<PackageReference Remove="Roslynator.CodeAnalysis.Analyzers" />
<PackageReference Remove="Roslynator.Formatting.Analyzers" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.4" />
<PackageReference Include="Azure.AI.OpenAI" Version="2.5.0-beta.1" />
<PackageReference Include="Azure.Identity" Version="1.17.0" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0-preview.251110.2" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="9.10.2-preview.1.25552.1" />
</ItemGroup>
<!-- Add analyzers with compatible versions -->
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="9.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.14.15">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Roslynator.Analyzers" Version="4.12.9">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Roslynator.CodeAnalysis.Analyzers" Version="4.12.9">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Roslynator.Formatting.Analyzers" Version="4.12.9">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
@@ -0,0 +1,20 @@
# Build the application
FROM mcr.microsoft.com/dotnet/sdk:9.0-alpine AS build
WORKDIR /src
# Copy files from the current directory on the host to the working directory in the container
COPY . .
RUN dotnet restore
RUN dotnet build -c Release --no-restore
RUN dotnet publish -c Release --no-build -o /app
# Run the application
FROM mcr.microsoft.com/dotnet/aspnet:9.0-alpine AS final
WORKDIR /app
# Copy everything needed to run the app from the "build" stage.
COPY --from=build /app .
EXPOSE 8088
ENTRYPOINT ["dotnet", "AgentWithHostedMCP.dll"]
@@ -0,0 +1,34 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample shows how to create and use a simple AI agent with OpenAI Responses as the backend, that uses a Hosted MCP Tool.
// In this case the OpenAI responses service will invoke any MCP tools as required. MCP tools are not invoked by the Agent Framework.
// The sample demonstrates how to use MCP tools with auto approval by setting ApprovalMode to NeverRequire.
using Azure.AI.AgentServer.AgentFramework.Extensions;
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI;
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";
// Create an MCP tool that can be called without approval.
AITool mcpTool = new HostedMcpServerTool(serverName: "microsoft_learn", serverAddress: "https://learn.microsoft.com/api/mcp")
{
AllowedTools = ["microsoft_docs_search"],
ApprovalMode = HostedMcpServerToolApprovalMode.NeverRequire
};
// Create an agent with the MCP tool using Azure OpenAI Responses.
AIAgent agent = new AzureOpenAIClient(
new Uri(endpoint),
new DefaultAzureCredential())
.GetOpenAIResponseClient(deploymentName)
.CreateAIAgent(
instructions: "You answer questions by searching the Microsoft Learn content only.",
name: "MicrosoftLearnAgent",
tools: [mcpTool]);
await agent.RunAIAgentAsync();
@@ -0,0 +1,43 @@
# What this sample demonstrates
This sample demonstrates how to use a Hosted Model Context Protocol (MCP) server with an AI agent.
The agent connects to the Microsoft Learn MCP server to search documentation and answer questions using official Microsoft content.
Key features:
- Configuring MCP tools with automatic approval (no user confirmation required)
- Filtering available tools from an MCP server
- Using Azure OpenAI Responses with MCP tools
## Prerequisites
Before running this sample, ensure you have:
1. An Azure OpenAI endpoint configured
2. A deployment of a chat model (e.g., gpt-4o-mini)
3. Azure CLI installed and authenticated
**Note**: This sample uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure OpenAI resource.
## Environment Variables
Set the following environment variables:
```powershell
# Replace with your Azure OpenAI endpoint
$env:AZURE_OPENAI_ENDPOINT="https://your-openai-resource.openai.azure.com/"
# Optional, defaults to gpt-4o-mini
$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini"
```
## How It Works
The sample connects to the Microsoft Learn MCP server and uses its documentation search capabilities:
1. The agent is configured with a HostedMcpServerTool pointing to `https://learn.microsoft.com/api/mcp`
2. Only the `microsoft_docs_search` tool is enabled from the available MCP tools
3. Approval mode is set to `NeverRequire`, allowing automatic tool execution
4. When you ask questions, Azure OpenAI Responses automatically invokes the MCP tool to search documentation
5. The agent returns answers based on the Microsoft Learn content
In this configuration, the OpenAI Responses service manages tool invocation directly - the Agent Framework does not handle MCP tool calls.
@@ -0,0 +1,31 @@
name: AgentWithHostedMCP
displayName: "Microsoft Learn Response Agent with MCP"
description: >
An AI agent that uses Azure OpenAI Responses with a Hosted Model Context Protocol (MCP) server.
The agent answers questions by searching Microsoft Learn documentation using MCP tools.
This demonstrates how MCP tools can be integrated with Azure OpenAI Responses where the service
itself handles tool invocation.
metadata:
authors:
- Microsoft Agent Framework Team
tags:
- Azure AI AgentServer
- Microsoft Agent Framework
- Model Context Protocol
- MCP
- Tool Call Approval
template:
kind: hosted
name: AgentWithHostedMCP
protocols:
- protocol: responses
version: v1
environment_variables:
- name: AZURE_OPENAI_ENDPOINT
value: ${AZURE_OPENAI_ENDPOINT}
- name: AZURE_OPENAI_DEPLOYMENT_NAME
value: gpt-4o-mini
resources:
- name: "gpt-4o-mini"
kind: model
id: gpt-4o-mini
@@ -0,0 +1,30 @@
@host = http://localhost:8088
@endpoint = {{host}}/responses
### Health Check
GET {{host}}/readiness
### Simple string input - Ask about MCP Tools
POST {{endpoint}}
Content-Type: application/json
{
"input": "Please summarize the Azure AI Agent documentation related to MCP Tool calling?"
}
### Explicit input - Ask about Agent Framework
POST {{endpoint}}
Content-Type: application/json
{
"input": [
{
"type": "message",
"role": "user",
"content": [
{
"type": "input_text",
"text": "What is the Microsoft Agent Framework?"
}
]
}
]
}
@@ -10,8 +10,10 @@ metadata:
authors:
- Microsoft Agent Framework Team
tags:
- example
- Agent Framework
- Azure AI AgentServer
- Microsoft Agent Framework
- Retrieval-Augmented Generation
- RAG
template:
kind: hosted
name: AgentWithTextSearchRag
@@ -6,11 +6,11 @@ description: >
to English, leveraging AI-powered translation capabilities in a pipeline workflow.
metadata:
authors:
- Agent Framework Team
tags:
- example
- Microsoft Agent Framework Team
- workflows
tags:
- Azure AI AgentServer
- Microsoft Agent Framework
- Workflows
template:
kind: hosted
name: AgentsInWorkflows
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<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" />
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Purview\Microsoft.Agents.AI.Purview.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,43 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample shows how to create and use a simple AI agent with Purview integration.
// It uses Azure OpenAI as the backend, but any IChatClient can be used.
// Authentication to Purview is done using an InteractiveBrowserCredential.
// Any TokenCredential with Purview API permissions can be used here.
using Azure.AI.OpenAI;
using Azure.Core;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Purview;
using Microsoft.Extensions.AI;
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 purviewClientAppId = Environment.GetEnvironmentVariable("PURVIEW_CLIENT_APP_ID") ?? throw new InvalidOperationException("PURVIEW_CLIENT_APP_ID is not set.");
// This will get a user token for an entra app configured to call the Purview API.
// Any TokenCredential with permissions to call the Purview API can be used here.
TokenCredential browserCredential = new InteractiveBrowserCredential(
new InteractiveBrowserCredentialOptions
{
ClientId = purviewClientAppId
});
using IChatClient client = new AzureOpenAIClient(
new Uri(endpoint),
new AzureCliCredential())
.GetOpenAIResponseClient(deploymentName)
.AsIChatClient()
.AsBuilder()
.WithPurview(browserCredential, new PurviewSettings("Agent Framework Test App"))
.Build();
Console.WriteLine("Enter a prompt to send to the client:");
string? promptText = Console.ReadLine();
if (!string.IsNullOrEmpty(promptText))
{
// Invoke the agent and output the text result.
Console.WriteLine(await client.GetResponseAsync(promptText));
}
+1
View File
@@ -19,6 +19,7 @@ The samples are subdivided into the following categories:
- [Getting Started - Agent Providers](./GettingStarted/AgentProviders/README.md): Shows how to create an AIAgent instance for a selection of providers.
- [Getting Started - Agent Telemetry](./GettingStarted/AgentOpenTelemetry/README.md): Demo which showcases the integration of OpenTelemetry with the Microsoft Agent Framework using Azure OpenAI and .NET Aspire Dashboard for telemetry visualization.
- [Semantic Kernel to Agent Framework Migration](https://github.com/microsoft/semantic-kernel/tree/main/dotnet/samples/AgentFrameworkMigration): For instructions and samples describing how to migrate from Semantic Kernel to Microsoft Agent Framework
- [Azure Functions](./AzureFunctions/README.md): Samples for using the Microsoft Agent Framework with Azure Functions via the durable task extension.
## Prerequisites
@@ -97,8 +97,9 @@ public sealed class InMemoryChatMessageStore : ChatMessageStore, IList<ChatMessa
if (serializedStoreState.ValueKind is JsonValueKind.Object)
{
var jso = jsonSerializerOptions ?? AgentAbstractionsJsonUtilities.DefaultOptions;
var state = serializedStoreState.Deserialize(
AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(StoreState))) as StoreState;
jso.GetTypeInfo(typeof(StoreState))) as StoreState;
if (state?.Messages is { } messages)
{
this._messages = messages;
@@ -164,7 +165,8 @@ public sealed class InMemoryChatMessageStore : ChatMessageStore, IList<ChatMessa
Messages = this._messages,
};
return JsonSerializer.SerializeToElement(state, AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(StoreState)));
var jso = jsonSerializerOptions ?? AgentAbstractionsJsonUtilities.DefaultOptions;
return JsonSerializer.SerializeToElement(state, jso.GetTypeInfo(typeof(StoreState)));
}
/// <inheritdoc />
@@ -18,9 +18,13 @@ namespace Microsoft.Agents.AI.DevUI.Entities;
[JsonSerializable(typeof(MetaResponse))]
[JsonSerializable(typeof(EnvVarRequirement))]
[JsonSerializable(typeof(List<EntityInfo>))]
[JsonSerializable(typeof(List<JsonElement>))]
[JsonSerializable(typeof(List<Dictionary<string, JsonElement>>))]
[JsonSerializable(typeof(List<Dictionary<string, string>>))]
[JsonSerializable(typeof(Dictionary<string, JsonElement>))]
[JsonSerializable(typeof(Dictionary<string, bool>))]
[JsonSerializable(typeof(Dictionary<string, Dictionary<string, string>>))]
[JsonSerializable(typeof(Dictionary<string, string>))]
[JsonSerializable(typeof(JsonElement))]
[JsonSerializable(typeof(string))]
[JsonSerializable(typeof(int))]
[ExcludeFromCodeCoverage]
internal sealed partial class EntitiesJsonContext : JsonSerializerContext;
@@ -36,16 +36,16 @@ internal sealed record EntityInfo(
string Name,
[property: JsonPropertyName("description")]
string? Description = null,
string? Description,
[property: JsonPropertyName("framework")]
string Framework = "dotnet",
string Framework,
[property: JsonPropertyName("tools")]
List<string>? Tools = null,
List<string> Tools,
[property: JsonPropertyName("metadata")]
Dictionary<string, JsonElement>? Metadata = null
Dictionary<string, JsonElement> Metadata
)
{
[JsonPropertyName("source")]
@@ -54,6 +54,32 @@ internal sealed record EntityInfo(
[JsonPropertyName("original_url")]
public string? OriginalUrl { get; init; }
// Deployment support
[JsonPropertyName("deployment_supported")]
public bool DeploymentSupported { get; init; }
[JsonPropertyName("deployment_reason")]
public string? DeploymentReason { get; init; }
// Agent-specific fields
[JsonPropertyName("instructions")]
public string? Instructions { get; init; }
[JsonPropertyName("model_id")]
public string? ModelId { get; init; }
[JsonPropertyName("chat_client_type")]
public string? ChatClientType { get; init; }
[JsonPropertyName("context_providers")]
public List<string>? ContextProviders { get; init; }
[JsonPropertyName("middleware")]
public List<string>? Middleware { get; init; }
[JsonPropertyName("module_path")]
public string? ModulePath { get; init; }
// Workflow-specific fields
[JsonPropertyName("required_env_vars")]
public List<EnvVarRequirement>? RequiredEnvVars { get; init; }
@@ -1,5 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Agents.AI.Workflows.Checkpointing;
@@ -17,31 +19,37 @@ internal static class WorkflowSerializationExtensions
/// Converts a workflow to a dictionary representation compatible with DevUI frontend.
/// This matches the Python workflow.to_dict() format expected by the UI.
/// </summary>
public static Dictionary<string, object> ToDevUIDict(this Workflow workflow)
/// <param name="workflow">The workflow to convert.</param>
/// <returns>A dictionary with string keys and JsonElement values containing the workflow data.</returns>
public static Dictionary<string, JsonElement> ToDevUIDict(this Workflow workflow)
{
var result = new Dictionary<string, object>
var result = new Dictionary<string, JsonElement>
{
["id"] = workflow.Name ?? Guid.NewGuid().ToString(),
["start_executor_id"] = workflow.StartExecutorId,
["max_iterations"] = MaxIterationsDefault
["id"] = Serialize(workflow.Name ?? Guid.NewGuid().ToString(), EntitiesJsonContext.Default.String),
["start_executor_id"] = Serialize(workflow.StartExecutorId, EntitiesJsonContext.Default.String),
["max_iterations"] = Serialize(MaxIterationsDefault, EntitiesJsonContext.Default.Int32)
};
// Add optional fields
if (!string.IsNullOrEmpty(workflow.Name))
{
result["name"] = workflow.Name;
result["name"] = Serialize(workflow.Name, EntitiesJsonContext.Default.String);
}
if (!string.IsNullOrEmpty(workflow.Description))
{
result["description"] = workflow.Description;
result["description"] = Serialize(workflow.Description, EntitiesJsonContext.Default.String);
}
// Convert executors to Python-compatible format
result["executors"] = ConvertExecutorsToDict(workflow);
result["executors"] = Serialize(
ConvertExecutorsToDict(workflow),
EntitiesJsonContext.Default.DictionaryStringDictionaryStringString);
// Convert edges to edge_groups format
result["edge_groups"] = ConvertEdgesToEdgeGroups(workflow);
result["edge_groups"] = Serialize(
ConvertEdgesToEdgeGroups(workflow),
EntitiesJsonContext.Default.ListDictionaryStringJsonElement);
return result;
}
@@ -49,9 +57,9 @@ internal static class WorkflowSerializationExtensions
/// <summary>
/// Converts workflow executors to a dictionary format compatible with Python
/// </summary>
private static Dictionary<string, object> ConvertExecutorsToDict(Workflow workflow)
private static Dictionary<string, Dictionary<string, string>> ConvertExecutorsToDict(Workflow workflow)
{
var executors = new Dictionary<string, object>();
var executors = new Dictionary<string, Dictionary<string, string>>();
// Extract executor IDs from edges and start executor
// (Registrations is internal, so we infer executors from the graph structure)
@@ -73,7 +81,7 @@ internal static class WorkflowSerializationExtensions
// Create executor entries (we can't access internal Registrations for type info)
foreach (var executorId in executorIds)
{
executors[executorId] = new Dictionary<string, object>
executors[executorId] = new Dictionary<string, string>
{
["id"] = executorId,
["type"] = "Executor"
@@ -86,9 +94,9 @@ internal static class WorkflowSerializationExtensions
/// <summary>
/// Converts workflow edges to edge_groups format expected by the UI
/// </summary>
private static List<object> ConvertEdgesToEdgeGroups(Workflow workflow)
private static List<Dictionary<string, JsonElement>> ConvertEdgesToEdgeGroups(Workflow workflow)
{
var edgeGroups = new List<object>();
var edgeGroups = new List<Dictionary<string, JsonElement>>();
var edgeGroupId = 0;
// Get edges using the public ReflectEdges method
@@ -101,13 +109,13 @@ internal static class WorkflowSerializationExtensions
if (edgeInfo is DirectEdgeInfo directEdge)
{
// Single edge group for direct edges
var edges = new List<object>();
var edges = new List<Dictionary<string, string>>();
foreach (var source in directEdge.Connection.SourceIds)
{
foreach (var sink in directEdge.Connection.SinkIds)
{
var edge = new Dictionary<string, object>
var edge = new Dictionary<string, string>
{
["source_id"] = source,
["target_id"] = sink
@@ -123,23 +131,25 @@ internal static class WorkflowSerializationExtensions
}
}
edgeGroups.Add(new Dictionary<string, object>
var edgeGroup = new Dictionary<string, JsonElement>
{
["id"] = $"edge_group_{edgeGroupId++}",
["type"] = "SingleEdgeGroup",
["edges"] = edges
});
["id"] = Serialize($"edge_group_{edgeGroupId++}", EntitiesJsonContext.Default.String),
["type"] = Serialize("SingleEdgeGroup", EntitiesJsonContext.Default.String),
["edges"] = Serialize(edges, EntitiesJsonContext.Default.ListDictionaryStringString)
};
edgeGroups.Add(edgeGroup);
}
else if (edgeInfo is FanOutEdgeInfo fanOutEdge)
{
// FanOut edge group
var edges = new List<object>();
var edges = new List<Dictionary<string, string>>();
foreach (var source in fanOutEdge.Connection.SourceIds)
{
foreach (var sink in fanOutEdge.Connection.SinkIds)
{
edges.Add(new Dictionary<string, object>
edges.Add(new Dictionary<string, string>
{
["source_id"] = source,
["target_id"] = sink
@@ -147,16 +157,16 @@ internal static class WorkflowSerializationExtensions
}
}
var fanOutGroup = new Dictionary<string, object>
var fanOutGroup = new Dictionary<string, JsonElement>
{
["id"] = $"edge_group_{edgeGroupId++}",
["type"] = "FanOutEdgeGroup",
["edges"] = edges
["id"] = Serialize($"edge_group_{edgeGroupId++}", EntitiesJsonContext.Default.String),
["type"] = Serialize("FanOutEdgeGroup", EntitiesJsonContext.Default.String),
["edges"] = Serialize(edges, EntitiesJsonContext.Default.ListDictionaryStringString)
};
if (fanOutEdge.HasAssigner)
{
fanOutGroup["selection_func_name"] = "selector";
fanOutGroup["selection_func_name"] = Serialize("selector", EntitiesJsonContext.Default.String);
}
edgeGroups.Add(fanOutGroup);
@@ -164,13 +174,13 @@ internal static class WorkflowSerializationExtensions
else if (edgeInfo is FanInEdgeInfo fanInEdge)
{
// FanIn edge group
var edges = new List<object>();
var edges = new List<Dictionary<string, string>>();
foreach (var source in fanInEdge.Connection.SourceIds)
{
foreach (var sink in fanInEdge.Connection.SinkIds)
{
edges.Add(new Dictionary<string, object>
edges.Add(new Dictionary<string, string>
{
["source_id"] = source,
["target_id"] = sink
@@ -178,16 +188,20 @@ internal static class WorkflowSerializationExtensions
}
}
edgeGroups.Add(new Dictionary<string, object>
var edgeGroup = new Dictionary<string, JsonElement>
{
["id"] = $"edge_group_{edgeGroupId++}",
["type"] = "FanInEdgeGroup",
["edges"] = edges
});
["id"] = Serialize($"edge_group_{edgeGroupId++}", EntitiesJsonContext.Default.String),
["type"] = Serialize("FanInEdgeGroup", EntitiesJsonContext.Default.String),
["edges"] = Serialize(edges, EntitiesJsonContext.Default.ListDictionaryStringString)
};
edgeGroups.Add(edgeGroup);
}
}
}
return edgeGroups;
}
private static JsonElement Serialize<T>(T value, JsonTypeInfo<T> typeInfo) => JsonSerializer.SerializeToElement(value, typeInfo);
}
@@ -6,6 +6,7 @@ using System.Text.Json;
using Microsoft.Agents.AI.DevUI.Entities;
using Microsoft.Agents.AI.Hosting;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.DevUI;
@@ -56,21 +57,21 @@ internal static class EntitiesApiExtensions
{
try
{
var entities = new List<EntityInfo>();
var entities = new Dictionary<string, EntityInfo>();
// Discover agents
await foreach (var agentInfo in DiscoverAgentsAsync(agentCatalog, entityIdFilter: null, cancellationToken).ConfigureAwait(false))
{
entities.Add(agentInfo);
entities[agentInfo.Id] = agentInfo;
}
// Discover workflows
await foreach (var workflowInfo in DiscoverWorkflowsAsync(workflowCatalog, entityIdFilter: null, cancellationToken).ConfigureAwait(false))
{
entities.Add(workflowInfo);
entities[workflowInfo.Id] = workflowInfo;
}
return Results.Json(new DiscoveryResponse([.. entities]), EntitiesJsonContext.Default.DiscoveryResponse);
return Results.Json(new DiscoveryResponse([.. entities.Values.OrderBy(e => e.Id)]), EntitiesJsonContext.Default.DiscoveryResponse);
}
catch (Exception ex)
{
@@ -90,14 +91,6 @@ internal static class EntitiesApiExtensions
{
try
{
if (type is null || string.Equals(type, "agent", StringComparison.OrdinalIgnoreCase))
{
await foreach (var agentInfo in DiscoverAgentsAsync(agentCatalog, entityId, cancellationToken).ConfigureAwait(false))
{
return Results.Json(agentInfo, EntitiesJsonContext.Default.EntityInfo);
}
}
if (type is null || string.Equals(type, "workflow", StringComparison.OrdinalIgnoreCase))
{
await foreach (var workflowInfo in DiscoverWorkflowsAsync(workflowCatalog, entityId, cancellationToken).ConfigureAwait(false))
@@ -106,6 +99,14 @@ internal static class EntitiesApiExtensions
}
}
if (type is null || string.Equals(type, "agent", StringComparison.OrdinalIgnoreCase))
{
await foreach (var agentInfo in DiscoverAgentsAsync(agentCatalog, entityId, cancellationToken).ConfigureAwait(false))
{
return Results.Json(agentInfo, EntitiesJsonContext.Default.EntityInfo);
}
}
return Results.NotFound(new { error = new { message = $"Entity '{entityId}' not found.", type = "invalid_request_error" } });
}
catch (Exception ex)
@@ -180,17 +181,82 @@ internal static class EntitiesApiExtensions
private static EntityInfo CreateAgentEntityInfo(AIAgent agent)
{
var entityId = agent.Name ?? agent.Id;
// Extract tools and other metadata using GetService
List<string> tools = [];
var metadata = new Dictionary<string, JsonElement>();
// Try to get ChatOptions from the agent which may contain tools
if (agent.GetService<ChatOptions>() is { Tools: { Count: > 0 } agentTools })
{
tools = agentTools
.Where(tool => !string.IsNullOrWhiteSpace(tool.Name))
.Select(tool => tool.Name!)
.Distinct()
.ToList();
}
// Extract agent-specific fields (top-level properties for compatibility with Python)
string? instructions = null;
string? modelId = null;
string? chatClientType = null;
// Get instructions from ChatClientAgent
if (agent is ChatClientAgent chatAgent && !string.IsNullOrWhiteSpace(chatAgent.Instructions))
{
instructions = chatAgent.Instructions;
}
// Get IChatClient to extract metadata
IChatClient? chatClient = agent.GetService<IChatClient>();
if (chatClient != null)
{
// Get chat client type
chatClientType = chatClient.GetType().Name;
// Get model ID from ChatClientMetadata
if (chatClient.GetService<ChatClientMetadata>() is { } chatClientMetadata)
{
modelId = chatClientMetadata.DefaultModelId;
// Add additional metadata for compatibility
if (!string.IsNullOrWhiteSpace(chatClientMetadata.ProviderName))
{
metadata["chat_client_provider"] = JsonSerializer.SerializeToElement(chatClientMetadata.ProviderName, EntitiesJsonContext.Default.String);
}
if (chatClientMetadata.ProviderUri is not null)
{
metadata["provider_uri"] = JsonSerializer.SerializeToElement(chatClientMetadata.ProviderUri.ToString(), EntitiesJsonContext.Default.String);
}
}
}
// Add provider name from AIAgentMetadata if available
if (agent.GetService<AIAgentMetadata>() is { } agentMetadata && !string.IsNullOrWhiteSpace(agentMetadata.ProviderName))
{
metadata["provider_name"] = JsonSerializer.SerializeToElement(agentMetadata.ProviderName, EntitiesJsonContext.Default.String);
}
// Add agent type information to metadata (in addition to chat_client_type)
var agentTypeName = agent.GetType().Name;
metadata["agent_type"] = JsonSerializer.SerializeToElement(agentTypeName, EntitiesJsonContext.Default.String);
return new EntityInfo(
Id: entityId,
Type: "agent",
Name: entityId,
Name: agent.DisplayName,
Description: agent.Description,
Framework: "agent-framework",
Tools: null,
Metadata: []
Framework: "agent_framework",
Tools: tools,
Metadata: metadata
)
{
Source = "in_memory"
Source = "in_memory",
Instructions = instructions,
ModelId = modelId,
ChatClientType = chatClientType,
Executors = [], // Agents have empty executors list (workflows use this field)
};
}
@@ -212,7 +278,7 @@ internal static class EntitiesApiExtensions
}
// Create a default input schema (string type)
var defaultInputSchema = new Dictionary<string, object>
var defaultInputSchema = new Dictionary<string, string>
{
["type"] = "string"
};
@@ -223,14 +289,17 @@ internal static class EntitiesApiExtensions
Type: "workflow",
Name: workflowId,
Description: workflow.Description,
Framework: "agent-framework",
Tools: [.. executorIds],
Framework: "agent_framework",
Tools: [],
Metadata: []
)
{
Source = "in_memory",
WorkflowDump = JsonSerializer.SerializeToElement(workflow.ToDevUIDict()),
InputSchema = JsonSerializer.SerializeToElement(defaultInputSchema),
Executors = [.. executorIds], // Workflows use Executors instead of Tools
WorkflowDump = JsonSerializer.SerializeToElement(
workflow.ToDevUIDict(),
EntitiesJsonContext.Default.DictionaryStringJsonElement),
InputSchema = JsonSerializer.SerializeToElement(defaultInputSchema, EntitiesJsonContext.Default.DictionaryStringString),
InputTypeName = "string",
StartExecutorId = workflow.StartExecutorId
};
@@ -0,0 +1,39 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
namespace Microsoft.Agents.AI.DurableTask;
/// <summary>
/// Extension methods for the <see cref="AIAgent"/> class.
/// </summary>
public static class AIAgentExtensions
{
/// <summary>
/// Converts an AIAgent to a durable agent proxy.
/// </summary>
/// <param name="agent">The agent to convert.</param>
/// <param name="services">The service provider.</param>
/// <returns>The durable agent proxy.</returns>
/// <exception cref="ArgumentException">
/// Thrown when the agent is a DurableAIAgent instance or if the agent has no name.
/// </exception>
/// <exception cref="InvalidOperationException">
/// Thrown if <paramref name="services"/> does not contain an <see cref="IDurableAgentClient"/>.
/// </exception>
public static AIAgent AsDurableAgentProxy(this AIAgent agent, IServiceProvider services)
{
// Don't allow this method to be used on DurableAIAgent instances.
if (agent is DurableAIAgent)
{
throw new ArgumentException(
$"{nameof(DurableAIAgent)} instances cannot be converted to a durable agent proxy.",
nameof(agent));
}
string agentName = agent.Name ?? throw new ArgumentException("Agent must have a name.", nameof(agent));
IDurableAgentClient agentClient = services.GetRequiredService<IDurableAgentClient>();
return new DurableAIAgentProxy(agentName, agentClient);
}
}
@@ -0,0 +1,124 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.DurableTask.State;
using Microsoft.DurableTask.Client;
using Microsoft.DurableTask.Entities;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Microsoft.Agents.AI.DurableTask;
internal class AgentEntity(IServiceProvider services, CancellationToken cancellationToken = default) : TaskEntity<DurableAgentState>
{
private readonly IServiceProvider _services = services;
private readonly DurableTaskClient _client = services.GetRequiredService<DurableTaskClient>();
private readonly ILoggerFactory _loggerFactory = services.GetRequiredService<ILoggerFactory>();
private readonly IAgentResponseHandler? _messageHandler = services.GetService<IAgentResponseHandler>();
private readonly CancellationToken _cancellationToken = cancellationToken != default
? cancellationToken
: services.GetService<IHostApplicationLifetime>()?.ApplicationStopping ?? CancellationToken.None;
public async Task<AgentRunResponse> RunAgentAsync(RunRequest request)
{
AgentSessionId sessionId = this.Context.Id;
IReadOnlyDictionary<string, Func<IServiceProvider, AIAgent>> agents =
this._services.GetRequiredService<IReadOnlyDictionary<string, Func<IServiceProvider, AIAgent>>>();
if (!agents.TryGetValue(sessionId.Name, out Func<IServiceProvider, AIAgent>? agentFactory))
{
throw new InvalidOperationException($"Agent '{sessionId.Name}' not found");
}
AIAgent agent = agentFactory(this._services);
EntityAgentWrapper agentWrapper = new(agent, this.Context, request, this._services);
// Logger category is Microsoft.DurableTask.Agents.{agentName}.{sessionId}
ILogger logger = this._loggerFactory.CreateLogger($"Microsoft.DurableTask.Agents.{agent.Name}.{sessionId.Key}");
if (request.Messages.Count == 0)
{
logger.LogInformation("Ignoring empty request");
}
this.State.Data.ConversationHistory.Add(DurableAgentStateRequest.FromRunRequest(request));
foreach (ChatMessage msg in request.Messages)
{
logger.LogAgentRequest(sessionId, msg.Role, msg.Text);
}
// Set the current agent context for the duration of the agent run. This will be exposed
// to any tools that are invoked by the agent.
DurableAgentContext agentContext = new(
entityContext: this.Context,
client: this._client,
lifetime: this._services.GetRequiredService<IHostApplicationLifetime>(),
services: this._services);
DurableAgentContext.SetCurrent(agentContext);
try
{
// Start the agent response stream
IAsyncEnumerable<AgentRunResponseUpdate> responseStream = agentWrapper.RunStreamingAsync(
this.State.Data.ConversationHistory.SelectMany(e => e.Messages).Select(m => m.ToChatMessage()),
agentWrapper.GetNewThread(),
options: null,
this._cancellationToken);
AgentRunResponse response;
if (this._messageHandler is null)
{
// If no message handler is provided, we can just get the full response at once.
// This is expected to be the common case for non-interactive agents.
response = await responseStream.ToAgentRunResponseAsync(this._cancellationToken);
}
else
{
List<AgentRunResponseUpdate> responseUpdates = [];
// To support interactive chat agents, we need to stream the responses to an IAgentMessageHandler.
// The user-provided message handler can be implemented to send the responses to the user.
// We assume that only non-empty text updates are useful for the user.
async IAsyncEnumerable<AgentRunResponseUpdate> StreamResultsAsync()
{
await foreach (AgentRunResponseUpdate update in responseStream)
{
// We need the full response further down, so we piece it together as we go.
responseUpdates.Add(update);
// Yield the update to the message handler.
yield return update;
}
}
await this._messageHandler.OnStreamingResponseUpdateAsync(StreamResultsAsync(), this._cancellationToken);
response = responseUpdates.ToAgentRunResponse();
}
// Persist the agent response to the entity state for client polling
this.State.Data.ConversationHistory.Add(
DurableAgentStateResponse.FromRunResponse(request.CorrelationId, response));
string responseText = response.Text;
if (!string.IsNullOrEmpty(responseText))
{
logger.LogAgentResponse(
sessionId,
response.Messages.FirstOrDefault()?.Role ?? ChatRole.Assistant,
responseText,
response.Usage?.InputTokenCount,
response.Usage?.OutputTokenCount,
response.Usage?.TotalTokenCount);
}
return response;
}
finally
{
// Clear the current agent context
DurableAgentContext.ClearCurrent();
}
}
}
@@ -0,0 +1,83 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.DurableTask.State;
using Microsoft.DurableTask.Client;
using Microsoft.DurableTask.Client.Entities;
using Microsoft.Extensions.Logging;
namespace Microsoft.Agents.AI.DurableTask;
/// <summary>
/// Represents a handle for a running agent request that can be used to retrieve the response.
/// </summary>
internal sealed class AgentRunHandle
{
private readonly DurableTaskClient _client;
private readonly ILogger _logger;
internal AgentRunHandle(
DurableTaskClient client,
ILogger logger,
AgentSessionId sessionId,
string correlationId)
{
this._client = client;
this._logger = logger;
this.SessionId = sessionId;
this.CorrelationId = correlationId;
}
/// <summary>
/// Gets the correlation ID for this request.
/// </summary>
public string CorrelationId { get; }
/// <summary>
/// Gets the session ID for this request.
/// </summary>
public AgentSessionId SessionId { get; }
/// <summary>
/// Reads the agent response for this request by polling the entity state until the response is found.
/// Uses an exponential backoff polling strategy with a maximum interval of 1 second.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The agent response corresponding to this request.</returns>
/// <exception cref="InvalidOperationException">Thrown when the response is not found after polling.</exception>
public async Task<AgentRunResponse> ReadAgentResponseAsync(CancellationToken cancellationToken = default)
{
TimeSpan pollInterval = TimeSpan.FromMilliseconds(50); // Start with 50ms
TimeSpan maxPollInterval = TimeSpan.FromSeconds(3); // Maximum 3 seconds
this._logger.LogStartPollingForResponse(this.SessionId, this.CorrelationId);
while (true)
{
// Poll the entity state for responses
EntityMetadata<DurableAgentState>? entityResponse = await this._client.Entities.GetEntityAsync<DurableAgentState>(
this.SessionId,
cancellation: cancellationToken);
DurableAgentState? state = entityResponse?.State;
if (state?.Data.ConversationHistory is not null)
{
// Look for an agent response with matching CorrelationId
DurableAgentStateResponse? response = state.Data.ConversationHistory
.OfType<DurableAgentStateResponse>()
.FirstOrDefault(r => r.CorrelationId == this.CorrelationId);
if (response is not null)
{
this._logger.LogDonePollingForResponse(this.SessionId, this.CorrelationId);
return response.ToRunResponse();
}
}
// Wait before polling again with exponential backoff
await Task.Delay(pollInterval, cancellationToken);
// Double the poll interval, but cap it at the maximum
pollInterval = TimeSpan.FromMilliseconds(Math.Min(pollInterval.TotalMilliseconds * 2, maxPollInterval.TotalMilliseconds));
}
}
}
@@ -0,0 +1,165 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.DurableTask.Entities;
namespace Microsoft.Agents.AI.DurableTask;
/// <summary>
/// Represents an agent session ID, which is used to identify a long-running agent session.
/// </summary>
[JsonConverter(typeof(AgentSessionIdJsonConverter))]
public readonly struct AgentSessionId : IEquatable<AgentSessionId>
{
private const string EntityNamePrefix = "dafx-";
private readonly EntityInstanceId _entityId;
/// <summary>
/// Initializes a new instance of the <see cref="AgentSessionId"/> struct.
/// </summary>
/// <param name="name">The name of the agent that owns the session (case-insensitive).</param>
/// <param name="key">The unique key of the agent session (case-sensitive).</param>
public AgentSessionId(string name, string key)
{
this.Name = name;
this._entityId = new EntityInstanceId(ToEntityName(name), key);
}
/// <summary>
/// Converts an agent name to its underlying entity name representation.
/// </summary>
/// <param name="name">The agent name.</param>
/// <returns>The entity name used by Durable Task for this agent.</returns>
public static string ToEntityName(string name) => $"{EntityNamePrefix}{name}";
/// <summary>
/// Gets the name of the agent that owns the session. Names are case-insensitive.
/// </summary>
public string Name { get; }
/// <summary>
/// Gets the unique key of the agent session. Keys are case-sensitive and are used to identify the session.
/// </summary>
public string Key => this._entityId.Key;
internal EntityInstanceId ToEntityId() => this._entityId;
/// <summary>
/// Creates a new <see cref="AgentSessionId"/> with the specified name and a randomly generated key.
/// </summary>
/// <param name="name">The name of the agent that owns the session.</param>
/// <returns>A new <see cref="AgentSessionId"/> with the specified name and a random key.</returns>
public static AgentSessionId WithRandomKey(string name) =>
new(name, Guid.NewGuid().ToString("N"));
/// <summary>
/// Determines whether two <see cref="AgentSessionId"/> instances are equal.
/// </summary>
/// <param name="left">The first <see cref="AgentSessionId"/> to compare.</param>
/// <param name="right">The second <see cref="AgentSessionId"/> to compare.</param>
/// <returns><c>true</c> if the two instances are equal; otherwise, <c>false</c>.</returns>
public static bool operator ==(AgentSessionId left, AgentSessionId right) =>
left._entityId == right._entityId;
/// <summary>
/// Determines whether two <see cref="AgentSessionId"/> instances are not equal.
/// </summary>
/// <param name="left">The first <see cref="AgentSessionId"/> to compare.</param>
/// <param name="right">The second <see cref="AgentSessionId"/> to compare.</param>
/// <returns><c>true</c> if the two instances are not equal; otherwise, <c>false</c>.</returns>
public static bool operator !=(AgentSessionId left, AgentSessionId right) =>
left._entityId != right._entityId;
/// <summary>
/// Determines whether the specified <see cref="AgentSessionId"/> is equal to the current <see cref="AgentSessionId"/>.
/// </summary>
/// <param name="other">The <see cref="AgentSessionId"/> to compare with the current <see cref="AgentSessionId"/>.</param>
/// <returns><c>true</c> if the specified <see cref="AgentSessionId"/> is equal to the current <see cref="AgentSessionId"/>; otherwise, <c>false</c>.</returns>
public bool Equals(AgentSessionId other) => this == other;
/// <summary>
/// Determines whether the specified object is equal to the current <see cref="AgentSessionId"/>.
/// </summary>
/// <param name="obj">The object to compare with the current <see cref="AgentSessionId"/>.</param>
/// <returns><c>true</c> if the specified object is equal to the current <see cref="AgentSessionId"/>; otherwise, <c>false</c>.</returns>
public override bool Equals(object? obj) => obj is AgentSessionId other && this == other;
/// <summary>
/// Returns the hash code for this <see cref="AgentSessionId"/>.
/// </summary>
/// <returns>A hash code for the current <see cref="AgentSessionId"/>.</returns>
public override int GetHashCode() => this._entityId.GetHashCode();
/// <summary>
/// Returns a string representation of this <see cref="AgentSessionId"/> in the form of @name@key.
/// </summary>
/// <returns>A string representation of the current <see cref="AgentSessionId"/>.</returns>
public override string ToString() => this._entityId.ToString();
/// <summary>
/// Converts the string representation of an agent session ID to its <see cref="AgentSessionId"/> equivalent.
/// The input string must be in the form of @name@key.
/// </summary>
/// <param name="sessionIdString">A string containing an agent session ID to convert.</param>
/// <returns>A <see cref="AgentSessionId"/> equivalent to the agent session ID contained in <paramref name="sessionIdString"/>.</returns>
/// <exception cref="ArgumentException">Thrown when <paramref name="sessionIdString"/> is not a valid agent session ID format.</exception>
public static AgentSessionId Parse(string sessionIdString)
{
EntityInstanceId entityId = EntityInstanceId.FromString(sessionIdString);
if (!entityId.Name.StartsWith(EntityNamePrefix, StringComparison.OrdinalIgnoreCase))
{
throw new ArgumentException($"'{sessionIdString}' is not a valid agent session ID.", nameof(sessionIdString));
}
return new AgentSessionId(entityId.Name[EntityNamePrefix.Length..], entityId.Key);
}
/// <summary>
/// Implicitly converts an <see cref="AgentSessionId"/> to an <see cref="EntityInstanceId"/>.
/// This conversion is useful for entity API interoperability.
/// </summary>
/// <param name="agentSessionId">The <see cref="AgentSessionId"/> to convert.</param>
/// <returns>The equivalent <see cref="EntityInstanceId"/>.</returns>
public static implicit operator EntityInstanceId(AgentSessionId agentSessionId) => agentSessionId.ToEntityId();
/// <summary>
/// Implicitly converts an <see cref="EntityInstanceId"/> to an <see cref="AgentSessionId"/>.
/// </summary>
/// <param name="entityId">The <see cref="EntityInstanceId"/> to convert.</param>
/// <returns>The equivalent <see cref="AgentSessionId"/>.</returns>
[System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1065:Do not raise exceptions in unexpected locations", Justification = "Implicit conversion must validate format.")]
public static implicit operator AgentSessionId(EntityInstanceId entityId)
{
if (!entityId.Name.StartsWith(EntityNamePrefix, StringComparison.OrdinalIgnoreCase))
{
throw new ArgumentException($"'{entityId}' is not a valid agent session ID.", nameof(entityId));
}
return new AgentSessionId(entityId.Name[EntityNamePrefix.Length..], entityId.Key);
}
/// <summary>
/// Custom JSON converter for <see cref="AgentSessionId"/> to ensure proper serialization and deserialization.
/// </summary>
public sealed class AgentSessionIdJsonConverter : JsonConverter<AgentSessionId>
{
/// <inheritdoc/>
public override AgentSessionId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType != JsonTokenType.String)
{
throw new JsonException("Expected string value");
}
string value = reader.GetString() ?? string.Empty;
return Parse(value);
}
/// <inheritdoc/>
public override void Write(Utf8JsonWriter writer, AgentSessionId value, JsonSerializerOptions options)
{
writer.WriteStringValue(value.ToString());
}
}
}
@@ -0,0 +1,5 @@
# Release History
## v1.0.0-preview.* (Unreleased)
- Initial public release.
@@ -0,0 +1,31 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.DurableTask.Client;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
namespace Microsoft.Agents.AI.DurableTask;
internal class DefaultDurableAgentClient(DurableTaskClient client, ILoggerFactory loggerFactory) : IDurableAgentClient
{
private readonly DurableTaskClient _client = client ?? throw new ArgumentNullException(nameof(client));
private readonly ILogger _logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger<DefaultDurableAgentClient>();
public async Task<AgentRunHandle> RunAgentAsync(
AgentSessionId sessionId,
RunRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
this._logger.LogSignallingAgent(sessionId);
await this._client.Entities.SignalEntityAsync(
sessionId,
nameof(AgentEntity.RunAgentAsync),
request,
cancellation: cancellationToken);
return new AgentRunHandle(this._client, this._logger, sessionId, request.CorrelationId);
}
}
@@ -0,0 +1,233 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
using Microsoft.DurableTask;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.DurableTask;
/// <summary>
/// A durable AIAgent implementation that uses entity methods to interact with agent entities.
/// </summary>
public sealed class DurableAIAgent : AIAgent
{
private readonly TaskOrchestrationContext _context;
private readonly string _agentName;
/// <summary>
/// Initializes a new instance of the <see cref="DurableAIAgent"/> class.
/// </summary>
/// <param name="context">The orchestration context.</param>
/// <param name="agentName">The name of the agent.</param>
internal DurableAIAgent(TaskOrchestrationContext context, string agentName)
{
this._context = context;
this._agentName = agentName;
}
/// <summary>
/// Creates a new agent thread for this agent using a random session ID.
/// </summary>
/// <returns>A new agent thread.</returns>
public override AgentThread GetNewThread()
{
AgentSessionId sessionId = this._context.NewAgentSessionId(this._agentName);
return new DurableAgentThread(sessionId);
}
/// <summary>
/// Deserializes an agent thread from JSON.
/// </summary>
/// <param name="serializedThread">The serialized thread data.</param>
/// <param name="jsonSerializerOptions">Optional JSON serializer options.</param>
/// <returns>The deserialized agent thread.</returns>
public override AgentThread DeserializeThread(
JsonElement serializedThread,
JsonSerializerOptions? jsonSerializerOptions = null)
{
return DurableAgentThread.Deserialize(serializedThread, jsonSerializerOptions);
}
/// <summary>
/// Runs the agent with messages and returns the response.
/// </summary>
/// <param name="messages">The messages to send to the agent.</param>
/// <param name="thread">The agent thread to use.</param>
/// <param name="options">Optional run options.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The response from the agent.</returns>
public override async Task<AgentRunResponse> RunAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
{
if (cancellationToken != default && cancellationToken.CanBeCanceled)
{
throw new NotSupportedException("Cancellation is not supported for durable agents.");
}
thread ??= this.GetNewThread();
if (thread is not DurableAgentThread durableThread)
{
throw new ArgumentException(
"The provided thread is not valid for a durable agent. " +
"Create a new thread using GetNewThread or provide a thread previously created by this agent.",
paramName: nameof(thread));
}
IList<string>? enableToolNames = null;
bool enableToolCalls = true;
ChatResponseFormat? responseFormat = null;
if (options is DurableAgentRunOptions durableOptions)
{
enableToolCalls = durableOptions.EnableToolCalls;
enableToolNames = durableOptions.EnableToolNames;
responseFormat = durableOptions.ResponseFormat;
}
else if (options is ChatClientAgentRunOptions chatClientOptions && chatClientOptions.ChatOptions?.Tools != null)
{
// Honor the response format from the chat client options if specified
responseFormat = chatClientOptions.ChatOptions?.ResponseFormat;
}
RunRequest request = new([.. messages], responseFormat, enableToolCalls, enableToolNames);
return await this._context.Entities.CallEntityAsync<AgentRunResponse>(durableThread.SessionId, nameof(AgentEntity.RunAgentAsync), request);
}
/// <summary>
/// Runs the agent with messages and returns a simulated streaming response.
/// </summary>
/// <remarks>
/// Streaming is not supported for durable agents, so this method just returns the full response
/// as a single update.
/// </remarks>
/// <param name="messages">The messages to send to the agent.</param>
/// <param name="thread">The agent thread to use.</param>
/// <param name="options">Optional run options.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A streaming response enumerable.</returns>
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
// Streaming is not supported for durable agents, so we just return the full response
// as a single update.
AgentRunResponse response = await this.RunAsync(messages, thread, options, cancellationToken);
foreach (AgentRunResponseUpdate update in response.ToAgentRunResponseUpdates())
{
yield return update;
}
}
/// <summary>
/// Runs the agent with a message and returns the deserialized output as an instance of <typeparamref name="T"/>.
/// </summary>
/// <param name="message">The message to send to the agent.</param>
/// <param name="thread">The agent thread to use.</param>
/// <param name="serializerOptions">Optional JSON serializer options.</param>
/// <param name="options">Optional run options.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <typeparam name="T">The type of the output.</typeparam>
/// <exception cref="ArgumentException">
/// Thrown when the provided <paramref name="options"/> already contains a response schema.
/// Thrown when the provided <paramref name="options"/> is not a <see cref="DurableAgentRunOptions"/>.
/// </exception>
/// <exception cref="InvalidOperationException">
/// Thrown when the agent response is empty or cannot be deserialized.
/// </exception>
/// <returns>The output from the agent.</returns>
public async Task<AgentRunResponse<T>> RunAsync<T>(
string message,
AgentThread? thread = null,
JsonSerializerOptions? serializerOptions = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
{
return await this.RunAsync<T>(
messages: [new ChatMessage(ChatRole.User, message) { CreatedAt = DateTimeOffset.UtcNow }],
thread,
serializerOptions,
options,
cancellationToken);
}
/// <summary>
/// Runs the agent with messages and returns the deserialized output as an instance of <typeparamref name="T"/>.
/// </summary>
/// <param name="messages">The messages to send to the agent.</param>
/// <param name="thread">The agent thread to use.</param>
/// <param name="serializerOptions">Optional JSON serializer options.</param>
/// <param name="options">Optional run options.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <typeparam name="T">The type of the output.</typeparam>
/// <exception cref="ArgumentException">
/// Thrown when the provided <paramref name="options"/> already contains a response schema.
/// Thrown when the provided <paramref name="options"/> is not a <see cref="DurableAgentRunOptions"/>.
/// </exception>
/// <exception cref="InvalidOperationException">
/// Thrown when the agent response is empty or cannot be deserialized.
/// </exception>
/// <returns>The output from the agent.</returns>
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Fallback to reflection-based deserialization is intentional for library flexibility with user-defined types.")]
[UnconditionalSuppressMessage("ReflectionAnalysis", "IL3050", Justification = "Fallback to reflection-based deserialization is intentional for library flexibility with user-defined types.")]
public async Task<AgentRunResponse<T>> RunAsync<T>(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
JsonSerializerOptions? serializerOptions = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
{
options ??= new DurableAgentRunOptions();
if (options is not DurableAgentRunOptions durableOptions)
{
throw new ArgumentException(
"Response schema is only supported with DurableAgentRunOptions when using durable agents. " +
"Cannot specify a response schema when calling RunAsync<T>.",
paramName: nameof(options));
}
if (durableOptions.ResponseFormat is not null)
{
throw new ArgumentException(
"A response schema is already defined in the provided DurableAgentRunOptions. " +
"Cannot specify a response schema when calling RunAsync<T>.",
paramName: nameof(options));
}
// Create the JSON schema for the response type
durableOptions.ResponseFormat = ChatResponseFormat.ForJsonSchema<T>();
AgentRunResponse response = await this.RunAsync(messages, thread, durableOptions, cancellationToken);
// Deserialize the response text to the requested type
if (string.IsNullOrEmpty(response.Text))
{
throw new InvalidOperationException("Agent response is empty and cannot be deserialized.");
}
serializerOptions ??= DurableAgentJsonUtilities.DefaultOptions;
// Prefer source-generated metadata when available to support AOT/trimming scenarios.
// Fallback to reflection-based deserialization for types without source-generated metadata.
// This is necessary since T is a user-provided type that may not have [JsonSerializable] coverage.
JsonTypeInfo? typeInfo = serializerOptions.GetTypeInfo(typeof(T));
T? result = (typeInfo is JsonTypeInfo typedInfo
? (T?)JsonSerializer.Deserialize(response.Text, typedInfo)
: JsonSerializer.Deserialize<T>(response.Text, serializerOptions))
?? throw new InvalidOperationException($"Failed to deserialize agent response to type {typeof(T).Name}.");
return new DurableAIAgentRunResponse<T>(response, result);
}
private sealed class DurableAIAgentRunResponse<T>(AgentRunResponse response, T result)
: AgentRunResponse<T>(response.AsChatResponse())
{
public override T Result { get; } = result;
}
}
@@ -0,0 +1,81 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.DurableTask;
internal class DurableAIAgentProxy(string name, IDurableAgentClient agentClient) : AIAgent
{
private readonly IDurableAgentClient _agentClient = agentClient;
public override string? Name { get; } = name;
public override AgentThread DeserializeThread(
JsonElement serializedThread,
JsonSerializerOptions? jsonSerializerOptions = null)
{
return DurableAgentThread.Deserialize(serializedThread, jsonSerializerOptions);
}
public override AgentThread GetNewThread()
{
return new DurableAgentThread(AgentSessionId.WithRandomKey(this.Name!));
}
public override async Task<AgentRunResponse> RunAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
{
thread ??= this.GetNewThread();
if (thread is not DurableAgentThread durableThread)
{
throw new ArgumentException(
"The provided thread is not valid for a durable agent. " +
"Create a new thread using GetNewThread or provide a thread previously created by this agent.",
paramName: nameof(thread));
}
IList<string>? enableToolNames = null;
bool enableToolCalls = true;
ChatResponseFormat? responseFormat = null;
bool isFireAndForget = false;
if (options is DurableAgentRunOptions durableOptions)
{
enableToolCalls = durableOptions.EnableToolCalls;
enableToolNames = durableOptions.EnableToolNames;
responseFormat = durableOptions.ResponseFormat;
isFireAndForget = durableOptions.IsFireAndForget;
}
else if (options is ChatClientAgentRunOptions chatClientOptions)
{
// Honor the response format from the chat client options if specified
responseFormat = chatClientOptions.ChatOptions?.ResponseFormat;
}
RunRequest request = new([.. messages], responseFormat, enableToolCalls, enableToolNames);
AgentSessionId sessionId = durableThread.SessionId;
AgentRunHandle agentRunHandle = await this._agentClient.RunAgentAsync(sessionId, request, cancellationToken);
if (isFireAndForget)
{
// If the request is fire and forget, return an empty response.
return new AgentRunResponse();
}
return await agentRunHandle.ReadAgentResponseAsync(cancellationToken);
}
public override IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
{
throw new NotSupportedException("Streaming is not supported for durable agents.");
}
}
@@ -0,0 +1,161 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.DurableTask;
using Microsoft.DurableTask.Client;
using Microsoft.DurableTask.Entities;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace Microsoft.Agents.AI.DurableTask;
/// <summary>
/// A context for durable agents that provides access to orchestration capabilities.
/// This class provides thread-static access to the current agent context.
/// </summary>
public class DurableAgentContext
{
private static readonly AsyncLocal<DurableAgentContext?> s_currentContext = new();
private readonly IServiceProvider _services;
private readonly CancellationToken _cancellationToken;
internal DurableAgentContext(
TaskEntityContext entityContext,
DurableTaskClient client,
IHostApplicationLifetime lifetime,
IServiceProvider services)
{
this.EntityContext = entityContext;
this.CurrentThread = new DurableAgentThread(entityContext.Id);
this.Client = client;
this._services = services;
this._cancellationToken = lifetime.ApplicationStopping;
}
/// <summary>
/// Gets the current durable agent context instance.
/// </summary>
/// <exception cref="InvalidOperationException">Thrown when no agent context is available.</exception>
public static DurableAgentContext Current => s_currentContext.Value ??
throw new InvalidOperationException("No agent context found!");
/// <summary>
/// Gets the entity context for this agent.
/// </summary>
public TaskEntityContext EntityContext { get; }
/// <summary>
/// Gets the durable task client for this agent.
/// </summary>
public DurableTaskClient Client { get; }
/// <summary>
/// Gets the current agent thread.
/// </summary>
public DurableAgentThread CurrentThread { get; }
/// <summary>
/// Sets the current durable agent context instance.
/// This is called internally by the agent entity during execution.
/// </summary>
/// <param name="context">The context instance to set.</param>
internal static void SetCurrent(DurableAgentContext context)
{
if (s_currentContext.Value is not null)
{
throw new InvalidOperationException("A DurableAgentContext has already been set for this AsyncLocal context.");
}
s_currentContext.Value = context;
}
/// <summary>
/// Clears the current durable agent context instance.
/// This is called internally by the agent entity after execution.
/// </summary>
internal static void ClearCurrent()
{
s_currentContext.Value = null;
}
/// <summary>
/// Schedules a new orchestration instance.
/// </summary>
/// <remarks>
/// When run in the context of a durable agent tool, the actual scheduling of the orchestration
/// occurs after the completion of the tool call. This allows the durable scheduling of the orchestration
/// and the agent state update to be committed atomically in a single transaction.
/// </remarks>
/// <param name="name">The name of the orchestration to schedule.</param>
/// <param name="input">The input to the orchestration.</param>
/// <param name="options">The options for the orchestration.</param>
/// <returns>The instance ID of the scheduled orchestration.</returns>
public string ScheduleNewOrchestration(
TaskName name,
object? input = null,
StartOrchestrationOptions? options = null)
{
return this.EntityContext.ScheduleNewOrchestration(name, input, options);
}
/// <summary>
/// Gets the status of an orchestration instance.
/// </summary>
/// <param name="instanceId">The instance ID of the orchestration to get the status of.</param>
/// <param name="includeDetails">Whether to include detailed information about the orchestration.</param>
/// <returns>The status of the orchestration.</returns>
public Task<OrchestrationMetadata?> GetOrchestrationStatusAsync(string instanceId, bool includeDetails = false)
{
return this.Client.GetInstanceAsync(instanceId, includeDetails, this._cancellationToken);
}
/// <summary>
/// Raises an event on an orchestration instance.
/// </summary>
/// <param name="instanceId">The instance ID of the orchestration to raise the event on.</param>
/// <param name="eventName">The name of the event to raise.</param>
/// <param name="eventData">The data to send with the event.</param>
#pragma warning disable CA1030 // Use events where appropriate
public Task RaiseOrchestrationEventAsync(string instanceId, string eventName, object? eventData = null)
#pragma warning restore CA1030 // Use events where appropriate
{
return this.Client.RaiseEventAsync(instanceId, eventName, eventData, this._cancellationToken);
}
/// <summary>
/// Asks the <see cref="DurableAgentContext"/> for an object of the specified type, <typeparamref name="TService"/>.
/// </summary>
/// <typeparam name="TService">The type of the object being requested.</typeparam>
/// <param name="serviceKey">An optional key to identify the service instance.</param>
/// <returns>The service instance, or <see langword="null"/> if the service is not found.</returns>
/// <exception cref="InvalidOperationException">
/// Thrown when <paramref name="serviceKey"/> is not <see langword="null"/> and the service provider does not support keyed services.
/// </exception>
public TService? GetService<TService>(object? serviceKey = null)
{
return this.GetService(typeof(TService), serviceKey) is TService service ? service : default;
}
/// <summary>
/// Asks the <see cref="DurableAgentContext"/> for an object of the specified type, <paramref name="serviceType"/>.
/// </summary>
/// <param name="serviceType">The type of the object being requested.</param>
/// <param name="serviceKey">An optional key to identify the service instance.</param>
/// <returns>The service instance, or <see langword="null"/> if the service is not found.</returns>
/// <exception cref="InvalidOperationException">
/// Thrown when <paramref name="serviceKey"/> is not <see langword="null"/> and the service provider does not support keyed services.
/// </exception>
public object? GetService(Type serviceType, object? serviceKey = null)
{
if (serviceKey is not null)
{
if (this._services is not IKeyedServiceProvider keyedServiceProvider)
{
throw new InvalidOperationException("The service provider does not support keyed services.");
}
return keyedServiceProvider.GetKeyedService(serviceType, serviceKey);
}
return this._services.GetService(serviceType);
}
}
@@ -0,0 +1,99 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Agents.AI.DurableTask.State;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.DurableTask;
/// <summary>Provides JSON serialization utilities and source-generated contracts for Durable Agent types.</summary>
/// <remarks>
/// <para>
/// This mirrors the pattern used by other libraries (e.g. <c>WorkflowsJsonUtilities</c>) to enable Native AOT and trimming
/// friendly serialization without relying on runtime reflection. It establishes a singleton <see cref="JsonSerializerOptions"/>
/// instance that is preconfigured with:
/// </para>
/// <list type="number">
/// <item><description><see cref="JsonSerializerDefaults.Web"/> baseline defaults.</description></item>
/// <item><description><see cref="JsonIgnoreCondition.WhenWritingNull"/> for default null-value suppression.</description></item>
/// <item><description><see cref="JsonNumberHandling.AllowReadingFromString"/> to tolerate numbers encoded as strings.</description></item>
/// <item><description>Chained type info resolvers from shared agent abstractions to cover cross-package types (e.g. <see cref="ChatMessage"/>, <see cref="AgentRunResponse"/>).</description></item>
/// </list>
/// <para>
/// Keep the list of <c>[JsonSerializable]</c> types in sync with the Durable Agent data model anytime new state or request/response
/// containers are introduced that must round-trip via JSON.
/// </para>
/// </remarks>
internal static partial class DurableAgentJsonUtilities
{
/// <summary>
/// Gets the singleton <see cref="JsonSerializerOptions"/> used for Durable Agent serialization.
/// </summary>
public static JsonSerializerOptions DefaultOptions { get; } = CreateDefaultOptions();
/// <summary>
/// Serializes a sequence of chat messages using the durable agent default options.
/// </summary>
/// <param name="messages">The messages to serialize.</param>
/// <returns>A <see cref="JsonElement"/> representing the serialized messages.</returns>
public static JsonElement Serialize(this IEnumerable<ChatMessage> messages) =>
JsonSerializer.SerializeToElement(messages, DefaultOptions.GetTypeInfo(typeof(IEnumerable<ChatMessage>)));
/// <summary>
/// Deserializes chat messages from a <see cref="JsonElement"/> using durable agent options.
/// </summary>
/// <param name="element">The JSON element containing the messages.</param>
/// <returns>The deserialized list of chat messages.</returns>
public static List<ChatMessage> DeserializeMessages(this JsonElement element) =>
(List<ChatMessage>?)element.Deserialize(DefaultOptions.GetTypeInfo(typeof(List<ChatMessage>))) ?? [];
/// <summary>
/// Creates the configured <see cref="JsonSerializerOptions"/> instance for durable agents.
/// </summary>
/// <returns>The configured options.</returns>
[UnconditionalSuppressMessage("ReflectionAnalysis", "IL3050:RequiresDynamicCode", Justification = "Converter is guarded by IsReflectionEnabledByDefault check.")]
[UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access", Justification = "Converter is guarded by IsReflectionEnabledByDefault check.")]
private static JsonSerializerOptions CreateDefaultOptions()
{
// Base configuration from the source-generated context below.
JsonSerializerOptions options = new(JsonContext.Default.Options)
{
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, // same as AgentAbstractionsJsonUtilities and AIJsonUtilities
};
// Chain in shared abstractions resolver (Microsoft.Extensions.AI + Agent abstractions) so dependent types are covered.
options.TypeInfoResolverChain.Clear();
options.TypeInfoResolverChain.Add(AgentAbstractionsJsonUtilities.DefaultOptions.TypeInfoResolver!);
options.TypeInfoResolverChain.Add(JsonContext.Default.Options.TypeInfoResolver!);
if (JsonSerializer.IsReflectionEnabledByDefault)
{
options.Converters.Add(new JsonStringEnumConverter());
}
options.MakeReadOnly();
return options;
}
// Keep in sync with CreateDefaultOptions above.
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
NumberHandling = JsonNumberHandling.AllowReadingFromString)]
// Durable Agent State Types
[JsonSerializable(typeof(DurableAgentState))]
[JsonSerializable(typeof(DurableAgentThread))]
// Request Types
[JsonSerializable(typeof(RunRequest))]
// Primitive / Supporting Types
[JsonSerializable(typeof(ChatMessage))]
[JsonSerializable(typeof(JsonElement))]
[ExcludeFromCodeCoverage]
internal sealed partial class JsonContext : JsonSerializerContext;
}
@@ -0,0 +1,36 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.DurableTask;
/// <summary>
/// Options for running a durable agent.
/// </summary>
public sealed class DurableAgentRunOptions : AgentRunOptions
{
/// <summary>
/// Gets or sets whether to enable tool calls for this request.
/// </summary>
public bool EnableToolCalls { get; set; } = true;
/// <summary>
/// Gets or sets the collection of tool names to enable. If not specified, all tools are enabled.
/// </summary>
public IList<string>? EnableToolNames { get; set; }
/// <summary>
/// Gets or sets the response format for the agent's response.
/// </summary>
public ChatResponseFormat? ResponseFormat { get; set; }
/// <summary>
/// Gets or sets whether to fire and forget the agent run request.
/// </summary>
/// <remarks>
/// If <see cref="IsFireAndForget"/> is <c>true</c>, the agent run request will be sent and the method will return immediately.
/// The caller will not wait for the agent to complete the run and will not receive a response. This setting is useful for
/// long-running tasks where the caller does not need to wait for the agent to complete the run.
/// </remarks>
public bool IsFireAndForget { get; set; }
}
@@ -0,0 +1,77 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.DurableTask;
/// <summary>
/// An agent thread implementation for durable agents.
/// </summary>
[DebuggerDisplay("{SessionId}")]
public sealed class DurableAgentThread : AgentThread
{
[JsonConstructor]
internal DurableAgentThread(AgentSessionId sessionId)
{
this.SessionId = sessionId;
}
/// <summary>
/// Gets the agent session ID.
/// </summary>
[JsonInclude]
[JsonPropertyName("sessionId")]
internal AgentSessionId SessionId { get; }
/// <inheritdoc/>
public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
{
return JsonSerializer.SerializeToElement(
this,
DurableAgentJsonUtilities.DefaultOptions.GetTypeInfo(typeof(DurableAgentThread)));
}
/// <summary>
/// Deserializes a DurableAgentThread from JSON.
/// </summary>
/// <param name="serializedThread">The serialized thread data.</param>
/// <param name="jsonSerializerOptions">Optional JSON serializer options.</param>
/// <returns>The deserialized DurableAgentThread.</returns>
internal static DurableAgentThread Deserialize(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
{
if (!serializedThread.TryGetProperty("sessionId", out JsonElement sessionIdElement) ||
sessionIdElement.ValueKind != JsonValueKind.String)
{
throw new JsonException("Invalid or missing sessionId property.");
}
string sessionIdString = sessionIdElement.GetString() ?? throw new JsonException("sessionId property is null.");
AgentSessionId sessionId = AgentSessionId.Parse(sessionIdString);
return new DurableAgentThread(sessionId);
}
/// <inheritdoc/>
public override object? GetService(Type serviceType, object? serviceKey = null)
{
// This is a common convention for MAF agents.
if (serviceType == typeof(AgentThreadMetadata))
{
return new AgentThreadMetadata(conversationId: this.SessionId.ToString());
}
if (serviceType == typeof(AgentSessionId))
{
return this.SessionId;
}
return base.GetService(serviceType, serviceKey);
}
/// <inheritdoc/>
public override string ToString()
{
return this.SessionId.ToString();
}
}
@@ -0,0 +1,84 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.DurableTask;
/// <summary>
/// Builder for configuring durable agents.
/// </summary>
public sealed class DurableAgentsOptions
{
// Agent names are case-insensitive
private readonly Dictionary<string, Func<IServiceProvider, AIAgent>> _agentFactories = new(StringComparer.OrdinalIgnoreCase);
internal DurableAgentsOptions()
{
}
/// <summary>
/// Adds an AI agent factory to the options.
/// </summary>
/// <param name="name">The name of the agent.</param>
/// <param name="factory">The factory function to create the agent.</param>
/// <returns>The options instance.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="name"/> or <paramref name="factory"/> is null.</exception>
public DurableAgentsOptions AddAIAgentFactory(string name, Func<IServiceProvider, AIAgent> factory)
{
ArgumentNullException.ThrowIfNull(name);
ArgumentNullException.ThrowIfNull(factory);
this._agentFactories.Add(name, factory);
return this;
}
/// <summary>
/// Adds a list of AI agents to the options.
/// </summary>
/// <param name="agents">The list of agents to add.</param>
/// <returns>The options instance.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="agents"/> is null.</exception>
public DurableAgentsOptions AddAIAgents(params IEnumerable<AIAgent> agents)
{
ArgumentNullException.ThrowIfNull(agents);
foreach (AIAgent agent in agents)
{
this.AddAIAgent(agent);
}
return this;
}
/// <summary>
/// Adds an AI agent to the options.
/// </summary>
/// <param name="agent">The agent to add.</param>
/// <returns>The options instance.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="agent"/> is null.</exception>
/// <exception cref="ArgumentException">
/// Thrown when <paramref name="agent.Name"/> is null or whitespace or when an agent with the same name has already been registered.
/// </exception>
public DurableAgentsOptions AddAIAgent(AIAgent agent)
{
ArgumentNullException.ThrowIfNull(agent);
if (string.IsNullOrWhiteSpace(agent.Name))
{
throw new ArgumentException($"{nameof(agent.Name)} must not be null or whitespace.", nameof(agent));
}
if (this._agentFactories.ContainsKey(agent.Name))
{
throw new ArgumentException($"An agent with name '{agent.Name}' has already been registered.", nameof(agent));
}
this._agentFactories.Add(agent.Name, sp => agent);
return this;
}
/// <summary>
/// Gets the agents that have been added to this builder.
/// </summary>
/// <returns>A read-only collection of agents.</returns>
internal IReadOnlyDictionary<string, Func<IServiceProvider, AIAgent>> GetAgentFactories()
{
return this._agentFactories.AsReadOnly();
}
}
@@ -0,0 +1,125 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Runtime.CompilerServices;
using Microsoft.Agents.AI;
using Microsoft.DurableTask.Entities;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
namespace Microsoft.Agents.AI.DurableTask;
internal sealed class EntityAgentWrapper(
AIAgent innerAgent,
TaskEntityContext entityContext,
RunRequest runRequest,
IServiceProvider? entityScopedServices = null) : DelegatingAIAgent(innerAgent)
{
private readonly TaskEntityContext _entityContext = entityContext;
private readonly RunRequest _runRequest = runRequest;
private readonly IServiceProvider? _entityScopedServices = entityScopedServices;
// The ID of the agent is always the entity ID.
public override string Id => this._entityContext.Id.ToString();
public override async Task<AgentRunResponse> RunAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
{
AgentRunResponse response = await base.RunAsync(
messages,
thread,
this.GetAgentEntityRunOptions(options),
cancellationToken);
response.AgentId = this.Id;
return response;
}
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await foreach (AgentRunResponseUpdate update in base.RunStreamingAsync(
messages,
thread,
this.GetAgentEntityRunOptions(options),
cancellationToken))
{
update.AgentId = this.Id;
yield return update;
}
}
// Override the GetService method to provide entity-scoped services.
public override object? GetService(Type serviceType, object? serviceKey = null)
{
object? result = null;
if (this._entityScopedServices is not null)
{
result = (serviceKey is not null && this._entityScopedServices is IKeyedServiceProvider keyedServiceProvider)
? keyedServiceProvider.GetKeyedService(serviceType, serviceKey)
: this._entityScopedServices.GetService(serviceType);
}
return result ?? base.GetService(serviceType, serviceKey);
}
private AgentRunOptions GetAgentEntityRunOptions(AgentRunOptions? options = null)
{
// Copied/modified from FunctionInvocationDelegatingAgent.cs in microsoft/agent-framework.
if (options is null || options.GetType() == typeof(AgentRunOptions))
{
options = new ChatClientAgentRunOptions();
}
if (options is not ChatClientAgentRunOptions chatAgentRunOptions)
{
throw new NotSupportedException($"Function Invocation Middleware is only supported without options or with {nameof(ChatClientAgentRunOptions)}.");
}
Func<IChatClient, IChatClient>? originalFactory = chatAgentRunOptions.ChatClientFactory;
chatAgentRunOptions.ChatClientFactory = chatClient =>
{
ChatClientBuilder builder = chatClient.AsBuilder();
if (originalFactory is not null)
{
builder.Use(originalFactory);
}
// Update the run options based on the run request.
// NOTE: Function middleware can go here if needed in the future.
return builder.ConfigureOptions(
newOptions =>
{
// Update the response format if requested by the caller.
if (this._runRequest.ResponseFormat is not null)
{
newOptions.ResponseFormat = this._runRequest.ResponseFormat;
}
// Update the tools if requested by the caller.
if (this._runRequest.EnableToolCalls)
{
IList<AITool>? tools = chatAgentRunOptions.ChatOptions?.Tools;
if (tools is not null && this._runRequest.EnableToolNames?.Count > 0)
{
// Filter tools to only include those with matching names
newOptions.Tools = [.. tools.Where(tool => this._runRequest.EnableToolNames.Contains(tool.Name))];
}
}
else
{
newOptions.Tools = null;
}
})
.Build();
};
return options;
}
}
@@ -0,0 +1,35 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.DurableTask;
/// <summary>
/// Handler for processing responses from the agent. This is typically used to send messages to the user.
/// </summary>
public interface IAgentResponseHandler
{
/// <summary>
/// Handles a streaming response update from the agent. This is typically used to send messages to the user.
/// </summary>
/// <param name="messageStream">
/// The stream of messages from the agent.
/// </param>
/// <param name="cancellationToken">
/// Signals that the operation should be cancelled.
/// </param>
ValueTask OnStreamingResponseUpdateAsync(
IAsyncEnumerable<AgentRunResponseUpdate> messageStream,
CancellationToken cancellationToken);
/// <summary>
/// Handles a discrete response from the agent. This is typically used to send messages to the user.
/// </summary>
/// <param name="message">
/// The message from the agent.
/// </param>
/// <param name="cancellationToken">
/// Signals that the operation should be cancelled.
/// </param>
ValueTask OnAgentResponseAsync(
AgentRunResponse message,
CancellationToken cancellationToken);
}
@@ -0,0 +1,21 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.DurableTask;
/// <summary>
/// Represents a client for interacting with a durable agent.
/// </summary>
internal interface IDurableAgentClient
{
/// <summary>
/// Runs an agent with the specified request.
/// </summary>
/// <param name="sessionId">The ID of the target agent session.</param>
/// <param name="request">The request containing the message, role, and configuration.</param>
/// <param name="cancellationToken">The cancellation token for scheduling the request.</param>
/// <returns>A task that returns a handle used to read the agent response.</returns>
Task<AgentRunHandle> RunAgentAsync(
AgentSessionId sessionId,
RunRequest request,
CancellationToken cancellationToken = default);
}
@@ -0,0 +1,49 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.DurableTask;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
internal static partial class Logs
{
[LoggerMessage(
EventId = 1,
Level = LogLevel.Information,
Message = "[{SessionId}] Request: [{Role}] {Content}")]
public static partial void LogAgentRequest(
this ILogger logger,
AgentSessionId sessionId,
ChatRole role,
string content);
[LoggerMessage(
EventId = 2,
Level = LogLevel.Information,
Message = "[{SessionId}] Response: [{Role}] {Content} (Input tokens: {InputTokenCount}, Output tokens: {OutputTokenCount}, Total tokens: {TotalTokenCount})")]
public static partial void LogAgentResponse(
this ILogger logger,
AgentSessionId sessionId,
ChatRole role,
string content,
long? inputTokenCount,
long? outputTokenCount,
long? totalTokenCount);
[LoggerMessage(
EventId = 3,
Level = LogLevel.Information,
Message = "Signalling agent with session ID '{SessionId}'")]
public static partial void LogSignallingAgent(this ILogger logger, AgentSessionId sessionId);
[LoggerMessage(
EventId = 4,
Level = LogLevel.Information,
Message = "Polling agent with session ID '{SessionId}' for response with correlation ID '{CorrelationId}'")]
public static partial void LogStartPollingForResponse(this ILogger logger, AgentSessionId sessionId, string correlationId);
[LoggerMessage(
EventId = 5,
Level = LogLevel.Information,
Message = "Found response for agent with session ID '{SessionId}' with correlation ID '{CorrelationId}'")]
public static partial void LogDonePollingForResponse(this ILogger logger, AgentSessionId sessionId, string correlationId);
}
@@ -0,0 +1,39 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>$(ProjectsCoreTargetFrameworks)</TargetFrameworks>
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsDebugCoreTargetFrameworks)</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<!-- CA2007: This rule should generally be suppressed in Durable Task libraries -->
<!-- MEAI001: UserInputRequestContent is experimental but used in source-generated code for AgentRunResponse -->
<NoWarn>$(NoWarn);CA2007;MEAI001</NoWarn>
</PropertyGroup>
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
<!-- NuGet package metadata -->
<PropertyGroup>
<Title>Durable Task extensions for Microsoft Agent Framework</Title>
<Description>Provides distributed durable execution capabilities for agents built with Microsoft Agent Framework.</Description>
<PackageReadmeFile>README.md</PackageReadmeFile>
</PropertyGroup>
<!-- Durable Task dependencies -->
<ItemGroup>
<PackageReference Include="Microsoft.DurableTask.Client" />
<PackageReference Include="Microsoft.DurableTask.Worker" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Microsoft.Agents.AI.DurableTask.UnitTests" />
<InternalsVisibleTo Include="Microsoft.Agents.AI.Hosting.AzureFunctions" />
</ItemGroup>
<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="/" />
</ItemGroup>
</Project>
@@ -0,0 +1,42 @@
# Microsoft.Agents.AI.DurableTask
The Microsoft Agent Framework provides a programming model for building agents and agent workflows in .NET. This package, the *Durable Task extension for the Agent Framework*, extends the Agent Framework programming model with the following capabilities:
- Stateful, durable execution of agents in distributed environments
- Automatic conversation history management
- Long-running agent workflows as "durable orchestrator" functions
- Tools and dashboards for managing and monitoring agents and agent workflows
These capabilities are implemented using foundational technologies from the Durable Task technology stack:
- [Durable Entities](https://learn.microsoft.com/azure/azure-functions/durable/durable-functions-entities) for stateful, durable execution of agents
- [Durable Orchestrations](https://learn.microsoft.com/azure/azure-functions/durable/durable-functions-orchestrations) for long-running agent workflows
- The [Durable Task Scheduler](https://learn.microsoft.com/azure/azure-functions/durable/durable-task-scheduler/choose-orchestration-framework) for managing durable task execution and observability at scale
This package can be used by itself or in conjunction with the `Microsoft.Agents.AI.Hosting.AzureFunctions` package, which provides additional features via Azure Functions integration.
## Install the package
From the command-line:
```bash
dotnet add package Microsoft.Agents.AI.DurableTask
```
Or directly in your project file:
```xml
<ItemGroup>
<PackageReference Include="Microsoft.Agents.AI.DurableTask" Version="[CURRENTVERSION]" />
</ItemGroup>
```
You can alternatively just reference the `Microsoft.Agents.AI.Hosting.AzureFunctions` package if you're hosting your agents and orchestrations in the Azure Functions .NET Isolated worker.
## Usage Examples
For a comprehensive tour of all the functionality, concepts, and APIs, check out the [Azure Functions samples](https://github.com/microsoft/agent-framework/tree/main/dotnet/samples/).
## Feedback & Contributing
We welcome feedback and contributions in [our GitHub repo](https://github.com/microsoft/agent-framework).
@@ -0,0 +1,76 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.DurableTask;
/// <summary>
/// Represents a request to run an agent with a specific message and configuration.
/// </summary>
public record RunRequest
{
/// <summary>
/// Gets the list of chat messages to send to the agent (for multi-message requests).
/// </summary>
public IList<ChatMessage> Messages { get; init; } = [];
/// <summary>
/// Gets the optional response format for the agent's response.
/// </summary>
public ChatResponseFormat? ResponseFormat { get; init; }
/// <summary>
/// Gets whether to enable tool calls for this request.
/// </summary>
public bool EnableToolCalls { get; init; } = true;
/// <summary>
/// Gets the collection of tool names to enable. If not specified, all tools are enabled.
/// </summary>
public IList<string>? EnableToolNames { get; init; }
/// <summary>
/// Gets or sets the correlation ID for correlating this request with its response.
/// </summary>
[JsonInclude]
internal string CorrelationId { get; set; } = Guid.NewGuid().ToString("N");
/// <summary>
/// Initializes a new instance of the <see cref="RunRequest"/> class for a single message.
/// </summary>
/// <param name="message">The message to send to the agent.</param>
/// <param name="role">The role of the message sender (User or System).</param>
/// <param name="responseFormat">Optional response format for the agent's response.</param>
/// <param name="enableToolCalls">Whether to enable tool calls for this request.</param>
/// <param name="enableToolNames">Optional collection of tool names to enable. If not specified, all tools are enabled.</param>
public RunRequest(
string message,
ChatRole? role = null,
ChatResponseFormat? responseFormat = null,
bool enableToolCalls = true,
IList<string>? enableToolNames = null)
: this([new ChatMessage(role ?? ChatRole.User, message) { CreatedAt = DateTimeOffset.UtcNow }], responseFormat, enableToolCalls, enableToolNames)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="RunRequest"/> class for multiple messages.
/// </summary>
/// <param name="messages">The list of chat messages to send to the agent.</param>
/// <param name="responseFormat">Optional response format for the agent's response.</param>
/// <param name="enableToolCalls">Whether to enable tool calls for this request.</param>
/// <param name="enableToolNames">Optional collection of tool names to enable. If not specified, all tools are enabled.</param>
[JsonConstructor]
public RunRequest(
IList<ChatMessage> messages,
ChatResponseFormat? responseFormat = null,
bool enableToolCalls = true,
IList<string>? enableToolNames = null)
{
this.Messages = messages;
this.ResponseFormat = responseFormat;
this.EnableToolCalls = enableToolCalls;
this.EnableToolNames = enableToolNames;
}
}
@@ -0,0 +1,159 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
using Microsoft.Agents.AI.DurableTask.State;
using Microsoft.DurableTask;
using Microsoft.DurableTask.Client;
using Microsoft.DurableTask.Worker;
using Microsoft.Extensions.DependencyInjection;
namespace Microsoft.Agents.AI.DurableTask;
/// <summary>
/// Agent-specific extension methods for the <see cref="IServiceCollection"/> class.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Gets a durable agent proxy by name.
/// </summary>
/// <param name="services">The service provider.</param>
/// <param name="name">The name of the agent.</param>
/// <returns>The durable agent proxy.</returns>
/// <exception cref="KeyNotFoundException">Thrown if the agent proxy is not found.</exception>
public static AIAgent GetDurableAgentProxy(this IServiceProvider services, string name)
{
return services.GetKeyedService<AIAgent>(name)
?? throw new KeyNotFoundException($"A durable agent with name '{name}' has not been registered.");
}
/// <summary>
/// Configures the Durable Agents services via the service collection.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configure">A delegate to configure the durable agents.</param>
/// <param name="workerBuilder">A delegate to configure the Durable Task worker.</param>
/// <param name="clientBuilder">A delegate to configure the Durable Task client.</param>
/// <returns>The service collection.</returns>
public static IServiceCollection ConfigureDurableAgents(
this IServiceCollection services,
Action<DurableAgentsOptions> configure,
Action<IDurableTaskWorkerBuilder>? workerBuilder = null,
Action<IDurableTaskClientBuilder>? clientBuilder = null)
{
ArgumentNullException.ThrowIfNull(configure);
DurableAgentsOptions options = services.ConfigureDurableAgents(configure);
// A worker is required to run the agent entities
services.AddDurableTaskWorker(builder =>
{
workerBuilder?.Invoke(builder);
builder.AddTasks(registry =>
{
foreach (string name in options.GetAgentFactories().Keys)
{
registry.AddEntity<AgentEntity>(AgentSessionId.ToEntityName(name));
}
});
});
// The client is needed to send notifications to the agent entities from non-orchestrator code
if (clientBuilder != null)
{
services.AddDurableTaskClient(clientBuilder);
}
services.AddSingleton<IDurableAgentClient, DefaultDurableAgentClient>();
return services;
}
// This is internal because it's also used by Microsoft.Azure.Functions.DurableAgents, which is a friend assembly project.
internal static DurableAgentsOptions ConfigureDurableAgents(
this IServiceCollection services,
Action<DurableAgentsOptions> configure)
{
DurableAgentsOptions options = new();
configure(options);
var agents = options.GetAgentFactories();
// The agent dictionary contains the real agent factories, which is used by the agent entities.
services.AddSingleton(agents);
// The keyed services are used to resolve durable agent *proxy* instances for external clients.
foreach (var factory in agents)
{
services.AddKeyedSingleton(factory.Key, (sp, _) => factory.Value(sp).AsDurableAgentProxy(sp));
}
// A custom data converter is needed because the default chat client uses camel case for JSON properties,
// which is not the default behavior for the Durable Task SDK.
services.AddSingleton<DataConverter, DefaultDataConverter>();
return options;
}
private sealed class DefaultDataConverter : DataConverter
{
// Use durable agent options (web defaults + camel case by default) with case-insensitive matching.
// We clone to apply naming/casing tweaks while retaining source-generated metadata where available.
private static readonly JsonSerializerOptions s_options = new(DurableAgentJsonUtilities.DefaultOptions)
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
};
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Fallback path uses reflection when metadata unavailable.")]
[UnconditionalSuppressMessage("ReflectionAnalysis", "IL3050", Justification = "Fallback path uses reflection when metadata unavailable.")]
public override object? Deserialize(string? data, Type targetType)
{
if (data is null)
{
return null;
}
if (targetType == typeof(DurableAgentState))
{
return JsonSerializer.Deserialize(data, DurableAgentStateJsonContext.Default.DurableAgentState);
}
JsonTypeInfo? typeInfo = s_options.GetTypeInfo(targetType);
if (typeInfo is JsonTypeInfo typedInfo)
{
return JsonSerializer.Deserialize(data, typedInfo);
}
// Fallback (may trigger trimming/AOT warnings for unsupported dynamic types).
return JsonSerializer.Deserialize(data, targetType, s_options);
}
[return: NotNullIfNotNull(nameof(value))]
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Fallback path uses reflection when metadata unavailable.")]
[UnconditionalSuppressMessage("ReflectionAnalysis", "IL3050", Justification = "Fallback path uses reflection when metadata unavailable.")]
public override string? Serialize(object? value)
{
if (value is null)
{
return null;
}
if (value is DurableAgentState durableAgentState)
{
return JsonSerializer.Serialize(durableAgentState, DurableAgentStateJsonContext.Default.DurableAgentState);
}
JsonTypeInfo? typeInfo = s_options.GetTypeInfo(value.GetType());
if (typeInfo is JsonTypeInfo typedInfo)
{
return JsonSerializer.Serialize(value, typedInfo);
}
return JsonSerializer.Serialize(value, s_options);
}
}
}
@@ -0,0 +1,27 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.DurableTask.State;
/// <summary>
/// Represents the state of a durable agent, including its conversation history.
/// </summary>
[JsonConverter(typeof(DurableAgentStateJsonConverter))]
internal sealed class DurableAgentState
{
/// <summary>
/// Gets the data of the durable agent.
/// </summary>
[JsonPropertyName("data")]
public DurableAgentStateData Data { get; init; } = new();
/// <summary>
/// Gets the schema version of the durable agent state.
/// </summary>
/// <remarks>
/// The version is specified in semver (i.e. "major.minor.patch") format.
/// </remarks>
[JsonPropertyName("schemaVersion")]
public string SchemaVersion { get; init; } = "1.0.0";
}

Some files were not shown because too many files have changed in this diff Show More