diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000000..66023c649a --- /dev/null +++ b/.github/CODEOWNERS @@ -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 diff --git a/.github/actions/azure-functions-integration-setup/action.yml b/.github/actions/azure-functions-integration-setup/action.yml new file mode 100644 index 0000000000..6be5afb814 --- /dev/null +++ b/.github/actions/azure-functions-integration-setup/action.yml @@ -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 diff --git a/.github/workflows/dotnet-build-and-test.yml b/.github/workflows/dotnet-build-and-test.yml index 8c9fe22ffc..ecf093a3e9 100644 --- a/.github/workflows/dotnet-build-and-test.yml +++ b/.github/workflows/dotnet-build-and-test.yml @@ -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 }} diff --git a/.github/workflows/python-code-quality.yml b/.github/workflows/python-code-quality.yml index 871436509c..dd4c0b57cf 100644 --- a/.github/workflows/python-code-quality.yml +++ b/.github/workflows/python-code-quality.yml @@ -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 diff --git a/.github/workflows/python-merge-tests.yml b/.github/workflows/python-merge-tests.yml index bd5768b968..a30b3c4ac3 100644 --- a/.github/workflows/python-merge-tests.yml +++ b/.github/workflows/python-merge-tests.yml @@ -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 diff --git a/.gitignore b/.gitignore index 70c1563f5a..162eec9ba7 100644 --- a/.gitignore +++ b/.gitignore @@ -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 \ No newline at end of file +*.db diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 3a85c615b7..fd7e69fd52 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -97,6 +97,20 @@ + + + + + + + + + + + + + + diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 6009e00f00..588dff618a 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -23,6 +23,17 @@ + + + + + + + + + + + @@ -80,6 +91,9 @@ + + + @@ -162,6 +176,7 @@ + @@ -289,13 +304,16 @@ + + + @@ -305,7 +323,9 @@ + + @@ -317,12 +337,15 @@ + + + diff --git a/dotnet/nuget/nuget-package.props b/dotnet/nuget/nuget-package.props index cbcc78bb9d..df707d9031 100644 --- a/dotnet/nuget/nuget-package.props +++ b/dotnet/nuget/nuget-package.props @@ -2,9 +2,9 @@ 1.0.0 - $(VersionPrefix)-$(VersionSuffix).251110.2 - $(VersionPrefix)-preview.251110.2 - 1.0.0-preview.251110.2 + $(VersionPrefix)-$(VersionSuffix).251113.1 + $(VersionPrefix)-preview.251113.1 + 1.0.0-preview.251113.1 Debug;Release;Publish true diff --git a/dotnet/samples/AzureFunctions/.editorconfig b/dotnet/samples/AzureFunctions/.editorconfig new file mode 100644 index 0000000000..b43bf5ebd0 --- /dev/null +++ b/dotnet/samples/AzureFunctions/.editorconfig @@ -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 diff --git a/dotnet/samples/AzureFunctions/01_SingleAgent/01_SingleAgent.csproj b/dotnet/samples/AzureFunctions/01_SingleAgent/01_SingleAgent.csproj new file mode 100644 index 0000000000..bce1b96f7b --- /dev/null +++ b/dotnet/samples/AzureFunctions/01_SingleAgent/01_SingleAgent.csproj @@ -0,0 +1,42 @@ + + + net9.0 + v4 + Exe + enable + enable + + SingleAgent + SingleAgent + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dotnet/samples/AzureFunctions/01_SingleAgent/Program.cs b/dotnet/samples/AzureFunctions/01_SingleAgent/Program.cs new file mode 100644 index 0000000000..60b3103adc --- /dev/null +++ b/dotnet/samples/AzureFunctions/01_SingleAgent/Program.cs @@ -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(); diff --git a/dotnet/samples/AzureFunctions/01_SingleAgent/README.md b/dotnet/samples/AzureFunctions/01_SingleAgent/README.md new file mode 100644 index 0000000000..5727f037c2 --- /dev/null +++ b/dotnet/samples/AzureFunctions/01_SingleAgent/README.md @@ -0,0 +1,89 @@ +# Single Agent Sample + +This sample demonstrates how to use the Durable Agent Framework (DAFx) to create a simple Azure Functions app that hosts a single AI agent and provides direct HTTP API access for interactive conversations. + +## Key Concepts Demonstrated + +- Using the Microsoft Agent Framework to define a simple AI agent with a name and instructions. +- Registering agents with the Function app and running them using HTTP. +- Conversation management (via session IDs) for isolated interactions. + +## Environment Setup + +See the [README.md](../README.md) file in the parent directory for more information on how to configure the environment, including how to install and run common sample dependencies. + +## Running the Sample + +With the environment setup and function app running, you can test the sample by sending an HTTP request to the agent endpoint. + +You can use the `demo.http` file to send a message to the agent, or a command line tool like `curl` as shown below: + +Bash (Linux/macOS/WSL): + +```bash +curl -X POST http://localhost:7071/api/agents/Joker/run \ + -H "Content-Type: text/plain" \ + -d "Tell me a joke about a pirate." +``` + +PowerShell: + +```powershell +Invoke-RestMethod -Method Post ` + -Uri http://localhost:7071/api/agents/Joker/run ` + -ContentType text/plain ` + -Body "Tell me a joke about a pirate." +``` + +You can also send JSON requests: + +```bash +curl -X POST http://localhost:7071/api/agents/Joker/run \ + -H "Content-Type: application/json" \ + -H "Accept: application/json" \ + -d '{"message": "Tell me a joke about a pirate."}' +``` + +To continue a conversation, include the `thread_id` in the query string or JSON body: + +```bash +curl -X POST "http://localhost:7071/api/agents/Joker/run?thread_id=@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 + } + } +} +``` diff --git a/dotnet/samples/AzureFunctions/01_SingleAgent/demo.http b/dotnet/samples/AzureFunctions/01_SingleAgent/demo.http new file mode 100644 index 0000000000..3b741adf31 --- /dev/null +++ b/dotnet/samples/AzureFunctions/01_SingleAgent/demo.http @@ -0,0 +1,8 @@ +# Default endpoint address for local testing +@authority=http://localhost:7071 + +### Prompt the agent +POST {{authority}}/api/agents/Joker/run +Content-Type: text/plain + +Tell me a joke about a pirate. diff --git a/dotnet/samples/AzureFunctions/01_SingleAgent/host.json b/dotnet/samples/AzureFunctions/01_SingleAgent/host.json new file mode 100644 index 0000000000..9384a0a583 --- /dev/null +++ b/dotnet/samples/AzureFunctions/01_SingleAgent/host.json @@ -0,0 +1,20 @@ +{ + "version": "2.0", + "logging": { + "logLevel": { + "Microsoft.Agents.AI.DurableTask": "Information", + "Microsoft.Agents.AI.Hosting.AzureFunctions": "Information", + "DurableTask": "Information", + "Microsoft.DurableTask": "Information" + } + }, + "extensions": { + "durableTask": { + "hubName": "default", + "storageProvider": { + "type": "AzureManaged", + "connectionStringName": "DURABLE_TASK_SCHEDULER_CONNECTION_STRING" + } + } + } +} diff --git a/dotnet/samples/AzureFunctions/01_SingleAgent/local.settings.json b/dotnet/samples/AzureFunctions/01_SingleAgent/local.settings.json new file mode 100644 index 0000000000..3411463ac4 --- /dev/null +++ b/dotnet/samples/AzureFunctions/01_SingleAgent/local.settings.json @@ -0,0 +1,10 @@ +{ + "IsEncrypted": false, + "Values": { + "FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated", + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None", + "AZURE_OPENAI_ENDPOINT": "", + "AZURE_OPENAI_DEPLOYMENT": "" + } +} \ No newline at end of file diff --git a/dotnet/samples/AzureFunctions/02_AgentOrchestration_Chaining/02_AgentOrchestration_Chaining.csproj b/dotnet/samples/AzureFunctions/02_AgentOrchestration_Chaining/02_AgentOrchestration_Chaining.csproj new file mode 100644 index 0000000000..4ec460450a --- /dev/null +++ b/dotnet/samples/AzureFunctions/02_AgentOrchestration_Chaining/02_AgentOrchestration_Chaining.csproj @@ -0,0 +1,42 @@ + + + net9.0 + v4 + Exe + enable + enable + + AgentOrchestration_Chaining + AgentOrchestration_Chaining + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dotnet/samples/AzureFunctions/02_AgentOrchestration_Chaining/FunctionTriggers.cs b/dotnet/samples/AzureFunctions/02_AgentOrchestration_Chaining/FunctionTriggers.cs new file mode 100644 index 0000000000..a631e7715c --- /dev/null +++ b/dotnet/samples/AzureFunctions/02_AgentOrchestration_Chaining/FunctionTriggers.cs @@ -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 RunOrchestrationAsync([OrchestrationTrigger] TaskOrchestrationContext context) + { + DurableAIAgent writer = context.GetAgent("WriterAgent"); + AgentThread writerThread = writer.GetNewThread(); + + AgentRunResponse initial = await writer.RunAsync( + message: "Write a concise inspirational sentence about learning.", + thread: writerThread); + + AgentRunResponse refined = await writer.RunAsync( + 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 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 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() : null, + output = status.SerializedOutput is not null ? (object)status.ReadOutputAs() : 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}"; + } +} diff --git a/dotnet/samples/AzureFunctions/02_AgentOrchestration_Chaining/Program.cs b/dotnet/samples/AzureFunctions/02_AgentOrchestration_Chaining/Program.cs new file mode 100644 index 0000000000..41f643a763 --- /dev/null +++ b/dotnet/samples/AzureFunctions/02_AgentOrchestration_Chaining/Program.cs @@ -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(); diff --git a/dotnet/samples/AzureFunctions/02_AgentOrchestration_Chaining/README.md b/dotnet/samples/AzureFunctions/02_AgentOrchestration_Chaining/README.md new file mode 100644 index 0000000000..e98885eced --- /dev/null +++ b/dotnet/samples/AzureFunctions/02_AgentOrchestration_Chaining/README.md @@ -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" +} +``` diff --git a/dotnet/samples/AzureFunctions/02_AgentOrchestration_Chaining/demo.http b/dotnet/samples/AzureFunctions/02_AgentOrchestration_Chaining/demo.http new file mode 100644 index 0000000000..aa4dcc4a16 --- /dev/null +++ b/dotnet/samples/AzureFunctions/02_AgentOrchestration_Chaining/demo.http @@ -0,0 +1,3 @@ +### Start the single-agent orchestration +POST http://localhost:7071/api/singleagent/run + diff --git a/dotnet/samples/AzureFunctions/02_AgentOrchestration_Chaining/host.json b/dotnet/samples/AzureFunctions/02_AgentOrchestration_Chaining/host.json new file mode 100644 index 0000000000..9384a0a583 --- /dev/null +++ b/dotnet/samples/AzureFunctions/02_AgentOrchestration_Chaining/host.json @@ -0,0 +1,20 @@ +{ + "version": "2.0", + "logging": { + "logLevel": { + "Microsoft.Agents.AI.DurableTask": "Information", + "Microsoft.Agents.AI.Hosting.AzureFunctions": "Information", + "DurableTask": "Information", + "Microsoft.DurableTask": "Information" + } + }, + "extensions": { + "durableTask": { + "hubName": "default", + "storageProvider": { + "type": "AzureManaged", + "connectionStringName": "DURABLE_TASK_SCHEDULER_CONNECTION_STRING" + } + } + } +} diff --git a/dotnet/samples/AzureFunctions/02_AgentOrchestration_Chaining/local.settings.json b/dotnet/samples/AzureFunctions/02_AgentOrchestration_Chaining/local.settings.json new file mode 100644 index 0000000000..54dfbb5664 --- /dev/null +++ b/dotnet/samples/AzureFunctions/02_AgentOrchestration_Chaining/local.settings.json @@ -0,0 +1,10 @@ +{ + "IsEncrypted": false, + "Values": { + "FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated", + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None", + "AZURE_OPENAI_ENDPOINT": "", + "AZURE_OPENAI_DEPLOYMENT": "" + } +} diff --git a/dotnet/samples/AzureFunctions/03_AgentOrchestration_Concurrency/03_AgentOrchestration_Concurrency.csproj b/dotnet/samples/AzureFunctions/03_AgentOrchestration_Concurrency/03_AgentOrchestration_Concurrency.csproj new file mode 100644 index 0000000000..8698b0a7b8 --- /dev/null +++ b/dotnet/samples/AzureFunctions/03_AgentOrchestration_Concurrency/03_AgentOrchestration_Concurrency.csproj @@ -0,0 +1,42 @@ + + + net9.0 + v4 + Exe + enable + enable + + AgentOrchestration_Concurrency + AgentOrchestration_Concurrency + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dotnet/samples/AzureFunctions/03_AgentOrchestration_Concurrency/FunctionTriggers.cs b/dotnet/samples/AzureFunctions/03_AgentOrchestration_Concurrency/FunctionTriggers.cs new file mode 100644 index 0000000000..2d15dd585c --- /dev/null +++ b/dotnet/samples/AzureFunctions/03_AgentOrchestration_Concurrency/FunctionTriggers.cs @@ -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 RunOrchestrationAsync([OrchestrationTrigger] TaskOrchestrationContext context) + { + // Get the prompt from the orchestration input + string prompt = context.GetInput() ?? 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> physicistTask = physicist.RunAsync(prompt); + + Task> chemistTask = chemist.RunAsync(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 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 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() : null, + output = status.SerializedOutput is not null ? (object)status.ReadOutputAs() : 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}"; + } +} diff --git a/dotnet/samples/AzureFunctions/03_AgentOrchestration_Concurrency/Program.cs b/dotnet/samples/AzureFunctions/03_AgentOrchestration_Concurrency/Program.cs new file mode 100644 index 0000000000..d4d5750df7 --- /dev/null +++ b/dotnet/samples/AzureFunctions/03_AgentOrchestration_Concurrency/Program.cs @@ -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(); diff --git a/dotnet/samples/AzureFunctions/03_AgentOrchestration_Concurrency/README.md b/dotnet/samples/AzureFunctions/03_AgentOrchestration_Concurrency/README.md new file mode 100644 index 0000000000..974aa1f2d2 --- /dev/null +++ b/dotnet/samples/AzureFunctions/03_AgentOrchestration_Concurrency/README.md @@ -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" +} +``` diff --git a/dotnet/samples/AzureFunctions/03_AgentOrchestration_Concurrency/demo.http b/dotnet/samples/AzureFunctions/03_AgentOrchestration_Concurrency/demo.http new file mode 100644 index 0000000000..8004e27e8e --- /dev/null +++ b/dotnet/samples/AzureFunctions/03_AgentOrchestration_Concurrency/demo.http @@ -0,0 +1,5 @@ +### Start the multi-agent concurrent orchestration +POST http://localhost:7071/api/multiagent/run +Content-Type: text/plain + +What is temperature? diff --git a/dotnet/samples/AzureFunctions/03_AgentOrchestration_Concurrency/host.json b/dotnet/samples/AzureFunctions/03_AgentOrchestration_Concurrency/host.json new file mode 100644 index 0000000000..9384a0a583 --- /dev/null +++ b/dotnet/samples/AzureFunctions/03_AgentOrchestration_Concurrency/host.json @@ -0,0 +1,20 @@ +{ + "version": "2.0", + "logging": { + "logLevel": { + "Microsoft.Agents.AI.DurableTask": "Information", + "Microsoft.Agents.AI.Hosting.AzureFunctions": "Information", + "DurableTask": "Information", + "Microsoft.DurableTask": "Information" + } + }, + "extensions": { + "durableTask": { + "hubName": "default", + "storageProvider": { + "type": "AzureManaged", + "connectionStringName": "DURABLE_TASK_SCHEDULER_CONNECTION_STRING" + } + } + } +} diff --git a/dotnet/samples/AzureFunctions/03_AgentOrchestration_Concurrency/local.settings.json b/dotnet/samples/AzureFunctions/03_AgentOrchestration_Concurrency/local.settings.json new file mode 100644 index 0000000000..54dfbb5664 --- /dev/null +++ b/dotnet/samples/AzureFunctions/03_AgentOrchestration_Concurrency/local.settings.json @@ -0,0 +1,10 @@ +{ + "IsEncrypted": false, + "Values": { + "FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated", + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None", + "AZURE_OPENAI_ENDPOINT": "", + "AZURE_OPENAI_DEPLOYMENT": "" + } +} diff --git a/dotnet/samples/AzureFunctions/04_AgentOrchestration_Conditionals/04_AgentOrchestration_Conditionals.csproj b/dotnet/samples/AzureFunctions/04_AgentOrchestration_Conditionals/04_AgentOrchestration_Conditionals.csproj new file mode 100644 index 0000000000..1971fb164a --- /dev/null +++ b/dotnet/samples/AzureFunctions/04_AgentOrchestration_Conditionals/04_AgentOrchestration_Conditionals.csproj @@ -0,0 +1,42 @@ + + + net9.0 + v4 + Exe + enable + enable + + AgentOrchestration_Conditionals + AgentOrchestration_Conditionals + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dotnet/samples/AzureFunctions/04_AgentOrchestration_Conditionals/FunctionTriggers.cs b/dotnet/samples/AzureFunctions/04_AgentOrchestration_Conditionals/FunctionTriggers.cs new file mode 100644 index 0000000000..14a91185f8 --- /dev/null +++ b/dotnet/samples/AzureFunctions/04_AgentOrchestration_Conditionals/FunctionTriggers.cs @@ -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 RunOrchestrationAsync([OrchestrationTrigger] TaskOrchestrationContext context) + { + // Get the email from the orchestration input + Email email = context.GetInput() ?? 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 spamDetectionResponse = await spamDetectionAgent.RunAsync( + 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(nameof(HandleSpamEmail), result.Reason); + } + + // Generate and send response for legitimate email + DurableAIAgent emailAssistantAgent = context.GetAgent("EmailAssistantAgent"); + AgentThread emailThread = emailAssistantAgent.GetNewThread(); + + AgentRunResponse emailAssistantResponse = await emailAssistantAgent.RunAsync( + 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(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 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(); + 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 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() : null, + output = status.SerializedOutput is not null ? (object)status.ReadOutputAs() : 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}"; + } +} diff --git a/dotnet/samples/AzureFunctions/04_AgentOrchestration_Conditionals/Models.cs b/dotnet/samples/AzureFunctions/04_AgentOrchestration_Conditionals/Models.cs new file mode 100644 index 0000000000..a39695d7d0 --- /dev/null +++ b/dotnet/samples/AzureFunctions/04_AgentOrchestration_Conditionals/Models.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace AgentOrchestration_Conditionals; + +/// +/// Represents an email input for spam detection and response generation. +/// +public sealed class Email +{ + [JsonPropertyName("email_id")] + public string EmailId { get; set; } = string.Empty; + + [JsonPropertyName("email_content")] + public string EmailContent { get; set; } = string.Empty; +} + +/// +/// Represents the result of spam detection analysis. +/// +public sealed class DetectionResult +{ + [JsonPropertyName("is_spam")] + public bool IsSpam { get; set; } + + [JsonPropertyName("reason")] + public string Reason { get; set; } = string.Empty; +} + +/// +/// Represents a generated email response. +/// +public sealed class EmailResponse +{ + [JsonPropertyName("response")] + public string Response { get; set; } = string.Empty; +} diff --git a/dotnet/samples/AzureFunctions/04_AgentOrchestration_Conditionals/Program.cs b/dotnet/samples/AzureFunctions/04_AgentOrchestration_Conditionals/Program.cs new file mode 100644 index 0000000000..e63d1a9667 --- /dev/null +++ b/dotnet/samples/AzureFunctions/04_AgentOrchestration_Conditionals/Program.cs @@ -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(); diff --git a/dotnet/samples/AzureFunctions/04_AgentOrchestration_Conditionals/README.md b/dotnet/samples/AzureFunctions/04_AgentOrchestration_Conditionals/README.md new file mode 100644 index 0000000000..97202b18a8 --- /dev/null +++ b/dotnet/samples/AzureFunctions/04_AgentOrchestration_Conditionals/README.md @@ -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" +} +``` diff --git a/dotnet/samples/AzureFunctions/04_AgentOrchestration_Conditionals/demo.http b/dotnet/samples/AzureFunctions/04_AgentOrchestration_Conditionals/demo.http new file mode 100644 index 0000000000..1120a7a181 --- /dev/null +++ b/dotnet/samples/AzureFunctions/04_AgentOrchestration_Conditionals/demo.http @@ -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!" +} diff --git a/dotnet/samples/AzureFunctions/04_AgentOrchestration_Conditionals/host.json b/dotnet/samples/AzureFunctions/04_AgentOrchestration_Conditionals/host.json new file mode 100644 index 0000000000..9384a0a583 --- /dev/null +++ b/dotnet/samples/AzureFunctions/04_AgentOrchestration_Conditionals/host.json @@ -0,0 +1,20 @@ +{ + "version": "2.0", + "logging": { + "logLevel": { + "Microsoft.Agents.AI.DurableTask": "Information", + "Microsoft.Agents.AI.Hosting.AzureFunctions": "Information", + "DurableTask": "Information", + "Microsoft.DurableTask": "Information" + } + }, + "extensions": { + "durableTask": { + "hubName": "default", + "storageProvider": { + "type": "AzureManaged", + "connectionStringName": "DURABLE_TASK_SCHEDULER_CONNECTION_STRING" + } + } + } +} diff --git a/dotnet/samples/AzureFunctions/04_AgentOrchestration_Conditionals/local.settings.json b/dotnet/samples/AzureFunctions/04_AgentOrchestration_Conditionals/local.settings.json new file mode 100644 index 0000000000..54dfbb5664 --- /dev/null +++ b/dotnet/samples/AzureFunctions/04_AgentOrchestration_Conditionals/local.settings.json @@ -0,0 +1,10 @@ +{ + "IsEncrypted": false, + "Values": { + "FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated", + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None", + "AZURE_OPENAI_ENDPOINT": "", + "AZURE_OPENAI_DEPLOYMENT": "" + } +} diff --git a/dotnet/samples/AzureFunctions/05_AgentOrchestration_HITL/05_AgentOrchestration_HITL.csproj b/dotnet/samples/AzureFunctions/05_AgentOrchestration_HITL/05_AgentOrchestration_HITL.csproj new file mode 100644 index 0000000000..b7d211605f --- /dev/null +++ b/dotnet/samples/AzureFunctions/05_AgentOrchestration_HITL/05_AgentOrchestration_HITL.csproj @@ -0,0 +1,43 @@ + + + net9.0 + v4 + Exe + enable + enable + + AgentOrchestration_HITL + AgentOrchestration_HITL + $(NoWarn);DURABLE0001;DURABLE0002;DURABLE0003;DURABLE0004;DURABLE0005;DURABLE0006 + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dotnet/samples/AzureFunctions/05_AgentOrchestration_HITL/FunctionTriggers.cs b/dotnet/samples/AzureFunctions/05_AgentOrchestration_HITL/FunctionTriggers.cs new file mode 100644 index 0000000000..001a52c105 --- /dev/null +++ b/dotnet/samples/AzureFunctions/05_AgentOrchestration_HITL/FunctionTriggers.cs @@ -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 RunOrchestrationAsync( + [OrchestrationTrigger] TaskOrchestrationContext context) + { + // Get the input from the orchestration + ContentGenerationInput input = context.GetInput() + ?? 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 writerResponse = await writerAgent.RunAsync( + 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( + 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( + 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 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(); + 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 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(); + 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 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() : null, + input = status.SerializedInput is not null ? (object)status.ReadInputAs() : null, + output = status.SerializedOutput is not null ? (object)status.ReadOutputAs() : 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}"; + } +} diff --git a/dotnet/samples/AzureFunctions/05_AgentOrchestration_HITL/Models.cs b/dotnet/samples/AzureFunctions/05_AgentOrchestration_HITL/Models.cs new file mode 100644 index 0000000000..1eaf1407eb --- /dev/null +++ b/dotnet/samples/AzureFunctions/05_AgentOrchestration_HITL/Models.cs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace AgentOrchestration_HITL; + +/// +/// Represents the input for the Human-in-the-Loop content generation workflow. +/// +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; +} + +/// +/// Represents the content generated by the writer agent. +/// +public sealed class GeneratedContent +{ + [JsonPropertyName("title")] + public string Title { get; set; } = string.Empty; + + [JsonPropertyName("content")] + public string Content { get; set; } = string.Empty; +} + +/// +/// Represents the human approval response. +/// +public sealed class HumanApprovalResponse +{ + [JsonPropertyName("approved")] + public bool Approved { get; set; } + + [JsonPropertyName("feedback")] + public string Feedback { get; set; } = string.Empty; +} diff --git a/dotnet/samples/AzureFunctions/05_AgentOrchestration_HITL/Program.cs b/dotnet/samples/AzureFunctions/05_AgentOrchestration_HITL/Program.cs new file mode 100644 index 0000000000..457fc4936e --- /dev/null +++ b/dotnet/samples/AzureFunctions/05_AgentOrchestration_HITL/Program.cs @@ -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(); diff --git a/dotnet/samples/AzureFunctions/05_AgentOrchestration_HITL/README.md b/dotnet/samples/AzureFunctions/05_AgentOrchestration_HITL/README.md new file mode 100644 index 0000000000..b6aa2f037a --- /dev/null +++ b/dotnet/samples/AzureFunctions/05_AgentOrchestration_HITL/README.md @@ -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" +} +``` diff --git a/dotnet/samples/AzureFunctions/05_AgentOrchestration_HITL/demo.http b/dotnet/samples/AzureFunctions/05_AgentOrchestration_HITL/demo.http new file mode 100644 index 0000000000..2ab2dc428a --- /dev/null +++ b/dotnet/samples/AzureFunctions/05_AgentOrchestration_HITL/demo.http @@ -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." +} diff --git a/dotnet/samples/AzureFunctions/05_AgentOrchestration_HITL/host.json b/dotnet/samples/AzureFunctions/05_AgentOrchestration_HITL/host.json new file mode 100644 index 0000000000..9384a0a583 --- /dev/null +++ b/dotnet/samples/AzureFunctions/05_AgentOrchestration_HITL/host.json @@ -0,0 +1,20 @@ +{ + "version": "2.0", + "logging": { + "logLevel": { + "Microsoft.Agents.AI.DurableTask": "Information", + "Microsoft.Agents.AI.Hosting.AzureFunctions": "Information", + "DurableTask": "Information", + "Microsoft.DurableTask": "Information" + } + }, + "extensions": { + "durableTask": { + "hubName": "default", + "storageProvider": { + "type": "AzureManaged", + "connectionStringName": "DURABLE_TASK_SCHEDULER_CONNECTION_STRING" + } + } + } +} diff --git a/dotnet/samples/AzureFunctions/05_AgentOrchestration_HITL/local.settings.json b/dotnet/samples/AzureFunctions/05_AgentOrchestration_HITL/local.settings.json new file mode 100644 index 0000000000..54dfbb5664 --- /dev/null +++ b/dotnet/samples/AzureFunctions/05_AgentOrchestration_HITL/local.settings.json @@ -0,0 +1,10 @@ +{ + "IsEncrypted": false, + "Values": { + "FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated", + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None", + "AZURE_OPENAI_ENDPOINT": "", + "AZURE_OPENAI_DEPLOYMENT": "" + } +} diff --git a/dotnet/samples/AzureFunctions/06_LongRunningTools/06_LongRunningTools.csproj b/dotnet/samples/AzureFunctions/06_LongRunningTools/06_LongRunningTools.csproj new file mode 100644 index 0000000000..f6e6b7bbfc --- /dev/null +++ b/dotnet/samples/AzureFunctions/06_LongRunningTools/06_LongRunningTools.csproj @@ -0,0 +1,42 @@ + + + net9.0 + v4 + Exe + enable + enable + + LongRunningTools + LongRunningTools + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dotnet/samples/AzureFunctions/06_LongRunningTools/FunctionTriggers.cs b/dotnet/samples/AzureFunctions/06_LongRunningTools/FunctionTriggers.cs new file mode 100644 index 0000000000..b5f81276b8 --- /dev/null +++ b/dotnet/samples/AzureFunctions/06_LongRunningTools/FunctionTriggers.cs @@ -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 RunOrchestrationAsync( + [OrchestrationTrigger] TaskOrchestrationContext context) + { + // Get the input from the orchestration + ContentGenerationInput input = context.GetInput() + ?? 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 writerResponse = await writerAgent.RunAsync( + 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( + 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( + 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); + } +} diff --git a/dotnet/samples/AzureFunctions/06_LongRunningTools/Models.cs b/dotnet/samples/AzureFunctions/06_LongRunningTools/Models.cs new file mode 100644 index 0000000000..771343694d --- /dev/null +++ b/dotnet/samples/AzureFunctions/06_LongRunningTools/Models.cs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace LongRunningTools; + +/// +/// Represents the input for the content generation workflow. +/// +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; +} + +/// +/// Represents the content generated by the writer agent. +/// +public sealed class GeneratedContent +{ + [JsonPropertyName("title")] + public string Title { get; set; } = string.Empty; + + [JsonPropertyName("content")] + public string Content { get; set; } = string.Empty; +} + +/// +/// Represents the human approval response. +/// +public sealed class HumanApprovalResponse +{ + [JsonPropertyName("approved")] + public bool Approved { get; set; } + + [JsonPropertyName("feedback")] + public string Feedback { get; set; } = string.Empty; +} diff --git a/dotnet/samples/AzureFunctions/06_LongRunningTools/Program.cs b/dotnet/samples/AzureFunctions/06_LongRunningTools/Program.cs new file mode 100644 index 0000000000..657a80d21f --- /dev/null +++ b/dotnet/samples/AzureFunctions/06_LongRunningTools/Program.cs @@ -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>()); + + 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(); diff --git a/dotnet/samples/AzureFunctions/06_LongRunningTools/README.md b/dotnet/samples/AzureFunctions/06_LongRunningTools/README.md new file mode 100644 index 0000000000..9c538298e8 --- /dev/null +++ b/dotnet/samples/AzureFunctions/06_LongRunningTools/README.md @@ -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 content generation workflow for the topic "The Future of Artificial Intelligence" has been successfully started, and the instance ID is **6a04276e8d824d8d941e1dc4142cc254**. If you need any further assistance or updates on the workflow, feel free to ask! +``` + +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. +``` diff --git a/dotnet/samples/AzureFunctions/06_LongRunningTools/Tools.cs b/dotnet/samples/AzureFunctions/06_LongRunningTools/Tools.cs new file mode 100644 index 0000000000..c2602e659e --- /dev/null +++ b/dotnet/samples/AzureFunctions/06_LongRunningTools/Tools.cs @@ -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; + +/// +/// Tools that demonstrate starting orchestrations from agent tool calls. +/// +internal sealed class Tools(ILogger logger) +{ + private readonly ILogger _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 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); + } +} diff --git a/dotnet/samples/AzureFunctions/06_LongRunningTools/demo.http b/dotnet/samples/AzureFunctions/06_LongRunningTools/demo.http new file mode 100644 index 0000000000..c0f13f1992 --- /dev/null +++ b/dotnet/samples/AzureFunctions/06_LongRunningTools/demo.http @@ -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 = + +### 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 diff --git a/dotnet/samples/AzureFunctions/06_LongRunningTools/host.json b/dotnet/samples/AzureFunctions/06_LongRunningTools/host.json new file mode 100644 index 0000000000..9384a0a583 --- /dev/null +++ b/dotnet/samples/AzureFunctions/06_LongRunningTools/host.json @@ -0,0 +1,20 @@ +{ + "version": "2.0", + "logging": { + "logLevel": { + "Microsoft.Agents.AI.DurableTask": "Information", + "Microsoft.Agents.AI.Hosting.AzureFunctions": "Information", + "DurableTask": "Information", + "Microsoft.DurableTask": "Information" + } + }, + "extensions": { + "durableTask": { + "hubName": "default", + "storageProvider": { + "type": "AzureManaged", + "connectionStringName": "DURABLE_TASK_SCHEDULER_CONNECTION_STRING" + } + } + } +} diff --git a/dotnet/samples/AzureFunctions/06_LongRunningTools/local.settings.json b/dotnet/samples/AzureFunctions/06_LongRunningTools/local.settings.json new file mode 100644 index 0000000000..54dfbb5664 --- /dev/null +++ b/dotnet/samples/AzureFunctions/06_LongRunningTools/local.settings.json @@ -0,0 +1,10 @@ +{ + "IsEncrypted": false, + "Values": { + "FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated", + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None", + "AZURE_OPENAI_ENDPOINT": "", + "AZURE_OPENAI_DEPLOYMENT": "" + } +} diff --git a/dotnet/samples/AzureFunctions/07_AgentAsMcpTool/07_AgentAsMcpTool.csproj b/dotnet/samples/AzureFunctions/07_AgentAsMcpTool/07_AgentAsMcpTool.csproj new file mode 100644 index 0000000000..8fa1f5f2e7 --- /dev/null +++ b/dotnet/samples/AzureFunctions/07_AgentAsMcpTool/07_AgentAsMcpTool.csproj @@ -0,0 +1,42 @@ + + + net9.0 + v4 + Exe + enable + enable + + AgentAsMcpTool + AgentAsMcpTool + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dotnet/samples/AzureFunctions/07_AgentAsMcpTool/Program.cs b/dotnet/samples/AzureFunctions/07_AgentAsMcpTool/Program.cs new file mode 100644 index 0000000000..1c55f41f16 --- /dev/null +++ b/dotnet/samples/AzureFunctions/07_AgentAsMcpTool/Program.cs @@ -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(); diff --git a/dotnet/samples/AzureFunctions/07_AgentAsMcpTool/README.md b/dotnet/samples/AzureFunctions/07_AgentAsMcpTool/README.md new file mode 100644 index 0000000000..a8efad04de --- /dev/null +++ b/dotnet/samples/AzureFunctions/07_AgentAsMcpTool/README.md @@ -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. diff --git a/dotnet/samples/AzureFunctions/07_AgentAsMcpTool/host.json b/dotnet/samples/AzureFunctions/07_AgentAsMcpTool/host.json new file mode 100644 index 0000000000..aa36d82912 --- /dev/null +++ b/dotnet/samples/AzureFunctions/07_AgentAsMcpTool/host.json @@ -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" + } + } + } +} diff --git a/dotnet/samples/AzureFunctions/07_AgentAsMcpTool/local.settings.json b/dotnet/samples/AzureFunctions/07_AgentAsMcpTool/local.settings.json new file mode 100644 index 0000000000..54dfbb5664 --- /dev/null +++ b/dotnet/samples/AzureFunctions/07_AgentAsMcpTool/local.settings.json @@ -0,0 +1,10 @@ +{ + "IsEncrypted": false, + "Values": { + "FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated", + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None", + "AZURE_OPENAI_ENDPOINT": "", + "AZURE_OPENAI_DEPLOYMENT": "" + } +} diff --git a/dotnet/samples/AzureFunctions/README.md b/dotnet/samples/AzureFunctions/README.md new file mode 100644 index 0000000000..83d26a53b7 --- /dev/null +++ b/dotnet/samples/AzureFunctions/README.md @@ -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//resourceGroups//providers/Microsoft.CognitiveServices/accounts/ +``` + +PowerShell: + +```powershell +az role assignment create ` + --assignee "yourname@contoso.com" ` + --role "Cognitive Services OpenAI User" ` + --scope /subscriptions//resourceGroups//providers/Microsoft.CognitiveServices/accounts/ +``` + +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. diff --git a/dotnet/samples/GettingStarted/DevUI/DevUI_Step01_BasicUsage/Program.cs b/dotnet/samples/GettingStarted/DevUI/DevUI_Step01_BasicUsage/Program.cs index 0415f0e0e0..7fded8c55b 100644 --- a/dotnet/samples/GettingStarted/DevUI/DevUI_Step01_BasicUsage/Program.cs +++ b/dotnet/samples/GettingStarted/DevUI/DevUI_Step01_BasicUsage/Program.cs @@ -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; /// /// 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."); diff --git a/dotnet/samples/HostedAgents/AgentWithHostedMCP/AgentWithHostedMCP.csproj b/dotnet/samples/HostedAgents/AgentWithHostedMCP/AgentWithHostedMCP.csproj new file mode 100644 index 0000000000..2343fe016f --- /dev/null +++ b/dotnet/samples/HostedAgents/AgentWithHostedMCP/AgentWithHostedMCP.csproj @@ -0,0 +1,70 @@ + + + + Exe + net9.0 + + enable + enable + + + false + $(NoWarn);MEAI001;OPENAI001 + + + + + + + + + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/dotnet/samples/HostedAgents/AgentWithHostedMCP/Dockerfile b/dotnet/samples/HostedAgents/AgentWithHostedMCP/Dockerfile new file mode 100644 index 0000000000..776f81041e --- /dev/null +++ b/dotnet/samples/HostedAgents/AgentWithHostedMCP/Dockerfile @@ -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"] diff --git a/dotnet/samples/HostedAgents/AgentWithHostedMCP/Program.cs b/dotnet/samples/HostedAgents/AgentWithHostedMCP/Program.cs new file mode 100644 index 0000000000..9cbea8b73a --- /dev/null +++ b/dotnet/samples/HostedAgents/AgentWithHostedMCP/Program.cs @@ -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(); diff --git a/dotnet/samples/HostedAgents/AgentWithHostedMCP/README.md b/dotnet/samples/HostedAgents/AgentWithHostedMCP/README.md new file mode 100644 index 0000000000..a5648d7ac9 --- /dev/null +++ b/dotnet/samples/HostedAgents/AgentWithHostedMCP/README.md @@ -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. diff --git a/dotnet/samples/HostedAgents/AgentWithHostedMCP/agent.yaml b/dotnet/samples/HostedAgents/AgentWithHostedMCP/agent.yaml new file mode 100644 index 0000000000..6444f1aad0 --- /dev/null +++ b/dotnet/samples/HostedAgents/AgentWithHostedMCP/agent.yaml @@ -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 diff --git a/dotnet/samples/HostedAgents/AgentWithHostedMCP/run-requests.http b/dotnet/samples/HostedAgents/AgentWithHostedMCP/run-requests.http new file mode 100644 index 0000000000..cc26f43b90 --- /dev/null +++ b/dotnet/samples/HostedAgents/AgentWithHostedMCP/run-requests.http @@ -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?" + } + ] + } + ] +} diff --git a/dotnet/samples/HostedAgents/AgentWithTextSearchRag/agent.yaml b/dotnet/samples/HostedAgents/AgentWithTextSearchRag/agent.yaml index 35a8582c1a..1366071b17 100644 --- a/dotnet/samples/HostedAgents/AgentWithTextSearchRag/agent.yaml +++ b/dotnet/samples/HostedAgents/AgentWithTextSearchRag/agent.yaml @@ -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 diff --git a/dotnet/samples/HostedAgents/AgentsInWorkflows/agent.yaml b/dotnet/samples/HostedAgents/AgentsInWorkflows/agent.yaml index bf706f9925..900f05d513 100644 --- a/dotnet/samples/HostedAgents/AgentsInWorkflows/agent.yaml +++ b/dotnet/samples/HostedAgents/AgentsInWorkflows/agent.yaml @@ -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 diff --git a/dotnet/samples/Purview/AgentWithPurview/AgentWithPurview.csproj b/dotnet/samples/Purview/AgentWithPurview/AgentWithPurview.csproj new file mode 100644 index 0000000000..8dc509efed --- /dev/null +++ b/dotnet/samples/Purview/AgentWithPurview/AgentWithPurview.csproj @@ -0,0 +1,21 @@ + + + + Exe + net9.0 + + enable + enable + + + + + + + + + + + + + diff --git a/dotnet/samples/Purview/AgentWithPurview/Program.cs b/dotnet/samples/Purview/AgentWithPurview/Program.cs new file mode 100644 index 0000000000..842917b427 --- /dev/null +++ b/dotnet/samples/Purview/AgentWithPurview/Program.cs @@ -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)); +} diff --git a/dotnet/samples/README.md b/dotnet/samples/README.md index d6f2f5c39c..db0202794a 100644 --- a/dotnet/samples/README.md +++ b/dotnet/samples/README.md @@ -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 diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatMessageStore.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatMessageStore.cs index 17d1cdce93..79d303207c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatMessageStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatMessageStore.cs @@ -97,8 +97,9 @@ public sealed class InMemoryChatMessageStore : ChatMessageStore, IList diff --git a/dotnet/src/Microsoft.Agents.AI.DevUI/Entities/EntitiesJsonContext.cs b/dotnet/src/Microsoft.Agents.AI.DevUI/Entities/EntitiesJsonContext.cs index 3acc8d48d3..09b95769a9 100644 --- a/dotnet/src/Microsoft.Agents.AI.DevUI/Entities/EntitiesJsonContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.DevUI/Entities/EntitiesJsonContext.cs @@ -18,9 +18,13 @@ namespace Microsoft.Agents.AI.DevUI.Entities; [JsonSerializable(typeof(MetaResponse))] [JsonSerializable(typeof(EnvVarRequirement))] [JsonSerializable(typeof(List))] -[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(List>))] +[JsonSerializable(typeof(List>))] [JsonSerializable(typeof(Dictionary))] -[JsonSerializable(typeof(Dictionary))] +[JsonSerializable(typeof(Dictionary>))] +[JsonSerializable(typeof(Dictionary))] [JsonSerializable(typeof(JsonElement))] +[JsonSerializable(typeof(string))] +[JsonSerializable(typeof(int))] [ExcludeFromCodeCoverage] internal sealed partial class EntitiesJsonContext : JsonSerializerContext; diff --git a/dotnet/src/Microsoft.Agents.AI.DevUI/Entities/EntityInfo.cs b/dotnet/src/Microsoft.Agents.AI.DevUI/Entities/EntityInfo.cs index 8b5e4e5492..7b711b36c2 100644 --- a/dotnet/src/Microsoft.Agents.AI.DevUI/Entities/EntityInfo.cs +++ b/dotnet/src/Microsoft.Agents.AI.DevUI/Entities/EntityInfo.cs @@ -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? Tools = null, + List Tools, [property: JsonPropertyName("metadata")] - Dictionary? Metadata = null + Dictionary 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? ContextProviders { get; init; } + + [JsonPropertyName("middleware")] + public List? Middleware { get; init; } + + [JsonPropertyName("module_path")] + public string? ModulePath { get; init; } + // Workflow-specific fields [JsonPropertyName("required_env_vars")] public List? RequiredEnvVars { get; init; } diff --git a/dotnet/src/Microsoft.Agents.AI.DevUI/Entities/WorkflowSerializationExtensions.cs b/dotnet/src/Microsoft.Agents.AI.DevUI/Entities/WorkflowSerializationExtensions.cs index 81ce6182d1..44fc8b1eb4 100644 --- a/dotnet/src/Microsoft.Agents.AI.DevUI/Entities/WorkflowSerializationExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.DevUI/Entities/WorkflowSerializationExtensions.cs @@ -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. /// - public static Dictionary ToDevUIDict(this Workflow workflow) + /// The workflow to convert. + /// A dictionary with string keys and JsonElement values containing the workflow data. + public static Dictionary ToDevUIDict(this Workflow workflow) { - var result = new Dictionary + var result = new Dictionary { - ["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 /// /// Converts workflow executors to a dictionary format compatible with Python /// - private static Dictionary ConvertExecutorsToDict(Workflow workflow) + private static Dictionary> ConvertExecutorsToDict(Workflow workflow) { - var executors = new Dictionary(); + var executors = new Dictionary>(); // 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 + executors[executorId] = new Dictionary { ["id"] = executorId, ["type"] = "Executor" @@ -86,9 +94,9 @@ internal static class WorkflowSerializationExtensions /// /// Converts workflow edges to edge_groups format expected by the UI /// - private static List ConvertEdgesToEdgeGroups(Workflow workflow) + private static List> ConvertEdgesToEdgeGroups(Workflow workflow) { - var edgeGroups = new List(); + var edgeGroups = new List>(); 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(); + var edges = new List>(); foreach (var source in directEdge.Connection.SourceIds) { foreach (var sink in directEdge.Connection.SinkIds) { - var edge = new Dictionary + var edge = new Dictionary { ["source_id"] = source, ["target_id"] = sink @@ -123,23 +131,25 @@ internal static class WorkflowSerializationExtensions } } - edgeGroups.Add(new Dictionary + var edgeGroup = new Dictionary { - ["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(); + var edges = new List>(); foreach (var source in fanOutEdge.Connection.SourceIds) { foreach (var sink in fanOutEdge.Connection.SinkIds) { - edges.Add(new Dictionary + edges.Add(new Dictionary { ["source_id"] = source, ["target_id"] = sink @@ -147,16 +157,16 @@ internal static class WorkflowSerializationExtensions } } - var fanOutGroup = new Dictionary + var fanOutGroup = new Dictionary { - ["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(); + var edges = new List>(); foreach (var source in fanInEdge.Connection.SourceIds) { foreach (var sink in fanInEdge.Connection.SinkIds) { - edges.Add(new Dictionary + edges.Add(new Dictionary { ["source_id"] = source, ["target_id"] = sink @@ -178,16 +188,20 @@ internal static class WorkflowSerializationExtensions } } - edgeGroups.Add(new Dictionary + var edgeGroup = new Dictionary { - ["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 value, JsonTypeInfo typeInfo) => JsonSerializer.SerializeToElement(value, typeInfo); } diff --git a/dotnet/src/Microsoft.Agents.AI.DevUI/EntitiesApiExtensions.cs b/dotnet/src/Microsoft.Agents.AI.DevUI/EntitiesApiExtensions.cs index eb41fe90b8..29b7dc588a 100644 --- a/dotnet/src/Microsoft.Agents.AI.DevUI/EntitiesApiExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.DevUI/EntitiesApiExtensions.cs @@ -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(); + var entities = new Dictionary(); // 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 tools = []; + var metadata = new Dictionary(); + + // Try to get ChatOptions from the agent which may contain tools + if (agent.GetService() 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(); + if (chatClient != null) + { + // Get chat client type + chatClientType = chatClient.GetType().Name; + + // Get model ID from ChatClientMetadata + if (chatClient.GetService() 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() 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 + var defaultInputSchema = new Dictionary { ["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 }; diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/AIAgentExtensions.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/AIAgentExtensions.cs new file mode 100644 index 0000000000..4f5619ac76 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/AIAgentExtensions.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.Agents.AI.DurableTask; + +/// +/// Extension methods for the class. +/// +public static class AIAgentExtensions +{ + /// + /// Converts an AIAgent to a durable agent proxy. + /// + /// The agent to convert. + /// The service provider. + /// The durable agent proxy. + /// + /// Thrown when the agent is a DurableAIAgent instance or if the agent has no name. + /// + /// + /// Thrown if does not contain an . + /// + 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(); + return new DurableAIAgentProxy(agentName, agentClient); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/AgentEntity.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/AgentEntity.cs new file mode 100644 index 0000000000..166799a124 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/AgentEntity.cs @@ -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 +{ + private readonly IServiceProvider _services = services; + private readonly DurableTaskClient _client = services.GetRequiredService(); + private readonly ILoggerFactory _loggerFactory = services.GetRequiredService(); + private readonly IAgentResponseHandler? _messageHandler = services.GetService(); + private readonly CancellationToken _cancellationToken = cancellationToken != default + ? cancellationToken + : services.GetService()?.ApplicationStopping ?? CancellationToken.None; + + public async Task RunAgentAsync(RunRequest request) + { + AgentSessionId sessionId = this.Context.Id; + IReadOnlyDictionary> agents = + this._services.GetRequiredService>>(); + if (!agents.TryGetValue(sessionId.Name, out Func? 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(), + services: this._services); + DurableAgentContext.SetCurrent(agentContext); + + try + { + // Start the agent response stream + IAsyncEnumerable 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 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 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(); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/AgentRunHandle.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/AgentRunHandle.cs new file mode 100644 index 0000000000..e4fe08dbf2 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/AgentRunHandle.cs @@ -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; + +/// +/// Represents a handle for a running agent request that can be used to retrieve the response. +/// +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; + } + + /// + /// Gets the correlation ID for this request. + /// + public string CorrelationId { get; } + + /// + /// Gets the session ID for this request. + /// + public AgentSessionId SessionId { get; } + + /// + /// 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. + /// + /// The cancellation token. + /// The agent response corresponding to this request. + /// Thrown when the response is not found after polling. + public async Task 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? entityResponse = await this._client.Entities.GetEntityAsync( + 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() + .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)); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/AgentSessionId.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/AgentSessionId.cs new file mode 100644 index 0000000000..f183ec84dc --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/AgentSessionId.cs @@ -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; + +/// +/// Represents an agent session ID, which is used to identify a long-running agent session. +/// +[JsonConverter(typeof(AgentSessionIdJsonConverter))] +public readonly struct AgentSessionId : IEquatable +{ + private const string EntityNamePrefix = "dafx-"; + private readonly EntityInstanceId _entityId; + + /// + /// Initializes a new instance of the struct. + /// + /// The name of the agent that owns the session (case-insensitive). + /// The unique key of the agent session (case-sensitive). + public AgentSessionId(string name, string key) + { + this.Name = name; + this._entityId = new EntityInstanceId(ToEntityName(name), key); + } + + /// + /// Converts an agent name to its underlying entity name representation. + /// + /// The agent name. + /// The entity name used by Durable Task for this agent. + public static string ToEntityName(string name) => $"{EntityNamePrefix}{name}"; + + /// + /// Gets the name of the agent that owns the session. Names are case-insensitive. + /// + public string Name { get; } + + /// + /// Gets the unique key of the agent session. Keys are case-sensitive and are used to identify the session. + /// + public string Key => this._entityId.Key; + + internal EntityInstanceId ToEntityId() => this._entityId; + + /// + /// Creates a new with the specified name and a randomly generated key. + /// + /// The name of the agent that owns the session. + /// A new with the specified name and a random key. + public static AgentSessionId WithRandomKey(string name) => + new(name, Guid.NewGuid().ToString("N")); + + /// + /// Determines whether two instances are equal. + /// + /// The first to compare. + /// The second to compare. + /// true if the two instances are equal; otherwise, false. + public static bool operator ==(AgentSessionId left, AgentSessionId right) => + left._entityId == right._entityId; + + /// + /// Determines whether two instances are not equal. + /// + /// The first to compare. + /// The second to compare. + /// true if the two instances are not equal; otherwise, false. + public static bool operator !=(AgentSessionId left, AgentSessionId right) => + left._entityId != right._entityId; + + /// + /// Determines whether the specified is equal to the current . + /// + /// The to compare with the current . + /// true if the specified is equal to the current ; otherwise, false. + public bool Equals(AgentSessionId other) => this == other; + + /// + /// Determines whether the specified object is equal to the current . + /// + /// The object to compare with the current . + /// true if the specified object is equal to the current ; otherwise, false. + public override bool Equals(object? obj) => obj is AgentSessionId other && this == other; + + /// + /// Returns the hash code for this . + /// + /// A hash code for the current . + public override int GetHashCode() => this._entityId.GetHashCode(); + + /// + /// Returns a string representation of this in the form of @name@key. + /// + /// A string representation of the current . + public override string ToString() => this._entityId.ToString(); + + /// + /// Converts the string representation of an agent session ID to its equivalent. + /// The input string must be in the form of @name@key. + /// + /// A string containing an agent session ID to convert. + /// A equivalent to the agent session ID contained in . + /// Thrown when is not a valid agent session ID format. + 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); + } + + /// + /// Implicitly converts an to an . + /// This conversion is useful for entity API interoperability. + /// + /// The to convert. + /// The equivalent . + public static implicit operator EntityInstanceId(AgentSessionId agentSessionId) => agentSessionId.ToEntityId(); + + /// + /// Implicitly converts an to an . + /// + /// The to convert. + /// The equivalent . + [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); + } + + /// + /// Custom JSON converter for to ensure proper serialization and deserialization. + /// + public sealed class AgentSessionIdJsonConverter : JsonConverter + { + /// + 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); + } + + /// + public override void Write(Utf8JsonWriter writer, AgentSessionId value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.ToString()); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md b/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md new file mode 100644 index 0000000000..e908deac89 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md @@ -0,0 +1,5 @@ +# Release History + +## v1.0.0-preview.* (Unreleased) + +- Initial public release. diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DefaultDurableAgentClient.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DefaultDurableAgentClient.cs new file mode 100644 index 0000000000..2086a00ecb --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/DefaultDurableAgentClient.cs @@ -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(); + + public async Task 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); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAIAgent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAIAgent.cs new file mode 100644 index 0000000000..52907c5816 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAIAgent.cs @@ -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; + +/// +/// A durable AIAgent implementation that uses entity methods to interact with agent entities. +/// +public sealed class DurableAIAgent : AIAgent +{ + private readonly TaskOrchestrationContext _context; + private readonly string _agentName; + + /// + /// Initializes a new instance of the class. + /// + /// The orchestration context. + /// The name of the agent. + internal DurableAIAgent(TaskOrchestrationContext context, string agentName) + { + this._context = context; + this._agentName = agentName; + } + + /// + /// Creates a new agent thread for this agent using a random session ID. + /// + /// A new agent thread. + public override AgentThread GetNewThread() + { + AgentSessionId sessionId = this._context.NewAgentSessionId(this._agentName); + return new DurableAgentThread(sessionId); + } + + /// + /// Deserializes an agent thread from JSON. + /// + /// The serialized thread data. + /// Optional JSON serializer options. + /// The deserialized agent thread. + public override AgentThread DeserializeThread( + JsonElement serializedThread, + JsonSerializerOptions? jsonSerializerOptions = null) + { + return DurableAgentThread.Deserialize(serializedThread, jsonSerializerOptions); + } + + /// + /// Runs the agent with messages and returns the response. + /// + /// The messages to send to the agent. + /// The agent thread to use. + /// Optional run options. + /// The cancellation token. + /// The response from the agent. + public override async Task RunAsync( + IEnumerable 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? 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(durableThread.SessionId, nameof(AgentEntity.RunAgentAsync), request); + } + + /// + /// Runs the agent with messages and returns a simulated streaming response. + /// + /// + /// Streaming is not supported for durable agents, so this method just returns the full response + /// as a single update. + /// + /// The messages to send to the agent. + /// The agent thread to use. + /// Optional run options. + /// The cancellation token. + /// A streaming response enumerable. + public override async IAsyncEnumerable RunStreamingAsync( + IEnumerable 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; + } + } + + /// + /// Runs the agent with a message and returns the deserialized output as an instance of . + /// + /// The message to send to the agent. + /// The agent thread to use. + /// Optional JSON serializer options. + /// Optional run options. + /// The cancellation token. + /// The type of the output. + /// + /// Thrown when the provided already contains a response schema. + /// Thrown when the provided is not a . + /// + /// + /// Thrown when the agent response is empty or cannot be deserialized. + /// + /// The output from the agent. + public async Task> RunAsync( + string message, + AgentThread? thread = null, + JsonSerializerOptions? serializerOptions = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + { + return await this.RunAsync( + messages: [new ChatMessage(ChatRole.User, message) { CreatedAt = DateTimeOffset.UtcNow }], + thread, + serializerOptions, + options, + cancellationToken); + } + + /// + /// Runs the agent with messages and returns the deserialized output as an instance of . + /// + /// The messages to send to the agent. + /// The agent thread to use. + /// Optional JSON serializer options. + /// Optional run options. + /// The cancellation token. + /// The type of the output. + /// + /// Thrown when the provided already contains a response schema. + /// Thrown when the provided is not a . + /// + /// + /// Thrown when the agent response is empty or cannot be deserialized. + /// + /// The output from the agent. + [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> RunAsync( + IEnumerable 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.", + 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.", + paramName: nameof(options)); + } + + // Create the JSON schema for the response type + durableOptions.ResponseFormat = ChatResponseFormat.ForJsonSchema(); + + 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(response.Text, serializerOptions)) + ?? throw new InvalidOperationException($"Failed to deserialize agent response to type {typeof(T).Name}."); + + return new DurableAIAgentRunResponse(response, result); + } + + private sealed class DurableAIAgentRunResponse(AgentRunResponse response, T result) + : AgentRunResponse(response.AsChatResponse()) + { + public override T Result { get; } = result; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAIAgentProxy.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAIAgentProxy.cs new file mode 100644 index 0000000000..58f9598a7e --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAIAgentProxy.cs @@ -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 RunAsync( + IEnumerable 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? 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 RunStreamingAsync( + IEnumerable messages, + AgentThread? thread = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + { + throw new NotSupportedException("Streaming is not supported for durable agents."); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentContext.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentContext.cs new file mode 100644 index 0000000000..94a6c00424 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentContext.cs @@ -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; + +/// +/// A context for durable agents that provides access to orchestration capabilities. +/// This class provides thread-static access to the current agent context. +/// +public class DurableAgentContext +{ + private static readonly AsyncLocal 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; + } + + /// + /// Gets the current durable agent context instance. + /// + /// Thrown when no agent context is available. + public static DurableAgentContext Current => s_currentContext.Value ?? + throw new InvalidOperationException("No agent context found!"); + + /// + /// Gets the entity context for this agent. + /// + public TaskEntityContext EntityContext { get; } + + /// + /// Gets the durable task client for this agent. + /// + public DurableTaskClient Client { get; } + + /// + /// Gets the current agent thread. + /// + public DurableAgentThread CurrentThread { get; } + + /// + /// Sets the current durable agent context instance. + /// This is called internally by the agent entity during execution. + /// + /// The context instance to set. + 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; + } + + /// + /// Clears the current durable agent context instance. + /// This is called internally by the agent entity after execution. + /// + internal static void ClearCurrent() + { + s_currentContext.Value = null; + } + + /// + /// Schedules a new orchestration instance. + /// + /// + /// 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. + /// + /// The name of the orchestration to schedule. + /// The input to the orchestration. + /// The options for the orchestration. + /// The instance ID of the scheduled orchestration. + public string ScheduleNewOrchestration( + TaskName name, + object? input = null, + StartOrchestrationOptions? options = null) + { + return this.EntityContext.ScheduleNewOrchestration(name, input, options); + } + + /// + /// Gets the status of an orchestration instance. + /// + /// The instance ID of the orchestration to get the status of. + /// Whether to include detailed information about the orchestration. + /// The status of the orchestration. + public Task GetOrchestrationStatusAsync(string instanceId, bool includeDetails = false) + { + return this.Client.GetInstanceAsync(instanceId, includeDetails, this._cancellationToken); + } + + /// + /// Raises an event on an orchestration instance. + /// + /// The instance ID of the orchestration to raise the event on. + /// The name of the event to raise. + /// The data to send with the event. +#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); + } + + /// + /// Asks the for an object of the specified type, . + /// + /// The type of the object being requested. + /// An optional key to identify the service instance. + /// The service instance, or if the service is not found. + /// + /// Thrown when is not and the service provider does not support keyed services. + /// + public TService? GetService(object? serviceKey = null) + { + return this.GetService(typeof(TService), serviceKey) is TService service ? service : default; + } + + /// + /// Asks the for an object of the specified type, . + /// + /// The type of the object being requested. + /// An optional key to identify the service instance. + /// The service instance, or if the service is not found. + /// + /// Thrown when is not and the service provider does not support keyed services. + /// + 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); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentJsonUtilities.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentJsonUtilities.cs new file mode 100644 index 0000000000..e3864e9ad4 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentJsonUtilities.cs @@ -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; + +/// Provides JSON serialization utilities and source-generated contracts for Durable Agent types. +/// +/// +/// This mirrors the pattern used by other libraries (e.g. WorkflowsJsonUtilities) to enable Native AOT and trimming +/// friendly serialization without relying on runtime reflection. It establishes a singleton +/// instance that is preconfigured with: +/// +/// +/// baseline defaults. +/// for default null-value suppression. +/// to tolerate numbers encoded as strings. +/// Chained type info resolvers from shared agent abstractions to cover cross-package types (e.g. , ). +/// +/// +/// Keep the list of [JsonSerializable] types in sync with the Durable Agent data model anytime new state or request/response +/// containers are introduced that must round-trip via JSON. +/// +/// +internal static partial class DurableAgentJsonUtilities +{ + /// + /// Gets the singleton used for Durable Agent serialization. + /// + public static JsonSerializerOptions DefaultOptions { get; } = CreateDefaultOptions(); + + /// + /// Serializes a sequence of chat messages using the durable agent default options. + /// + /// The messages to serialize. + /// A representing the serialized messages. + public static JsonElement Serialize(this IEnumerable messages) => + JsonSerializer.SerializeToElement(messages, DefaultOptions.GetTypeInfo(typeof(IEnumerable))); + + /// + /// Deserializes chat messages from a using durable agent options. + /// + /// The JSON element containing the messages. + /// The deserialized list of chat messages. + public static List DeserializeMessages(this JsonElement element) => + (List?)element.Deserialize(DefaultOptions.GetTypeInfo(typeof(List))) ?? []; + + /// + /// Creates the configured instance for durable agents. + /// + /// The configured options. + [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; +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentRunOptions.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentRunOptions.cs new file mode 100644 index 0000000000..0f1984ad62 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentRunOptions.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.DurableTask; + +/// +/// Options for running a durable agent. +/// +public sealed class DurableAgentRunOptions : AgentRunOptions +{ + /// + /// Gets or sets whether to enable tool calls for this request. + /// + public bool EnableToolCalls { get; set; } = true; + + /// + /// Gets or sets the collection of tool names to enable. If not specified, all tools are enabled. + /// + public IList? EnableToolNames { get; set; } + + /// + /// Gets or sets the response format for the agent's response. + /// + public ChatResponseFormat? ResponseFormat { get; set; } + + /// + /// Gets or sets whether to fire and forget the agent run request. + /// + /// + /// If is true, 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. + /// + public bool IsFireAndForget { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentThread.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentThread.cs new file mode 100644 index 0000000000..32dea2cb18 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentThread.cs @@ -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; + +/// +/// An agent thread implementation for durable agents. +/// +[DebuggerDisplay("{SessionId}")] +public sealed class DurableAgentThread : AgentThread +{ + [JsonConstructor] + internal DurableAgentThread(AgentSessionId sessionId) + { + this.SessionId = sessionId; + } + + /// + /// Gets the agent session ID. + /// + [JsonInclude] + [JsonPropertyName("sessionId")] + internal AgentSessionId SessionId { get; } + + /// + public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null) + { + return JsonSerializer.SerializeToElement( + this, + DurableAgentJsonUtilities.DefaultOptions.GetTypeInfo(typeof(DurableAgentThread))); + } + + /// + /// Deserializes a DurableAgentThread from JSON. + /// + /// The serialized thread data. + /// Optional JSON serializer options. + /// The deserialized DurableAgentThread. + 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); + } + + /// + 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); + } + + /// + public override string ToString() + { + return this.SessionId.ToString(); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentsOptions.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentsOptions.cs new file mode 100644 index 0000000000..f2ac3f4c9a --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentsOptions.cs @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.DurableTask; + +/// +/// Builder for configuring durable agents. +/// +public sealed class DurableAgentsOptions +{ + // Agent names are case-insensitive + private readonly Dictionary> _agentFactories = new(StringComparer.OrdinalIgnoreCase); + + internal DurableAgentsOptions() + { + } + + /// + /// Adds an AI agent factory to the options. + /// + /// The name of the agent. + /// The factory function to create the agent. + /// The options instance. + /// Thrown when or is null. + public DurableAgentsOptions AddAIAgentFactory(string name, Func factory) + { + ArgumentNullException.ThrowIfNull(name); + ArgumentNullException.ThrowIfNull(factory); + this._agentFactories.Add(name, factory); + return this; + } + + /// + /// Adds a list of AI agents to the options. + /// + /// The list of agents to add. + /// The options instance. + /// Thrown when is null. + public DurableAgentsOptions AddAIAgents(params IEnumerable agents) + { + ArgumentNullException.ThrowIfNull(agents); + foreach (AIAgent agent in agents) + { + this.AddAIAgent(agent); + } + + return this; + } + + /// + /// Adds an AI agent to the options. + /// + /// The agent to add. + /// The options instance. + /// Thrown when is null. + /// + /// Thrown when is null or whitespace or when an agent with the same name has already been registered. + /// + 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; + } + + /// + /// Gets the agents that have been added to this builder. + /// + /// A read-only collection of agents. + internal IReadOnlyDictionary> GetAgentFactories() + { + return this._agentFactories.AsReadOnly(); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/EntityAgentWrapper.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/EntityAgentWrapper.cs new file mode 100644 index 0000000000..34c9208967 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/EntityAgentWrapper.cs @@ -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 RunAsync( + IEnumerable 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 RunStreamingAsync( + IEnumerable 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? 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? 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; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/IAgentResponseHandler.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/IAgentResponseHandler.cs new file mode 100644 index 0000000000..45a4e9f258 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/IAgentResponseHandler.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.DurableTask; + +/// +/// Handler for processing responses from the agent. This is typically used to send messages to the user. +/// +public interface IAgentResponseHandler +{ + /// + /// Handles a streaming response update from the agent. This is typically used to send messages to the user. + /// + /// + /// The stream of messages from the agent. + /// + /// + /// Signals that the operation should be cancelled. + /// + ValueTask OnStreamingResponseUpdateAsync( + IAsyncEnumerable messageStream, + CancellationToken cancellationToken); + + /// + /// Handles a discrete response from the agent. This is typically used to send messages to the user. + /// + /// + /// The message from the agent. + /// + /// + /// Signals that the operation should be cancelled. + /// + ValueTask OnAgentResponseAsync( + AgentRunResponse message, + CancellationToken cancellationToken); +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/IDurableAgentClient.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/IDurableAgentClient.cs new file mode 100644 index 0000000000..d49999cbbe --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/IDurableAgentClient.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.DurableTask; + +/// +/// Represents a client for interacting with a durable agent. +/// +internal interface IDurableAgentClient +{ + /// + /// Runs an agent with the specified request. + /// + /// The ID of the target agent session. + /// The request containing the message, role, and configuration. + /// The cancellation token for scheduling the request. + /// A task that returns a handle used to read the agent response. + Task RunAgentAsync( + AgentSessionId sessionId, + RunRequest request, + CancellationToken cancellationToken = default); +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Logs.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Logs.cs new file mode 100644 index 0000000000..0bec1e149c --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Logs.cs @@ -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); +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Microsoft.Agents.AI.DurableTask.csproj b/dotnet/src/Microsoft.Agents.AI.DurableTask/Microsoft.Agents.AI.DurableTask.csproj new file mode 100644 index 0000000000..85f790d17b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Microsoft.Agents.AI.DurableTask.csproj @@ -0,0 +1,39 @@ + + + + $(ProjectsCoreTargetFrameworks) + $(ProjectsDebugCoreTargetFrameworks) + enable + + + $(NoWarn);CA2007;MEAI001 + + + + + + + Durable Task extensions for Microsoft Agent Framework + Provides distributed durable execution capabilities for agents built with Microsoft Agent Framework. + README.md + + + + + + + + + + + + + + + + + + + + + diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/README.md b/dotnet/src/Microsoft.Agents.AI.DurableTask/README.md new file mode 100644 index 0000000000..85686cce69 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/README.md @@ -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 + + + +``` + +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). diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/RunRequest.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/RunRequest.cs new file mode 100644 index 0000000000..60e5a7f83c --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/RunRequest.cs @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.DurableTask; + +/// +/// Represents a request to run an agent with a specific message and configuration. +/// +public record RunRequest +{ + /// + /// Gets the list of chat messages to send to the agent (for multi-message requests). + /// + public IList Messages { get; init; } = []; + + /// + /// Gets the optional response format for the agent's response. + /// + public ChatResponseFormat? ResponseFormat { get; init; } + + /// + /// Gets whether to enable tool calls for this request. + /// + public bool EnableToolCalls { get; init; } = true; + + /// + /// Gets the collection of tool names to enable. If not specified, all tools are enabled. + /// + public IList? EnableToolNames { get; init; } + + /// + /// Gets or sets the correlation ID for correlating this request with its response. + /// + [JsonInclude] + internal string CorrelationId { get; set; } = Guid.NewGuid().ToString("N"); + + /// + /// Initializes a new instance of the class for a single message. + /// + /// The message to send to the agent. + /// The role of the message sender (User or System). + /// Optional response format for the agent's response. + /// Whether to enable tool calls for this request. + /// Optional collection of tool names to enable. If not specified, all tools are enabled. + public RunRequest( + string message, + ChatRole? role = null, + ChatResponseFormat? responseFormat = null, + bool enableToolCalls = true, + IList? enableToolNames = null) + : this([new ChatMessage(role ?? ChatRole.User, message) { CreatedAt = DateTimeOffset.UtcNow }], responseFormat, enableToolCalls, enableToolNames) + { + } + + /// + /// Initializes a new instance of the class for multiple messages. + /// + /// The list of chat messages to send to the agent. + /// Optional response format for the agent's response. + /// Whether to enable tool calls for this request. + /// Optional collection of tool names to enable. If not specified, all tools are enabled. + [JsonConstructor] + public RunRequest( + IList messages, + ChatResponseFormat? responseFormat = null, + bool enableToolCalls = true, + IList? enableToolNames = null) + { + this.Messages = messages; + this.ResponseFormat = responseFormat; + this.EnableToolCalls = enableToolCalls; + this.EnableToolNames = enableToolNames; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/ServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/ServiceCollectionExtensions.cs new file mode 100644 index 0000000000..c611206d6a --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/ServiceCollectionExtensions.cs @@ -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; + +/// +/// Agent-specific extension methods for the class. +/// +public static class ServiceCollectionExtensions +{ + /// + /// Gets a durable agent proxy by name. + /// + /// The service provider. + /// The name of the agent. + /// The durable agent proxy. + /// Thrown if the agent proxy is not found. + public static AIAgent GetDurableAgentProxy(this IServiceProvider services, string name) + { + return services.GetKeyedService(name) + ?? throw new KeyNotFoundException($"A durable agent with name '{name}' has not been registered."); + } + + /// + /// Configures the Durable Agents services via the service collection. + /// + /// The service collection. + /// A delegate to configure the durable agents. + /// A delegate to configure the Durable Task worker. + /// A delegate to configure the Durable Task client. + /// The service collection. + public static IServiceCollection ConfigureDurableAgents( + this IServiceCollection services, + Action configure, + Action? workerBuilder = null, + Action? 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(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(); + + 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 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(); + + 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); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentState.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentState.cs new file mode 100644 index 0000000000..f0a12e4099 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentState.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.DurableTask.State; + +/// +/// Represents the state of a durable agent, including its conversation history. +/// +[JsonConverter(typeof(DurableAgentStateJsonConverter))] +internal sealed class DurableAgentState +{ + /// + /// Gets the data of the durable agent. + /// + [JsonPropertyName("data")] + public DurableAgentStateData Data { get; init; } = new(); + + /// + /// Gets the schema version of the durable agent state. + /// + /// + /// The version is specified in semver (i.e. "major.minor.patch") format. + /// + [JsonPropertyName("schemaVersion")] + public string SchemaVersion { get; init; } = "1.0.0"; +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateContent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateContent.cs new file mode 100644 index 0000000000..62f9f18d60 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateContent.cs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.DurableTask.State; + +/// +/// Base class for durable agent state content types. +/// +[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")] +[JsonDerivedType(typeof(DurableAgentStateDataContent), "data")] +[JsonDerivedType(typeof(DurableAgentStateErrorContent), "error")] +[JsonDerivedType(typeof(DurableAgentStateFunctionCallContent), "functionCall")] +[JsonDerivedType(typeof(DurableAgentStateFunctionResultContent), "functionResult")] +[JsonDerivedType(typeof(DurableAgentStateHostedFileContent), "hostedFile")] +[JsonDerivedType(typeof(DurableAgentStateHostedVectorStoreContent), "hostedVectorStore")] +[JsonDerivedType(typeof(DurableAgentStateTextContent), "text")] +[JsonDerivedType(typeof(DurableAgentStateTextReasoningContent), "reasoning")] +[JsonDerivedType(typeof(DurableAgentStateUriContent), "uri")] +[JsonDerivedType(typeof(DurableAgentStateUsageContent), "usage")] +[JsonDerivedType(typeof(DurableAgentStateUnknownContent), "unknown")] +internal abstract class DurableAgentStateContent +{ + /// + /// Gets any additional data found during deserialization that does not map to known properties. + /// + [JsonExtensionData] + public IDictionary? ExtensionData { get; set; } + + /// + /// Converts this durable agent state content to an . + /// + /// A converted instance. + public abstract AIContent ToAIContent(); + + /// + /// Creates a from an . + /// + /// The to convert. + /// A representing the original . + public static DurableAgentStateContent FromAIContent(AIContent content) + { + return content switch + { + DataContent dataContent => DurableAgentStateDataContent.FromDataContent(dataContent), + ErrorContent errorContent => DurableAgentStateErrorContent.FromErrorContent(errorContent), + FunctionCallContent functionCallContent => DurableAgentStateFunctionCallContent.FromFunctionCallContent(functionCallContent), + FunctionResultContent functionResultContent => DurableAgentStateFunctionResultContent.FromFunctionResultContent(functionResultContent), + HostedFileContent hostedFileContent => DurableAgentStateHostedFileContent.FromHostedFileContent(hostedFileContent), + HostedVectorStoreContent hostedVectorStoreContent => DurableAgentStateHostedVectorStoreContent.FromHostedVectorStoreContent(hostedVectorStoreContent), + TextContent textContent => DurableAgentStateTextContent.FromTextContent(textContent), + TextReasoningContent textReasoningContent => DurableAgentStateTextReasoningContent.FromTextReasoningContent(textReasoningContent), + UriContent uriContent => DurableAgentStateUriContent.FromUriContent(uriContent), + UsageContent usageContent => DurableAgentStateUsageContent.FromUsageContent(usageContent), + _ => DurableAgentStateUnknownContent.FromUnknownContent(content) + }; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateData.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateData.cs new file mode 100644 index 0000000000..f51820dcf5 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateData.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.DurableTask.State; + +/// +/// Represents the data of a durable agent, including its conversation history. +/// +internal sealed class DurableAgentStateData +{ + /// + /// Gets the ordered list of state entries representing the complete conversation history. + /// This includes both user messages and agent responses in chronological order. + /// + [JsonPropertyName("conversationHistory")] + public IList ConversationHistory { get; init; } = []; + + /// + /// Gets any additional data found during deserialization that does not map to known properties. + /// + [JsonExtensionData] + public IDictionary? ExtensionData { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateDataContent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateDataContent.cs new file mode 100644 index 0000000000..9954213bd7 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateDataContent.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.DurableTask.State; + +/// +/// Represents a durable agent state content that contains data content. +/// +internal sealed class DurableAgentStateDataContent : DurableAgentStateContent +{ + /// + /// Gets the URI of the data content. + /// + [JsonPropertyName("uri")] + public required string Uri { get; init; } + + /// + /// Gets the media type of the data content. + /// + [JsonPropertyName("mediaType")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? MediaType { get; init; } + + /// + /// Creates a from a . + /// + /// The to convert. + /// A representing the original . + public static DurableAgentStateDataContent FromDataContent(DataContent content) + { + return new DurableAgentStateDataContent() + { + MediaType = content.MediaType, + Uri = content.Uri + }; + } + + /// + public override AIContent ToAIContent() + { + return new DataContent(this.Uri, this.MediaType); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateEntry.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateEntry.cs new file mode 100644 index 0000000000..2f04c90097 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateEntry.cs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.DurableTask.State; + +/// +/// Represents a single entry in the durable agent state, which can either be a +/// user/system request or agent response. +/// +[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")] +[JsonDerivedType(typeof(DurableAgentStateRequest), "request")] +[JsonDerivedType(typeof(DurableAgentStateResponse), "response")] +internal abstract class DurableAgentStateEntry +{ + /// + /// Gets the correlation ID for this entry. + /// + /// + /// This ID is used to correlate back to its + /// . + /// + [JsonPropertyName("correlationId")] + public required string CorrelationId { get; init; } + + /// + /// Gets the timestamp when this entry was created. + /// + [JsonPropertyName("createdAt")] + public required DateTimeOffset CreatedAt { get; init; } + + /// + /// Gets the list of messages associated with this entry, in chronological order. + /// + [JsonPropertyName("messages")] + public IReadOnlyList Messages { get; init; } = []; + + /// + /// Gets any additional data found during deserialization that does not map to known properties. + /// + [JsonExtensionData] + public IDictionary? ExtensionData { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateErrorContent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateErrorContent.cs new file mode 100644 index 0000000000..17e5fea75f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateErrorContent.cs @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.DurableTask.State; + +/// +/// Represents durable agent state content that contains error content. +/// +internal sealed class DurableAgentStateErrorContent : DurableAgentStateContent +{ + /// + /// Gets the error message. + /// + [JsonPropertyName("message")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Message { get; init; } + + /// + /// Gets the error code. + /// + [JsonPropertyName("errorCode")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ErrorCode { get; init; } + + /// + /// Gets the error details. + /// + [JsonPropertyName("details")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Details { get; init; } + + /// + /// Creates a from an . + /// + /// The to convert. + /// A representing the original + /// . + public static DurableAgentStateErrorContent FromErrorContent(ErrorContent content) + { + return new DurableAgentStateErrorContent() + { + Details = content.Details, + ErrorCode = content.ErrorCode, + Message = content.Message + }; + } + + /// + public override AIContent ToAIContent() + { + return new ErrorContent(this.Message) + { + Details = this.Details, + ErrorCode = this.ErrorCode + }; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateFunctionCallContent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateFunctionCallContent.cs new file mode 100644 index 0000000000..babea0f4ff --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateFunctionCallContent.cs @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Immutable; +using System.Text.Json.Serialization; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.DurableTask.State; + +/// +/// Durable agent state content representing a function call. +/// +internal sealed class DurableAgentStateFunctionCallContent : DurableAgentStateContent +{ + /// + /// The function call arguments. + /// + /// TODO: Consider ensuring that empty dictionaries are omitted from serialization. + [JsonPropertyName("arguments")] + public required IReadOnlyDictionary Arguments { get; init; } = + ImmutableDictionary.Empty; + + /// + /// Gets the function call identifier. + /// + /// + /// This is used to correlate this function call with its resulting + /// . + /// + [JsonPropertyName("callId")] + public required string CallId { get; init; } + + /// + /// Gets the function name. + /// + [JsonPropertyName("name")] + public required string Name { get; init; } + + /// + /// Creates a from a . + /// + /// The to convert. + /// + /// A representing the original content. + /// + public static DurableAgentStateFunctionCallContent FromFunctionCallContent(FunctionCallContent content) + { + return new DurableAgentStateFunctionCallContent() + { + Arguments = content.Arguments?.ToImmutableDictionary() ?? ImmutableDictionary.Empty, + CallId = content.CallId, + Name = content.Name + }; + } + + /// + public override AIContent ToAIContent() + { + return new FunctionCallContent( + this.CallId, + this.Name, + new Dictionary(this.Arguments)); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateFunctionResultContent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateFunctionResultContent.cs new file mode 100644 index 0000000000..9237fdfa76 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateFunctionResultContent.cs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.DurableTask.State; + +/// +/// Represents the function result content for a durable agent state response. +/// +internal sealed class DurableAgentStateFunctionResultContent : DurableAgentStateContent +{ + /// + /// Gets the function call identifier. + /// + /// + /// This is used to correlate this function result with its originating + /// . + /// + [JsonPropertyName("callId")] + public required string CallId { get; init; } + + /// + /// Gets the function result. + /// + [JsonPropertyName("result")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public object? Result { get; init; } + + /// + /// Creates a from a . + /// + /// The to convert. + /// A representing the original content. + public static DurableAgentStateFunctionResultContent FromFunctionResultContent(FunctionResultContent content) + { + return new DurableAgentStateFunctionResultContent() + { + CallId = content.CallId, + Result = content.Result + }; + } + + /// + public override AIContent ToAIContent() + { + return new FunctionResultContent(this.CallId, this.Result); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateHostedFileContent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateHostedFileContent.cs new file mode 100644 index 0000000000..c6fc860ac0 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateHostedFileContent.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.DurableTask.State; + +/// +/// Represents durable agent state content that contains hosted file content. +/// +internal sealed class DurableAgentStateHostedFileContent : DurableAgentStateContent +{ + /// + /// Gets the file ID of the hosted file content. + /// + [JsonPropertyName("fileId")] + public required string FileId { get; init; } + + /// + /// Creates a from a . + /// + /// The to convert. + /// + /// A representing the original . + /// + public static DurableAgentStateHostedFileContent FromHostedFileContent(HostedFileContent content) + { + return new DurableAgentStateHostedFileContent() + { + FileId = content.FileId + }; + } + + /// + public override AIContent ToAIContent() + { + return new HostedFileContent(this.FileId); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateHostedVectorStoreContent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateHostedVectorStoreContent.cs new file mode 100644 index 0000000000..f7b615564b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateHostedVectorStoreContent.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.DurableTask.State; + +/// +/// Represents durable agent state content that contains hosted vector store content. +/// +internal sealed class DurableAgentStateHostedVectorStoreContent : DurableAgentStateContent +{ + /// + /// Gets the vector store ID of the hosted vector store content. + /// + [JsonPropertyName("vectorStoreId")] + public required string VectorStoreId { get; init; } + + /// + /// Creates a from a . + /// + /// The to convert. + /// + /// A representing the original . + /// + public static DurableAgentStateHostedVectorStoreContent FromHostedVectorStoreContent(HostedVectorStoreContent content) + { + return new DurableAgentStateHostedVectorStoreContent() + { + VectorStoreId = content.VectorStoreId + }; + } + + /// + public override AIContent ToAIContent() + { + return new HostedVectorStoreContent(this.VectorStoreId); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonContext.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonContext.cs new file mode 100644 index 0000000000..2684fcd3e1 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonContext.cs @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.DurableTask.State; + +[JsonSourceGenerationOptions(WriteIndented = false)] +[JsonSerializable(typeof(DurableAgentState))] +[JsonSerializable(typeof(DurableAgentStateContent))] +[JsonSerializable(typeof(DurableAgentStateData))] +[JsonSerializable(typeof(DurableAgentStateEntry))] +[JsonSerializable(typeof(DurableAgentStateMessage))] +// Function call and result content +[JsonSerializable(typeof(Dictionary))] +[JsonSerializable(typeof(IDictionary))] +[JsonSerializable(typeof(JsonDocument))] +[JsonSerializable(typeof(JsonElement))] +[JsonSerializable(typeof(JsonNode))] +[JsonSerializable(typeof(JsonObject))] +[JsonSerializable(typeof(JsonValue))] +[JsonSerializable(typeof(JsonArray))] +[JsonSerializable(typeof(IEnumerable))] +[JsonSerializable(typeof(char))] +[JsonSerializable(typeof(string))] +[JsonSerializable(typeof(int))] +[JsonSerializable(typeof(short))] +[JsonSerializable(typeof(long))] +[JsonSerializable(typeof(uint))] +[JsonSerializable(typeof(ushort))] +[JsonSerializable(typeof(ulong))] +[JsonSerializable(typeof(float))] +[JsonSerializable(typeof(double))] +[JsonSerializable(typeof(decimal))] +[JsonSerializable(typeof(bool))] +[JsonSerializable(typeof(TimeSpan))] +[JsonSerializable(typeof(DateTime))] +[JsonSerializable(typeof(DateTimeOffset))] +internal sealed partial class DurableAgentStateJsonContext : JsonSerializerContext +{ +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonConverter.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonConverter.cs new file mode 100644 index 0000000000..4c7796b36c --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonConverter.cs @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.DurableTask.State; + +/// +/// JSON converter for which performs schema version checks before deserialization. +/// +internal sealed class DurableAgentStateJsonConverter : JsonConverter +{ + private const string SchemaVersionPropertyName = "schemaVersion"; + private const string DataPropertyName = "data"; + + /// + public override DurableAgentState? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + JsonElement? element = JsonSerializer.Deserialize( + ref reader, + DurableAgentStateJsonContext.Default.JsonElement); + + if (element is null) + { + throw new JsonException("The durable agent state is not valid JSON."); + } + + if (!element.Value.TryGetProperty(SchemaVersionPropertyName, out JsonElement versionElement)) + { + throw new InvalidOperationException("The durable agent state is missing the 'schemaVersion' property."); + } + + if (!Version.TryParse(versionElement.GetString(), out Version? schemaVersion)) + { + throw new InvalidOperationException("The durable agent state has an invalid 'schemaVersion' property."); + } + + if (schemaVersion.Major != 1) + { + throw new InvalidOperationException($"The durable agent state schema version '{schemaVersion}' is not supported."); + } + + if (!element.Value.TryGetProperty(DataPropertyName, out JsonElement dataElement)) + { + throw new InvalidOperationException("The durable agent state is missing the 'data' property."); + } + + DurableAgentStateData? data = dataElement.Deserialize( + DurableAgentStateJsonContext.Default.DurableAgentStateData); + + return new DurableAgentState + { + SchemaVersion = schemaVersion.ToString(), + Data = data ?? new DurableAgentStateData() + }; + } + + /// + public override void Write(Utf8JsonWriter writer, DurableAgentState value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + writer.WritePropertyName(SchemaVersionPropertyName); + writer.WriteStringValue(value.SchemaVersion); + writer.WritePropertyName(DataPropertyName); + JsonSerializer.Serialize( + writer, + value.Data, + DurableAgentStateJsonContext.Default.DurableAgentStateData); + writer.WriteEndObject(); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateMessage.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateMessage.cs new file mode 100644 index 0000000000..294453c149 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateMessage.cs @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.DurableTask.State; + +/// +/// Represents a single message within a durable agent state entry. +/// +internal sealed class DurableAgentStateMessage +{ + /// + /// Gets the name of the author of this message. + /// + [JsonPropertyName("authorName")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? AuthorName { get; init; } + + /// + /// Gets the timestamp when this message was created. + /// + [JsonPropertyName("createdAt")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public DateTimeOffset? CreatedAt { get; init; } + + /// + /// Gets the contents of this message. + /// + [JsonPropertyName("contents")] + public IReadOnlyList Contents { get; init; } = []; + + /// + /// Gets the role of the message sender (e.g., "user", "assistant", "system"). + /// + [JsonPropertyName("role")] + public required string Role { get; init; } + + /// + /// Gets any additional data found during deserialization that does not map to known properties. + /// + [JsonExtensionData] + public IDictionary? ExtensionData { get; set; } + + /// + /// Creates a from a . + /// + /// The to convert. + /// A representing the original message. + public static DurableAgentStateMessage FromChatMessage(ChatMessage message) + { + return new DurableAgentStateMessage() + { + CreatedAt = message.CreatedAt, + AuthorName = message.AuthorName, + Role = message.Role.ToString(), + Contents = message.Contents.Select(DurableAgentStateContent.FromAIContent).ToList() + }; + } + + /// + /// Converts this to a . + /// + /// A representing this message. + public ChatMessage ToChatMessage() + { + return new ChatMessage() + { + CreatedAt = this.CreatedAt, + AuthorName = this.AuthorName, + Contents = this.Contents.Select(c => c.ToAIContent()).ToList(), + Role = new(this.Role) + }; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateRequest.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateRequest.cs new file mode 100644 index 0000000000..cb8f3c137c --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateRequest.cs @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.DurableTask.State; + +/// +/// Represents a user or system request entry in the durable agent state. +/// +internal sealed class DurableAgentStateRequest : DurableAgentStateEntry +{ + /// + /// Gets the expected response type for this request (e.g. "json" or "text"). + /// + /// + /// If omitted, the expectation is that the agent will respond in plain text. + /// + [JsonPropertyName("responseType")] + public string? ResponseType { get; init; } + + /// + /// Gets the expected response JSON schema for this request, if applicable. + /// + /// + /// This is only applicable when is "json". + /// If omitted, no specific schema is expected. + /// + [JsonPropertyName("responseSchema")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? ResponseSchema { get; init; } + + /// + /// Creates a from a . + /// + /// The to convert. + /// A representing the original request. + public static DurableAgentStateRequest FromRunRequest(RunRequest request) + { + return new DurableAgentStateRequest() + { + CorrelationId = request.CorrelationId, + Messages = request.Messages.Select(DurableAgentStateMessage.FromChatMessage).ToList(), + CreatedAt = request.Messages.Min(m => m.CreatedAt) ?? DateTimeOffset.UtcNow, + ResponseType = request.ResponseFormat is ChatResponseFormatJson ? "json" : "text", + ResponseSchema = (request.ResponseFormat as ChatResponseFormatJson)?.Schema + }; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateResponse.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateResponse.cs new file mode 100644 index 0000000000..216bb6e05c --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateResponse.cs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.DurableTask.State; + +/// +/// Represents a durable agent state entry that is a response from the agent. +/// +internal sealed class DurableAgentStateResponse : DurableAgentStateEntry +{ + /// + /// Gets the usage details for this state response. + /// + [JsonPropertyName("usage")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public DurableAgentStateUsage? Usage { get; init; } + + /// + /// Creates a from an . + /// + /// The correlation ID linking this response to its request. + /// The to convert. + /// A representing the original response. + public static DurableAgentStateResponse FromRunResponse(string correlationId, AgentRunResponse response) + { + return new DurableAgentStateResponse() + { + CorrelationId = correlationId, + CreatedAt = response.CreatedAt ?? response.Messages.Max(m => m.CreatedAt) ?? DateTimeOffset.UtcNow, + Messages = response.Messages.Select(DurableAgentStateMessage.FromChatMessage).ToList(), + Usage = DurableAgentStateUsage.FromUsage(response.Usage) + }; + } + + /// + /// Converts this back to an . + /// + /// A representing this response. + public AgentRunResponse ToRunResponse() + { + return new AgentRunResponse() + { + CreatedAt = this.CreatedAt, + Messages = this.Messages.Select(m => m.ToChatMessage()).ToList(), + Usage = this.Usage?.ToUsageDetails(), + }; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTextContent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTextContent.cs new file mode 100644 index 0000000000..0f3085465a --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTextContent.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.DurableTask.State; + +/// +/// Represents the text content for a durable agent state entry. +/// +internal sealed class DurableAgentStateTextContent : DurableAgentStateContent +{ + /// + /// Gets the text message content. + /// + [JsonPropertyName("text")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public required string? Text { get; init; } + + /// + /// Creates a from a . + /// + /// The to convert. + /// A representing the original content. + public static DurableAgentStateTextContent FromTextContent(TextContent content) + { + return new DurableAgentStateTextContent() + { + Text = content.Text + }; + } + + /// + public override AIContent ToAIContent() + { + return new TextContent(this.Text); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTextReasoningContent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTextReasoningContent.cs new file mode 100644 index 0000000000..9b5d6eba34 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTextReasoningContent.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.DurableTask.State; + +/// +/// Represents the text reasoning content for a durable agent state entry. +/// +internal sealed class DurableAgentStateTextReasoningContent : DurableAgentStateContent +{ + /// + /// Gets the text reasoning content. + /// + [JsonPropertyName("text")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Text { get; init; } + + /// + /// Creates a from a . + /// + /// The to convert. + /// A representing the original content. + public static DurableAgentStateTextReasoningContent FromTextReasoningContent(TextReasoningContent content) + { + return new DurableAgentStateTextReasoningContent() + { + Text = content.Text + }; + } + + /// + public override AIContent ToAIContent() + { + return new TextReasoningContent(this.Text); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUnknownContent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUnknownContent.cs new file mode 100644 index 0000000000..00a180bba3 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUnknownContent.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.DurableTask.State; + +/// +/// Represents the unknown content for a durable agent state entry. +/// +internal sealed class DurableAgentStateUnknownContent : DurableAgentStateContent +{ + /// + /// Gets the serialized unknown content. + /// + [JsonPropertyName("content")] + public required JsonElement Content { get; init; } + + /// + /// Creates a from an . + /// + /// The to convert. + /// A representing the original content. + public static DurableAgentStateUnknownContent FromUnknownContent(AIContent content) + { + return new DurableAgentStateUnknownContent() + { + Content = JsonSerializer.SerializeToElement( + value: content, + jsonTypeInfo: AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AIContent))) + }; + } + + /// + public override AIContent ToAIContent() + { + AIContent? content = this.Content.Deserialize( + jsonTypeInfo: AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AIContent))) as AIContent; + + return content ?? throw new InvalidOperationException($"The content '{this.Content}' is not valid AI content."); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUriContent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUriContent.cs new file mode 100644 index 0000000000..8c6bbb8f24 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUriContent.cs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.DurableTask.State; + +/// +/// Represents URI content for a durable agent state message. +/// +internal sealed class DurableAgentStateUriContent : DurableAgentStateContent +{ + /// + /// Gets the URI of the content. + /// + [JsonPropertyName("uri")] + public required Uri Uri { get; init; } + + /// + /// Gets the media type of the content. + /// + [JsonPropertyName("mediaType")] + public required string MediaType { get; init; } + + /// + /// Creates a from a . + /// + /// The to convert. + /// A representing the original content. + public static DurableAgentStateUriContent FromUriContent(UriContent uriContent) + { + return new DurableAgentStateUriContent() + { + MediaType = uriContent.MediaType, + Uri = uriContent.Uri + }; + } + + /// + public override AIContent ToAIContent() + { + return new UriContent(this.Uri, this.MediaType); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUsage.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUsage.cs new file mode 100644 index 0000000000..1b3714faca --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUsage.cs @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.DurableTask.State; + +/// +/// Represents the token usage details for a durable agent state response. +/// +internal sealed class DurableAgentStateUsage +{ + /// + /// Gets the number of input tokens used. + /// + [JsonPropertyName("inputTokenCount")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? InputTokenCount { get; init; } + + /// + /// Gets the number of output tokens used. + /// + [JsonPropertyName("outputTokenCount")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? OutputTokenCount { get; init; } + + /// + /// Gets the total number of tokens used. + /// + [JsonPropertyName("totalTokenCount")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? TotalTokenCount { get; init; } + + /// + /// Gets any additional data found during deserialization that does not map to known properties. + /// + [JsonExtensionData] + public IDictionary? ExtensionData { get; set; } + + /// + /// Creates a from a . + /// + /// The to convert. + /// A representing the original usage details. + [return: NotNullIfNotNull(nameof(usage))] + public static DurableAgentStateUsage? FromUsage(UsageDetails? usage) => + usage is not null + ? new() + { + InputTokenCount = usage.InputTokenCount, + OutputTokenCount = usage.OutputTokenCount, + TotalTokenCount = usage.TotalTokenCount + } + : null; + + /// + /// Converts this back to a . + /// + /// A representing this usage. + public UsageDetails ToUsageDetails() + { + return new() + { + InputTokenCount = this.InputTokenCount, + OutputTokenCount = this.OutputTokenCount, + TotalTokenCount = this.TotalTokenCount + }; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUsageContent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUsageContent.cs new file mode 100644 index 0000000000..bdad860e62 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUsageContent.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.DurableTask.State; + +/// +/// Represents the content for a durable agent state message. +/// +internal sealed class DurableAgentStateUsageContent : DurableAgentStateContent +{ + /// + /// Gets the usage details. + /// + [JsonPropertyName("usage")] + public DurableAgentStateUsage Usage { get; init; } = new(); + + /// + /// Creates a from a . + /// + /// The to convert. + /// A representing the original content. + public static DurableAgentStateUsageContent FromUsageContent(UsageContent content) + { + return new DurableAgentStateUsageContent() + { + Usage = DurableAgentStateUsage.FromUsage(content.Details) + }; + } + + /// + public override AIContent ToAIContent() + { + return new UsageContent(this.Usage.ToUsageDetails()); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/README.md b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/README.md new file mode 100644 index 0000000000..09bb13c51e --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/README.md @@ -0,0 +1,147 @@ +# Durable Agent State + +Durable agents are represented as durable entities, with each session (i.e. thread) of conversation history stored as JSON-serialized state for an individual entity instance. + +## State Schema + +The [schema](../../../../schemas/durable-agent-entity-state.json) for durable agent state is a distillation of the prompt and response messages accumulated over the lifetime of a session. While these messages and content originate from Microsoft Agent Framework types (for .NET, see [ChatMessage](https://github.com/dotnet/extensions/blob/main/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatMessage.cs) and [AIContent](https://github.com/dotnet/extensions/blob/main/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIContent.cs)), durable agent state uses its own, parallel, types in order to (1) better manage the versioning and compatibility of serialized state over time, (2) account for agent implementations across languages/platforms (e.g. .NET and Python), as well as (3) ensure consistency for external tools that make use of state data. + +> When new AI content types are added to the Microsoft Agent Framework, equivalent types should be added to the entity state schema as well. The durable agent state "unknown" type can be used when an AI content type is encountered but no equivalent type exists. + +## State Versioning + +The serialized state contains a root `schemaVersion` property, which represents the version of the schema used to serialize data in that state (represented by the `data` property). + +Some versioning considerations: + +- Versions should use semver notation (e.g. `".."`) +- Durable agents should use the version property to determine how to deserialize that state and should not attempt to deserialize semver-incompatible versions +- Newer versions of durable agents should strive to be compatible with older schema versions (e.g. new properties and objects should be optional) +- Durable agents should preserve existing, but unrecognized, properties when serializing state + +## Sample State + +```json +{ + "schemaVersion": "1.0.0", + "data": { + "conversationHistory": [ + { + "$type": "request", + "responseType": "text", + "correlationId": "c338f064f4b44b8d9c21a66e3cda41b2", + "createdAt": "2025-11-04T19:33:05.245476+00:00", + "messages": [ + { + "contents": [ + { + "$type": "text", + "text": "Start the documentation generation workflow for the product \u0027Goldbrew Coffee\u0027" + } + ], + "role": "user" + } + ] + }, + { + "$type": "response", + "usage": { + "inputTokenCount": 595, + "outputTokenCount": 63, + "totalTokenCount": 658 + }, + "correlationId": "c338f064f4b44b8d9c21a66e3cda41b2", + "createdAt": "2025-11-04T19:33:10.47008+00:00", + "messages": [ + { + "authorName": "OrchestratorAgent", + "createdAt": "2025-11-04T19:33:10+00:00", + "contents": [ + { + "$type": "functionCall", + "arguments": { + "productName": "Goldbrew Coffee" + }, + "callId": "call_qWk9Ay4doKYrUBoADK8MBwHf", + "name": "StartDocumentGeneration" + } + ], + "role": "assistant" + }, + { + "authorName": "OrchestratorAgent", + "createdAt": "2025-11-04T19:33:10.47008+00:00", + "contents": [ + { + "$type": "functionResult", + "callId": "call_qWk9Ay4doKYrUBoADK8MBwHf", + "result": "8b835e8f2a6f40faabdba33bd8fd8c74" + } + ], + "role": "tool" + }, + { + "authorName": "OrchestratorAgent", + "createdAt": "2025-11-04T19:33:10+00:00", + "contents": [ + { + "$type": "text", + "text": "The documentation generation workflow for the product \u0022Goldbrew Coffee\u0022 has been started. You can request updates on its status or provide additional input anytime during the process. Let me know how you\u2019d like to proceed!" + } + ], + "role": "assistant" + } + ] + }, + { + "$type": "request", + "responseType": "text", + "correlationId": "71f35b7add6b403fadd0db8a7c137b58", + "createdAt": "2025-11-04T19:33:11.903413+00:00", + "messages": [ + { + "contents": [ + { + "$type": "text", + "text": "Tell the user that you\u0027re starting to gather information for product \u0027Goldbrew Coffee\u0027." + } + ], + "role": "system" + } + ] + }, + { + "$type": "response", + "usage": { + "inputTokenCount": 396, + "outputTokenCount": 48, + "totalTokenCount": 444 + }, + "correlationId": "71f35b7add6b403fadd0db8a7c137b58", + "createdAt": "2025-11-04T19:33:12+00:00", + "messages": [ + { + "authorName": "OrchestratorAgent", + "createdAt": "2025-11-04T19:33:12+00:00", + "contents": [ + { + "$type": "text", + "text": "I am starting to gather information to create product documentation for \u0027Goldbrew Coffee\u0027. If you have any specific details, key features, or requirements you\u0027d like included, please share them. Otherwise, I\u0027ll continue with the standard documentation process." + } + ], + "role": "assistant" + } + ] + } + ] + } +} +``` + +## State Consumers + +Additional tools may make use of durable agent state. Significant changes to the state schema may need corresponding changes to those applications. + +### Durable Task Scheduler Dashboard + +The [Durable Task Scheduler (DTS)](https://learn.microsoft.com/azure/azure-functions/durable/durable-task-scheduler/durable-task-scheduler) Dashboard, while providing general UX for management of durable orchestrations and entities, also has UX specific to the use of durable agents. diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/TaskOrchestrationContextExtensions.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/TaskOrchestrationContextExtensions.cs new file mode 100644 index 0000000000..63f491cf48 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/TaskOrchestrationContextExtensions.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.ComponentModel; +using Microsoft.DurableTask; + +namespace Microsoft.Agents.AI.DurableTask; + +/// +/// Agent-related extension methods for the class. +/// +[EditorBrowsable(EditorBrowsableState.Never)] +public static class TaskOrchestrationContextExtensions +{ + /// + /// Gets a for interacting with hosted agents within an orchestration. + /// + /// The orchestration context. + /// The name of the agent. + /// Thrown when is null or empty. + /// A that can be used to interact with the agent. + public static DurableAIAgent GetAgent( + this TaskOrchestrationContext context, + string agentName) + { + ArgumentException.ThrowIfNullOrEmpty(agentName); + return new DurableAIAgent(context, agentName); + } + + /// + /// Generates an for an agent. + /// + /// + /// This method is deterministic and safe for use in an orchestration context. + /// + /// The orchestration context. + /// The name of the agent. + /// Thrown when is null or empty. + /// The generated agent session ID. + internal static AgentSessionId NewAgentSessionId( + this TaskOrchestrationContext context, + string agentName) + { + ArgumentException.ThrowIfNullOrEmpty(agentName); + + return new AgentSessionId(agentName, context.NewGuid().ToString("N")); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctionExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctionExecutor.cs new file mode 100644 index 0000000000..291f042db5 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctionExecutor.cs @@ -0,0 +1,110 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Context.Features; +using Microsoft.Azure.Functions.Worker.Extensions.Mcp; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Azure.Functions.Worker.Invocation; +using Microsoft.DurableTask.Client; + +namespace Microsoft.Agents.AI.Hosting.AzureFunctions; + +/// +/// This implementation of function executor handles invocations using the built-in static methods for agent HTTP and entity functions. +/// +/// By default, the Azure Functions worker generates function executor and that executor is used for function invocations. +/// But for the dummy HTTP function we create for agents (by augmenting the metadata), that executor will not have the code to handle that function since the entrypoint is a built-in static method. +/// +internal sealed class BuiltInFunctionExecutor : IFunctionExecutor +{ + public async ValueTask ExecuteAsync(FunctionContext context) + { + ArgumentNullException.ThrowIfNull(context); + + // Acquire the input binding feature (fail fast if missing rather than null-forgiving operator). + IFunctionInputBindingFeature? functionInputBindingFeature = context.Features.Get(); + if (functionInputBindingFeature == null) + { + throw new InvalidOperationException("Function input binding feature is not available on the current context."); + } + + FunctionInputBindingResult? inputBindingResults = await functionInputBindingFeature.BindFunctionInputAsync(context); + if (inputBindingResults is not { Values: { } values }) + { + throw new InvalidOperationException($"Function input binding failed for the invocation {context.InvocationId}"); + } + + HttpRequestData? httpRequestData = null; + TaskEntityDispatcher? dispatcher = null; + DurableTaskClient? durableTaskClient = null; + ToolInvocationContext? mcpToolInvocationContext = null; + + foreach (var binding in values) + { + switch (binding) + { + case HttpRequestData request: + httpRequestData = request; + break; + case TaskEntityDispatcher entityDispatcher: + dispatcher = entityDispatcher; + break; + case DurableTaskClient client: + durableTaskClient = client; + break; + case ToolInvocationContext toolContext: + mcpToolInvocationContext = toolContext; + break; + } + } + + if (durableTaskClient is null) + { + // This is not expected to happen since all built-in functions are + // expected to have a Durable Task client binding. + throw new InvalidOperationException($"Durable Task client binding is missing for the invocation {context.InvocationId}."); + } + + if (context.FunctionDefinition.EntryPoint == BuiltInFunctions.RunAgentHttpFunctionEntryPoint) + { + if (httpRequestData == null) + { + throw new InvalidOperationException($"HTTP request data binding is missing for the invocation {context.InvocationId}."); + } + + context.GetInvocationResult().Value = await BuiltInFunctions.RunAgentHttpAsync( + httpRequestData, + durableTaskClient, + context); + return; + } + + if (context.FunctionDefinition.EntryPoint == BuiltInFunctions.RunAgentEntityFunctionEntryPoint) + { + if (dispatcher is null) + { + throw new InvalidOperationException($"Task entity dispatcher binding is missing for the invocation {context.InvocationId}."); + } + + await BuiltInFunctions.InvokeAgentAsync( + dispatcher, + durableTaskClient, + context); + return; + } + + if (context.FunctionDefinition.EntryPoint == BuiltInFunctions.RunAgentMcpToolFunctionEntryPoint) + { + if (mcpToolInvocationContext is null) + { + throw new InvalidOperationException($"MCP tool invocation context binding is missing for the invocation {context.InvocationId}."); + } + + context.GetInvocationResult().Value = + await BuiltInFunctions.RunMcpToolAsync(mcpToolInvocationContext, durableTaskClient, context); + return; + } + + throw new InvalidOperationException($"Unsupported function entry point '{context.FunctionDefinition.EntryPoint}' for invocation {context.InvocationId}."); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs new file mode 100644 index 0000000000..f8c4618c9e --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs @@ -0,0 +1,373 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Net; +using System.Text.Json.Serialization; +using Microsoft.Agents.AI.DurableTask; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Extensions.Mcp; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.DurableTask.Client; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.Agents.AI.Hosting.AzureFunctions; + +internal static class BuiltInFunctions +{ + internal const string HttpPrefix = "http-"; + internal const string McpToolPrefix = "mcptool-"; + + internal static readonly string RunAgentHttpFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RunAgentHttpAsync)}"; + internal static readonly string RunAgentEntityFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(InvokeAgentAsync)}"; + internal static readonly string RunAgentMcpToolFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RunMcpToolAsync)}"; + + // Exposed as an entity trigger via AgentFunctionsProvider + public static async Task InvokeAgentAsync( + [EntityTrigger] TaskEntityDispatcher dispatcher, + [DurableClient] DurableTaskClient client, + FunctionContext functionContext) + { + // This should never be null except if the function trigger is misconfigured. + ArgumentNullException.ThrowIfNull(dispatcher); + ArgumentNullException.ThrowIfNull(client); + ArgumentNullException.ThrowIfNull(functionContext); + + // Create a combined service provider that includes both the existing services + // and the DurableTaskClient instance + IServiceProvider combinedServiceProvider = new CombinedServiceProvider(functionContext.InstanceServices, client); + + // This method is the entry point for the agent entity. + // It will be invoked by the Azure Functions runtime when the entity is called. + await dispatcher.DispatchAsync(new AgentEntity(combinedServiceProvider, functionContext.CancellationToken)); + } + + public static async Task RunAgentHttpAsync( + [HttpTrigger] HttpRequestData req, + [DurableClient] DurableTaskClient client, + FunctionContext context) + { + // Parse request body - support both JSON and plain text + string? message = null; + string? threadIdFromBody = null; + + if (req.Headers.TryGetValues("Content-Type", out IEnumerable? contentTypeValues) && + contentTypeValues.Any(ct => ct.Contains("application/json", StringComparison.OrdinalIgnoreCase))) + { + // Parse JSON body using POCO record + AgentRunRequest? requestBody = await req.ReadFromJsonAsync(context.CancellationToken); + if (requestBody != null) + { + message = requestBody.Message; + threadIdFromBody = requestBody.ThreadId; + } + } + else + { + // Plain text body + message = await req.ReadAsStringAsync(); + } + + // The thread ID can come from query string or JSON body + string? threadIdFromQuery = req.Query["thread_id"]; + + // Validate that if thread_id is specified in both places, they must match + if (!string.IsNullOrEmpty(threadIdFromQuery) && !string.IsNullOrEmpty(threadIdFromBody) && + !string.Equals(threadIdFromQuery, threadIdFromBody, StringComparison.Ordinal)) + { + return await CreateErrorResponseAsync( + req, + context, + HttpStatusCode.BadRequest, + "thread_id specified in both query string and request body must match."); + } + + string? threadIdValue = threadIdFromBody ?? threadIdFromQuery; + + // If no session ID is provided, use a new one based on the function name and invocation ID. + // This may be better than a random one because it can be correlated with the function invocation. + // Specifying a session ID is how the caller correlates multiple calls to the same agent session. + AgentSessionId sessionId = string.IsNullOrEmpty(threadIdValue) + ? new AgentSessionId(GetAgentName(context), context.InvocationId) + : AgentSessionId.Parse(threadIdValue); + + if (string.IsNullOrWhiteSpace(message)) + { + return await CreateErrorResponseAsync( + req, + context, + HttpStatusCode.BadRequest, + "Run request cannot be empty."); + } + + // Check if we should wait for response (default is true) + bool waitForResponse = true; + if (req.Headers.TryGetValues("x-ms-wait-for-response", out IEnumerable? waitForResponseValues)) + { + string? waitForResponseValue = waitForResponseValues.FirstOrDefault(); + if (!string.IsNullOrEmpty(waitForResponseValue) && bool.TryParse(waitForResponseValue, out bool parsedValue)) + { + waitForResponse = parsedValue; + } + } + + AIAgent agentProxy = client.AsDurableAgentProxy(context, GetAgentName(context)); + + DurableAgentRunOptions options = new() { IsFireAndForget = !waitForResponse }; + + if (waitForResponse) + { + AgentRunResponse agentResponse = await agentProxy.RunAsync( + message: new ChatMessage(ChatRole.User, message), + thread: new DurableAgentThread(sessionId), + options: options, + cancellationToken: context.CancellationToken); + + return await CreateSuccessResponseAsync( + req, + context, + HttpStatusCode.OK, + sessionId.ToString(), + agentResponse); + } + + // Fire and forget - return 202 Accepted + await agentProxy.RunAsync( + message: new ChatMessage(ChatRole.User, message), + thread: new DurableAgentThread(sessionId), + options: options, + cancellationToken: context.CancellationToken); + + return await CreateAcceptedResponseAsync( + req, + context, + sessionId.ToString()); + } + + public static async Task RunMcpToolAsync( + [McpToolTrigger("BuiltInMcpTool")] ToolInvocationContext context, + [DurableClient] DurableTaskClient client, + FunctionContext functionContext) + { + if (context.Arguments is null) + { + throw new ArgumentException("MCP Tool invocation is missing required arguments."); + } + + if (!context.Arguments.TryGetValue("query", out object? queryObj) || queryObj is not string query) + { + throw new ArgumentException("MCP Tool invocation is missing required 'query' argument of type string."); + } + + string agentName = context.Name; + + // Derive session id: try to parse provided threadId, otherwise create a new one. + AgentSessionId sessionId = context.Arguments.TryGetValue("threadId", out object? threadObj) && threadObj is string threadId && !string.IsNullOrWhiteSpace(threadId) + ? AgentSessionId.Parse(threadId) + : new AgentSessionId(agentName, functionContext.InvocationId); + + AIAgent agentProxy = client.AsDurableAgentProxy(functionContext, agentName); + + AgentRunResponse agentResponse = await agentProxy.RunAsync( + message: new ChatMessage(ChatRole.User, query), + thread: new DurableAgentThread(sessionId), + options: null); + + return agentResponse.Text; + } + + /// + /// Creates an error response with the specified status code and error message. + /// + /// The HTTP request data. + /// The function context. + /// The HTTP status code. + /// The error message. + /// The HTTP response data containing the error. + private static async Task CreateErrorResponseAsync( + HttpRequestData req, + FunctionContext context, + HttpStatusCode statusCode, + string errorMessage) + { + HttpResponseData response = req.CreateResponse(statusCode); + bool acceptsJson = req.Headers.TryGetValues("Accept", out IEnumerable? acceptValues) && + acceptValues.Contains("application/json", StringComparer.OrdinalIgnoreCase); + + if (acceptsJson) + { + ErrorResponse errorResponse = new((int)statusCode, errorMessage); + await response.WriteAsJsonAsync(errorResponse, context.CancellationToken); + } + else + { + response.Headers.Add("Content-Type", "text/plain"); + await response.WriteStringAsync(errorMessage, context.CancellationToken); + } + + return response; + } + + /// + /// Creates a successful agent run response with the agent's response. + /// + /// The HTTP request data. + /// The function context. + /// The HTTP status code (typically 200 OK). + /// The thread ID for the conversation. + /// The agent's response. + /// The HTTP response data containing the success response. + private static async Task CreateSuccessResponseAsync( + HttpRequestData req, + FunctionContext context, + HttpStatusCode statusCode, + string threadId, + AgentRunResponse agentResponse) + { + HttpResponseData response = req.CreateResponse(statusCode); + response.Headers.Add("x-ms-thread-id", threadId); + + bool acceptsJson = req.Headers.TryGetValues("Accept", out IEnumerable? acceptValues) && + acceptValues.Contains("application/json", StringComparer.OrdinalIgnoreCase); + + if (acceptsJson) + { + AgentRunSuccessResponse successResponse = new((int)statusCode, threadId, agentResponse); + await response.WriteAsJsonAsync(successResponse, context.CancellationToken); + } + else + { + response.Headers.Add("Content-Type", "text/plain"); + await response.WriteStringAsync(agentResponse.Text, context.CancellationToken); + } + + return response; + } + + /// + /// Creates an accepted (fire-and-forget) agent run response. + /// + /// The HTTP request data. + /// The function context. + /// The thread ID for the conversation. + /// The HTTP response data containing the accepted response. + private static async Task CreateAcceptedResponseAsync( + HttpRequestData req, + FunctionContext context, + string threadId) + { + HttpResponseData response = req.CreateResponse(HttpStatusCode.Accepted); + response.Headers.Add("x-ms-thread-id", threadId); + + bool acceptsJson = req.Headers.TryGetValues("Accept", out IEnumerable? acceptValues) && + acceptValues.Contains("application/json", StringComparer.OrdinalIgnoreCase); + + if (acceptsJson) + { + AgentRunAcceptedResponse acceptedResponse = new((int)HttpStatusCode.Accepted, threadId); + await response.WriteAsJsonAsync(acceptedResponse, context.CancellationToken); + } + else + { + response.Headers.Add("Content-Type", "text/plain"); + await response.WriteStringAsync("Request accepted.", context.CancellationToken); + } + + return response; + } + + private static string GetAgentName(FunctionContext context) + { + // Check if the function name starts with the HttpPrefix + string functionName = context.FunctionDefinition.Name; + if (!functionName.StartsWith(HttpPrefix, StringComparison.Ordinal)) + { + // This should never happen because the function metadata provider ensures + // that the function name starts with the HttpPrefix (http-). + throw new InvalidOperationException( + $"Built-in HTTP trigger function name '{functionName}' does not start with '{HttpPrefix}'."); + } + + // Remove the HttpPrefix from the function name to get the agent name. + return functionName[HttpPrefix.Length..]; + } + + /// + /// Represents a request to run an agent. + /// + /// The message to send to the agent. + /// The optional thread ID to continue a conversation. + private sealed record AgentRunRequest( + [property: JsonPropertyName("message")] string? Message, + [property: JsonPropertyName("thread_id")] string? ThreadId); + + /// + /// Represents an error response. + /// + /// The HTTP status code. + /// The error message. + private sealed record ErrorResponse( + [property: JsonPropertyName("status")] int Status, + [property: JsonPropertyName("error")] string Error); + + /// + /// Represents a successful agent run response. + /// + /// The HTTP status code. + /// The thread ID for the conversation. + /// The agent response. + private sealed record AgentRunSuccessResponse( + [property: JsonPropertyName("status")] int Status, + [property: JsonPropertyName("thread_id")] string ThreadId, + [property: JsonPropertyName("response")] AgentRunResponse Response); + + /// + /// Represents an accepted (fire-and-forget) agent run response. + /// + /// The HTTP status code. + /// The thread ID for the conversation. + private sealed record AgentRunAcceptedResponse( + [property: JsonPropertyName("status")] int Status, + [property: JsonPropertyName("thread_id")] string ThreadId); + + /// + /// A service provider that combines the original service provider with an additional DurableTaskClient instance. + /// + private sealed class CombinedServiceProvider(IServiceProvider originalProvider, DurableTaskClient client) + : IServiceProvider, IKeyedServiceProvider + { + private readonly IServiceProvider _originalProvider = originalProvider; + private readonly DurableTaskClient _client = client; + + public object? GetKeyedService(Type serviceType, object? serviceKey) + { + if (this._originalProvider is IKeyedServiceProvider keyedProvider) + { + return keyedProvider.GetKeyedService(serviceType, serviceKey); + } + + return null; + } + + public object GetRequiredKeyedService(Type serviceType, object? serviceKey) + { + if (this._originalProvider is IKeyedServiceProvider keyedProvider) + { + return keyedProvider.GetRequiredKeyedService(serviceType, serviceKey); + } + + throw new InvalidOperationException("The original service provider does not support keyed services."); + } + + public object? GetService(Type serviceType) + { + // If the requested service is DurableTaskClient, return our instance + if (serviceType == typeof(DurableTaskClient)) + { + return this._client; + } + + // Otherwise try to get the service from the original provider + return this._originalProvider.GetService(serviceType); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/CHANGELOG.md b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/CHANGELOG.md new file mode 100644 index 0000000000..e908deac89 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/CHANGELOG.md @@ -0,0 +1,5 @@ +# Release History + +## v1.0.0-preview.* (Unreleased) + +- Initial public release. diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DefaultFunctionsAgentOptionsProvider.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DefaultFunctionsAgentOptionsProvider.cs new file mode 100644 index 0000000000..1039fb5aec --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DefaultFunctionsAgentOptionsProvider.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.Agents.AI.Hosting.AzureFunctions; + +/// +/// Provides access to agent-specific options for functions agents by name. +/// Returns default options (HTTP trigger enabled, MCP tool disabled) when no explicit options were configured. +/// +internal sealed class DefaultFunctionsAgentOptionsProvider(IReadOnlyDictionary functionsAgentOptions) + : IFunctionsAgentOptionsProvider +{ + private readonly IReadOnlyDictionary _functionsAgentOptions = + functionsAgentOptions ?? throw new ArgumentNullException(nameof(functionsAgentOptions)); + + // Default options. HTTP trigger enabled, MCP tool disabled. + private static readonly FunctionsAgentOptions s_defaultOptions = new() + { + HttpTrigger = { IsEnabled = true }, + McpToolTrigger = { IsEnabled = false } + }; + + /// + /// Attempts to retrieve the options associated with the specified agent name. + /// If not found, a default options instance (with HTTP trigger enabled) is returned. + /// + /// The name of the agent whose options are to be retrieved. Cannot be null or empty. + /// The options for the specified agent. Will never be null. + /// Always true. Returns configured options if present; otherwise default fallback options. + public bool TryGet(string agentName, [NotNullWhen(true)] out FunctionsAgentOptions? options) + { + ArgumentException.ThrowIfNullOrEmpty(agentName); + + if (this._functionsAgentOptions.TryGetValue(agentName, out FunctionsAgentOptions? existing)) + { + options = existing; + return true; + } + + // If not defined, return default options. + options = s_defaultOptions; + return true; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableAgentFunctionMetadataTransformer.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableAgentFunctionMetadataTransformer.cs new file mode 100644 index 0000000000..cce8fbd1b0 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableAgentFunctionMetadataTransformer.cs @@ -0,0 +1,118 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.DurableTask; +using Microsoft.Azure.Functions.Worker.Core.FunctionMetadata; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Agents.AI.Hosting.AzureFunctions; + +/// +/// Transforms function metadata by registering durable agent functions for each configured agent. +/// +/// This transformer adds both entity trigger and HTTP trigger functions for every agent registered in the application. +internal sealed class DurableAgentFunctionMetadataTransformer : IFunctionMetadataTransformer +{ + private readonly ILogger _logger; + private readonly IReadOnlyDictionary> _agents; + private readonly IServiceProvider _serviceProvider; + private readonly IFunctionsAgentOptionsProvider _functionsAgentOptionsProvider; + +#pragma warning disable IL3000 // Avoid accessing Assembly file path when publishing as a single file - Azure Functions does not use single-file publishing + private static readonly string s_builtInFunctionsScriptFile = Path.GetFileName(typeof(BuiltInFunctions).Assembly.Location); +#pragma warning restore IL3000 + + public DurableAgentFunctionMetadataTransformer( + IReadOnlyDictionary> agents, + ILogger logger, + IServiceProvider serviceProvider, + IFunctionsAgentOptionsProvider functionsAgentOptionsProvider) + { + this._agents = agents ?? throw new ArgumentNullException(nameof(agents)); + this._logger = logger ?? throw new ArgumentNullException(nameof(logger)); + this._serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + this._functionsAgentOptionsProvider = functionsAgentOptionsProvider ?? throw new ArgumentNullException(nameof(functionsAgentOptionsProvider)); + } + + public string Name => nameof(DurableAgentFunctionMetadataTransformer); + + public void Transform(IList original) + { + this._logger.LogTransformingFunctionMetadata(original.Count); + + foreach (KeyValuePair> kvp in this._agents) + { + string agentName = kvp.Key; + + this._logger.LogRegisteringTriggerForAgent(agentName, "entity"); + + original.Add(CreateAgentTrigger(agentName)); + + if (this._functionsAgentOptionsProvider.TryGet(agentName, out FunctionsAgentOptions? agentTriggerOptions)) + { + if (agentTriggerOptions.HttpTrigger.IsEnabled) + { + this._logger.LogRegisteringTriggerForAgent(agentName, "http"); + original.Add(CreateHttpTrigger(agentName, $"agents/{agentName}/run")); + } + + if (agentTriggerOptions.McpToolTrigger.IsEnabled) + { + AIAgent agent = kvp.Value(this._serviceProvider); + this._logger.LogRegisteringTriggerForAgent(agentName, "mcpTool"); + original.Add(CreateMcpToolTrigger(agentName, agent.Description)); + } + } + } + } + + private static DefaultFunctionMetadata CreateAgentTrigger(string name) + { + return new DefaultFunctionMetadata() + { + Name = AgentSessionId.ToEntityName(name), + Language = "dotnet-isolated", + RawBindings = + [ + """{"name":"dispatcher","type":"entityTrigger","direction":"In"}""", + """{"name":"client","type":"durableClient","direction":"In"}""" + ], + EntryPoint = BuiltInFunctions.RunAgentEntityFunctionEntryPoint, + ScriptFile = s_builtInFunctionsScriptFile, + }; + } + + private static DefaultFunctionMetadata CreateHttpTrigger(string name, string route) + { + return new DefaultFunctionMetadata() + { + Name = $"{BuiltInFunctions.HttpPrefix}{name}", + Language = "dotnet-isolated", + RawBindings = + [ + $"{{\"name\":\"req\",\"type\":\"httpTrigger\",\"direction\":\"In\",\"authLevel\":\"function\",\"methods\": [\"post\"],\"route\":\"{route}\"}}", + "{\"name\":\"$return\",\"type\":\"http\",\"direction\":\"Out\"}", + "{\"name\":\"client\",\"type\":\"durableClient\",\"direction\":\"In\"}" + ], + EntryPoint = BuiltInFunctions.RunAgentHttpFunctionEntryPoint, + ScriptFile = s_builtInFunctionsScriptFile, + }; + } + + private static DefaultFunctionMetadata CreateMcpToolTrigger(string agentName, string? description) + { + return new DefaultFunctionMetadata + { + Name = $"{BuiltInFunctions.McpToolPrefix}{agentName}", + Language = "dotnet-isolated", + RawBindings = + [ + $$"""{"name":"context","type":"mcpToolTrigger","direction":"In","toolName":"{{agentName}}","description":"{{description}}","toolProperties":"[{\"propertyName\":\"query\",\"propertyType\":\"string\",\"description\":\"The query to send to the agent.\",\"isRequired\":true,\"isArray\":false},{\"propertyName\":\"threadId\",\"propertyType\":\"string\",\"description\":\"Optional thread identifier.\",\"isRequired\":false,\"isArray\":false}]"}""", + """{"name":"query","type":"mcpToolProperty","direction":"In","propertyName":"query","description":"The query to send to the agent","isRequired":true,"dataType":"String","propertyType":"string"}""", + """{"name":"threadId","type":"mcpToolProperty","direction":"In","propertyName":"threadId","description":"The thread identifier.","isRequired":false,"dataType":"String","propertyType":"string"}""", + """{"name":"client","type":"durableClient","direction":"In"}""" + ], + EntryPoint = BuiltInFunctions.RunAgentMcpToolFunctionEntryPoint, + ScriptFile = s_builtInFunctionsScriptFile, + }; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableAgentsOptionsExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableAgentsOptionsExtensions.cs new file mode 100644 index 0000000000..ad21d8f4e1 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableAgentsOptionsExtensions.cs @@ -0,0 +1,137 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.DurableTask; + +namespace Microsoft.Agents.AI.Hosting.AzureFunctions; + +/// +/// Provides extension methods for registering and configuring AI agents in the context of the Azure Functions hosting environment. +/// +public static class DurableAgentsOptionsExtensions +{ + // Registry of agent options. + private static readonly Dictionary s_agentOptions = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Adds an AI agent to the specified DurableAgentsOptions instance and optionally configures agent-specific + /// options. + /// + /// The DurableAgentsOptions instance to which the AI agent will be added. + /// The AI agent to add. The agent's Name property must not be null or empty. + /// An optional delegate to configure agent-specific options. If null, default options are used. + /// The updated instance containing the added AI agent. + public static DurableAgentsOptions AddAIAgent( + this DurableAgentsOptions options, + AIAgent agent, + Action? configure) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(agent); + ArgumentException.ThrowIfNullOrEmpty(agent.Name); + + // Initialize with default behavior (HTTP trigger enabled) + FunctionsAgentOptions agentOptions = new() { HttpTrigger = { IsEnabled = true } }; + configure?.Invoke(agentOptions); + options.AddAIAgent(agent); + s_agentOptions[agent.Name] = agentOptions; + return options; + } + + /// + /// Adds an AI agent to the specified options and configures trigger support for HTTP and MCP tool invocations. + /// + /// If an agent with the same name already exists in the options, its configuration will be + /// updated. Both triggers can be enabled independently. This method supports method chaining by returning the + /// provided options instance. + /// The options collection to which the AI agent will be added. Cannot be null. + /// The AI agent to add. The agent's Name property must not be null or empty. + /// true to enable an HTTP trigger for the agent; otherwise, false. + /// true to enable an MCP tool trigger for the agent; otherwise, false. + /// The updated instance with the specified AI agent and trigger configuration applied. + public static DurableAgentsOptions AddAIAgent( + this DurableAgentsOptions options, + AIAgent agent, + bool enableHttpTrigger, + bool enableMcpToolTrigger) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(agent); + ArgumentException.ThrowIfNullOrEmpty(agent.Name); + + FunctionsAgentOptions agentOptions = new(); + agentOptions.HttpTrigger.IsEnabled = enableHttpTrigger; + agentOptions.McpToolTrigger.IsEnabled = enableMcpToolTrigger; + + options.AddAIAgent(agent); + s_agentOptions[agent.Name] = agentOptions; + return options; + } + + /// + /// Registers an AI agent factory with the specified name and optional configuration in the provided + /// DurableAgentsOptions instance. + /// + /// If an agent factory with the same name already exists, its configuration will be replaced. + /// This method enables custom agent registration and configuration for use in durable agent scenarios. + /// The DurableAgentsOptions instance to which the AI agent factory will be added. Cannot be null. + /// The unique name used to identify the AI agent factory. Cannot be null. + /// A delegate that creates an AIAgent instance using the provided IServiceProvider. Cannot be null. + /// An optional action to configure FunctionsAgentOptions for the agent factory. If null, default options are used. + /// The updated DurableAgentsOptions instance containing the registered AI agent factory. + public static DurableAgentsOptions AddAIAgentFactory( + this DurableAgentsOptions options, + string name, + Func factory, + Action? configure) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(name); + ArgumentNullException.ThrowIfNull(factory); + + // Initialize with default behavior (HTTP trigger enabled) + FunctionsAgentOptions agentOptions = new() { HttpTrigger = { IsEnabled = true } }; + configure?.Invoke(agentOptions); + options.AddAIAgentFactory(name, factory); + s_agentOptions[name] = agentOptions; + return options; + } + + /// + /// Registers an AI agent factory with the specified name and configures trigger options for the agent. + /// + /// If both triggers are disabled, the agent will not be accessible via HTTP or MCP tool + /// endpoints. This method can be used to register multiple agent factories with different configurations. + /// The options object to which the AI agent factory will be added. Cannot be null. + /// The unique name used to identify the AI agent factory. Cannot be null. + /// A delegate that creates an instance of the AI agent using the provided service provider. Cannot be null. + /// true to enable the HTTP trigger for the agent; otherwise, false. + /// true to enable the MCP tool trigger for the agent; otherwise, false. + /// The same DurableAgentsOptions instance, allowing for method chaining. + public static DurableAgentsOptions AddAIAgentFactory( + this DurableAgentsOptions options, + string name, + Func factory, + bool enableHttpTrigger, + bool enableMcpToolTrigger) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(name); + ArgumentNullException.ThrowIfNull(factory); + + FunctionsAgentOptions agentOptions = new(); + agentOptions.HttpTrigger.IsEnabled = enableHttpTrigger; + agentOptions.McpToolTrigger.IsEnabled = enableMcpToolTrigger; + + options.AddAIAgentFactory(name, factory); + s_agentOptions[name] = agentOptions; + return options; + } + + /// + /// Builds the agentOptions used for dependency injection (read-only copy). + /// + internal static IReadOnlyDictionary GetAgentOptionsSnapshot() + { + return new Dictionary(s_agentOptions, StringComparer.OrdinalIgnoreCase); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableTaskClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableTaskClientExtensions.cs new file mode 100644 index 0000000000..7628a3f3bf --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableTaskClientExtensions.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.DurableTask; +using Microsoft.Azure.Functions.Worker; +using Microsoft.DurableTask.Client; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.Agents.AI.Hosting.AzureFunctions; + +/// +/// Extension methods for the class. +/// +public static class DurableTaskClientExtensions +{ + /// + /// Converts a to a durable agent proxy. + /// + /// The to convert. + /// The for the current function invocation. + /// The name of the agent. + /// A durable agent proxy. + /// Thrown when or is null. + /// Thrown when is null or empty. + public static AIAgent AsDurableAgentProxy( + this DurableTaskClient durableClient, + FunctionContext context, + string agentName) + { + ArgumentNullException.ThrowIfNull(durableClient); + ArgumentNullException.ThrowIfNull(context); + ArgumentException.ThrowIfNullOrEmpty(agentName); + + DefaultDurableAgentClient agentClient = ActivatorUtilities.CreateInstance( + context.InstanceServices, + durableClient); + + return new DurableAIAgentProxy(agentName, agentClient); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsAgentOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsAgentOptions.cs new file mode 100644 index 0000000000..6ead7d8be5 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsAgentOptions.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Hosting.AzureFunctions; + +/// +/// Provides configuration options for enabling and customizing function triggers for an agent. +/// +public sealed class FunctionsAgentOptions +{ + /// + /// Gets or sets the configuration options for the HTTP trigger endpoint. + /// + public HttpTriggerOptions HttpTrigger { get; set; } = new(false); + + /// + /// Gets or sets the options used to configure the MCP tool trigger behavior. + /// + public McpToolTriggerOptions McpToolTrigger { get; set; } = new(false); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsApplicationBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsApplicationBuilderExtensions.cs new file mode 100644 index 0000000000..e13c6008ea --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsApplicationBuilderExtensions.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.DurableTask; +using Microsoft.Azure.Functions.Worker.Builder; +using Microsoft.Azure.Functions.Worker.Core.FunctionMetadata; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; + +namespace Microsoft.Agents.AI.Hosting.AzureFunctions; + +/// +/// Extension methods for the class. +/// +public static class FunctionsApplicationBuilderExtensions +{ + /// + /// Configures the application to use durable agents with a builder pattern. + /// + /// The functions application builder. + /// A delegate to configure the durable agents. + /// The functions application builder. + public static FunctionsApplicationBuilder ConfigureDurableAgents( + this FunctionsApplicationBuilder builder, + Action configure) + { + ArgumentNullException.ThrowIfNull(configure); + + // The main agent services registration is done in Microsoft.DurableTask.Agents. + builder.Services.ConfigureDurableAgents(configure); + + builder.Services.TryAddSingleton(_ => + new DefaultFunctionsAgentOptionsProvider(DurableAgentsOptionsExtensions.GetAgentOptionsSnapshot())); + + builder.Services.AddSingleton(); + + // Handling of built-in function execution for Agent HTTP, MCP tool, or Entity invocations. + builder.UseWhen(static context => + string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunAgentHttpFunctionEntryPoint, StringComparison.Ordinal) || + string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunAgentMcpToolFunctionEntryPoint, StringComparison.Ordinal) || + string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunAgentEntityFunctionEntryPoint, StringComparison.Ordinal)); + builder.Services.AddSingleton(); + + return builder; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/HttpTriggerOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/HttpTriggerOptions.cs new file mode 100644 index 0000000000..2a750c3ae5 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/HttpTriggerOptions.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Hosting.AzureFunctions; + +/// +/// Represents configuration options for the HTTP trigger for an agent. +/// +/// +/// Initializes a new instance of the class. +/// +/// Indicates whether the HTTP trigger is enabled for the agent. +public sealed class HttpTriggerOptions(bool isEnabled) +{ + /// + /// Gets or sets a value indicating whether the HTTP trigger is enabled for the agent. + /// + public bool IsEnabled { get; set; } = isEnabled; +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/IFunctionsAgentOptionsProvider.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/IFunctionsAgentOptionsProvider.cs new file mode 100644 index 0000000000..347b4242a3 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/IFunctionsAgentOptionsProvider.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.Agents.AI.Hosting.AzureFunctions; + +/// +/// Provides access to function trigger options for agents in the Azure Functions hosting environment. +/// +internal interface IFunctionsAgentOptionsProvider +{ + /// + /// Attempts to get trigger options for the specified agent. + /// + /// The agent name. + /// The resulting options if found. + /// True if options exist; otherwise false. + bool TryGet(string agentName, [NotNullWhen(true)] out FunctionsAgentOptions? options); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Logs.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Logs.cs new file mode 100644 index 0000000000..c49d2b39df --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Logs.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.Logging; + +namespace Microsoft.Agents.AI.Hosting.AzureFunctions; + +internal static partial class Logs +{ + [LoggerMessage( + EventId = 100, + Level = LogLevel.Information, + Message = "Transforming function metadata to add durable agent functions. Initial function count: {FunctionCount}")] + public static partial void LogTransformingFunctionMetadata(this ILogger logger, int functionCount); + + [LoggerMessage( + EventId = 101, + Level = LogLevel.Information, + Message = "Registering {TriggerType} function for agent '{AgentName}'")] + public static partial void LogRegisteringTriggerForAgent(this ILogger logger, string agentName, string triggerType); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/McpToolTriggerOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/McpToolTriggerOptions.cs new file mode 100644 index 0000000000..8e729f6840 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/McpToolTriggerOptions.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Hosting.AzureFunctions; + +/// +/// This class provides configuration options for the MCP tool trigger for an agent. +/// +/// +/// A value indicating whether the MCP tool trigger is enabled for the agent. +/// Set to to enable the trigger; otherwise, . +/// +public sealed class McpToolTriggerOptions(bool isEnabled) +{ + /// + /// Gets or sets a value indicating whether MCP tool trigger is enabled for the agent. + /// + public bool IsEnabled { get; set; } = isEnabled; +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Microsoft.Agents.AI.Hosting.AzureFunctions.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Microsoft.Agents.AI.Hosting.AzureFunctions.csproj new file mode 100644 index 0000000000..bb9ccc6ca0 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Microsoft.Agents.AI.Hosting.AzureFunctions.csproj @@ -0,0 +1,59 @@ + + + + $(ProjectsCoreTargetFrameworks) + $(ProjectsDebugCoreTargetFrameworks) + enable + + $(NoWarn);CA2007 + + + + + + + Azure Functions extensions for Microsoft Agent Framework + Provides durable agent hosting and orchestration support for Microsoft Agent Framework workloads. + README.md + + + + + + + + + + + + + + + + + + + + + + + + + + + + + <_Parameter1>Microsoft.Azure.Functions.Extensions.Mcp + <_Parameter2>1.0.0 + + <_Parameter3>true + <_Parameter3_IsLiteral>true + + + diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Middlewares/BuiltInFunctionExecutionMiddleware.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Middlewares/BuiltInFunctionExecutionMiddleware.cs new file mode 100644 index 0000000000..3dc1a58943 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Middlewares/BuiltInFunctionExecutionMiddleware.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Invocation; +using Microsoft.Azure.Functions.Worker.Middleware; + +namespace Microsoft.Agents.AI.Hosting.AzureFunctions; + +/// +/// This middleware sets a custom function executor for invocation of functions that have the built-in method as the entrypoint. +/// +internal sealed class BuiltInFunctionExecutionMiddleware(BuiltInFunctionExecutor builtInFunctionExecutor) + : IFunctionsWorkerMiddleware +{ + private readonly BuiltInFunctionExecutor _builtInFunctionExecutor = builtInFunctionExecutor; + + public async Task Invoke(FunctionContext context, FunctionExecutionDelegate next) + { + // We set our custom function executor for this invocation. + context.Features.Set(this._builtInFunctionExecutor); + + await next(context); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/README.md b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/README.md new file mode 100644 index 0000000000..4e819e5985 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/README.md @@ -0,0 +1,177 @@ +# Microsoft.Agents.AI.Hosting.AzureFunctions + +This package adds Azure Functions integration and serverless hosting for Microsoft Agent Framework on Azure Functions. It builds upon the `Microsoft.Agents.AI.DurableTask` package to provide the following capabilities: + +- Stateful, durable execution of agents in distributed, serverless environments +- Automatic conversation history management in supported [Durable Functions backends](https://learn.microsoft.com/azure/azure-functions/durable/durable-functions-storage-providers) +- Long-running agent workflows as "durable orchestrator" functions +- Tools and [dashboards](https://learn.microsoft.com/azure/azure-functions/durable/durable-task-scheduler/durable-task-scheduler-dashboard) for managing and monitoring agents and agent workflows + +## Install the package + +From the command-line: + +```bash +dotnet add package Microsoft.Agents.AI.Hosting.AzureFunctions +``` + +Or directly in your project file: + +```xml + + + +``` + +## 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/) in the [Microsoft Agent Framework GitHub repository](https://github.com/microsoft/agent-framework). + +### Hosting single agents + +This package provides a `ConfigureDurableAgents` extension method on the `FunctionsApplicationBuilder` class to configure the application to host Microsoft Agent Framework agents. These hosted agents are automatically registered as durable entities with the Durable Task runtime and can be invoked via HTTP or Durable Task orchestrator functions. + +```csharp +// Create agents using the standard Microsoft Agent Framework. +// Invocable via HTTP via http://localhost:7071/api/agents/SpamDetectionAgent/run +AIAgent spamDetector = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()) + .GetChatClient(deploymentName) + .CreateAIAgent( + instructions: "You are a spam detection assistant that identifies spam emails.", + name: "SpamDetectionAgent"); + +AIAgent emailAssistant = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()) + .GetChatClient(deploymentName) + .CreateAIAgent( + instructions: "You are an email assistant that helps users draft responses to emails with professionalism.", + name: "EmailAssistantAgent"); + +// Configure the Functions application to host the agents. +using IHost app = FunctionsApplication + .CreateBuilder(args) + .ConfigureFunctionsWebApplication() + .ConfigureDurableAgents(options => + { + options.AddAIAgent(spamDetector); + options.AddAIAgent(emailAssistant); + }) + .Build(); +app.Run(); +``` + +By default, each agent can be invoked via a built-in HTTP trigger function at the route `http[s]://[host]/api/agents/{agentName}/run`. + +### Orchestrating hosted agents + +This package also provides a set of extension methods such as `GetAgent` on the [`TaskOrchestrationContext`](https://learn.microsoft.com/dotnet/api/microsoft.durabletask.taskorchestrationcontext) class for interacting with hosted agents within orchestrations. + +```csharp +[Function(nameof(SpamDetectionOrchestration))] +public static async Task SpamDetectionOrchestration( + [OrchestrationTrigger] TaskOrchestrationContext context) +{ + Email email = context.GetInput() ?? 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 spamDetectionResponse = await spamDetectionAgent.RunAsync( + 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(nameof(HandleSpamEmail), result.Reason); + } + else + { + // Generate and send response for legitimate email + DurableAIAgent emailAssistantAgent = context.GetAgent("EmailAssistantAgent"); + AgentThread emailThread = emailAssistantAgent.GetNewThread(); + + AgentRunResponse emailAssistantResponse = await emailAssistantAgent.RunAsync( + 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(nameof(SendEmail), emailResponse.Response); + } +} +``` + +### Scheduling orchestrations from custom code tools + +Agents can also schedule and interact with orchestrations from custom code tools. This is useful for long-running tool use cases where orchestrations need to be executed in the context of the agent. + +The `DurableAgentContext.Current` *AsyncLocal* property provides access to the current agent context, which can be used to schedule and interact with orchestrations. + +```csharp +class Tools +{ + [Description("Starts a content generation workflow and returns the instance ID for tracking.")] + public string StartContentGenerationWorkflow( + [Description("The topic for content generation")] string topic) + { + // ContentGenerationWorkflow is an orchestrator function defined in the same project. + string instanceId = DurableAgentContext.Current.ScheduleNewOrchestration( + name: nameof(ContentGenerationWorkflow), + input: topic); + + // Return the instance ID so that it gets added to the LLM context. + return instanceId; + } + + [Description("Gets the status of a content generation workflow.")] + public async Task GetContentGenerationStatus( + [Description("The instance ID of the workflow to check")] string instanceId, + [Description("Whether to include detailed information")] bool includeDetails = true) + { + OrchestrationMetadata? status = await DurableAgentContext.Current.Client.GetOrchestrationStatusAsync( + instanceId, + includeDetails); + return status ?? throw new InvalidOperationException($"Workflow instance '{instanceId}' not found."); + } +} +``` + +These tools are registered with the agent using the `tools` parameter when creating the agent. + +```csharp +Tools tools = new(); +AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()) + .GetChatClient(deploymentName) + .CreateAIAgent( + instructions: "You are a content generation assistant that helps users generate content.", + name: "ContentGenerationAgent", + tools: [ + AIFunctionFactory.Create(tools.StartContentGenerationWorkflow), + AIFunctionFactory.Create(tools.GetContentGenerationStatus) + ]); + +using IHost app = FunctionsApplication + .CreateBuilder(args) + .ConfigureFunctionsWebApplication() + .ConfigureDurableAgents(options => options.AddAIAgent(agent)) + .Build(); +app.Run(); +``` + +## Feedback & Contributing + +We welcome feedback and contributions in [our GitHub repo](https://github.com/microsoft/agent-framework). diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/BackgroundJobRunner.cs b/dotnet/src/Microsoft.Agents.AI.Purview/BackgroundJobRunner.cs new file mode 100644 index 0000000000..d55c5a6a66 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/BackgroundJobRunner.cs @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Purview.Models.Jobs; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Agents.AI.Purview; + +/// +/// Service that runs jobs in background threads. +/// +internal sealed class BackgroundJobRunner +{ + private readonly IChannelHandler _channelHandler; + private readonly IPurviewClient _purviewClient; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The channel handler used to manage job channels. + /// The Purview client used to send requests to Purview. + /// The logger used to log information about background jobs. + /// The settings used to configure Purview client behavior. + public BackgroundJobRunner(IChannelHandler channelHandler, IPurviewClient purviewClient, ILogger logger, PurviewSettings purviewSettings) + { + this._channelHandler = channelHandler; + this._purviewClient = purviewClient; + this._logger = logger; + + for (int i = 0; i < purviewSettings.MaxConcurrentJobConsumers; i++) + { + this._channelHandler.AddRunner(async (Channel channel) => + { + await foreach (BackgroundJobBase job in channel.Reader.ReadAllAsync().ConfigureAwait(false)) + { + try + { + await this.RunJobAsync(job).ConfigureAwait(false); + } + catch (Exception e) when ( + !(e is OperationCanceledException) && + !(e is SystemException)) + { + this._logger.LogError(e, "Error running background job {BackgroundJobError}.", e.Message); + } + } + }); + } + } + + /// + /// Runs a job. + /// + /// The job to run. + /// A task representing the job. + private async Task RunJobAsync(BackgroundJobBase job) + { + switch (job) + { + case ProcessContentJob processContentJob: + _ = await this._purviewClient.ProcessContentAsync(processContentJob.Request, CancellationToken.None).ConfigureAwait(false); + break; + case ContentActivityJob contentActivityJob: + _ = await this._purviewClient.SendContentActivitiesAsync(contentActivityJob.Request, CancellationToken.None).ConfigureAwait(false); + break; + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/CacheProvider.cs b/dotnet/src/Microsoft.Agents.AI.Purview/CacheProvider.cs new file mode 100644 index 0000000000..472b53c50b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/CacheProvider.cs @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Purview.Serialization; +using Microsoft.Extensions.Caching.Distributed; + +namespace Microsoft.Agents.AI.Purview; + +/// +/// Manages caching of values. +/// +internal sealed class CacheProvider : ICacheProvider +{ + private readonly IDistributedCache _cache; + private readonly PurviewSettings _purviewSettings; + + /// + /// Create a new instance of the class. + /// + /// The cache where the data is stored. + /// The purview integration settings. + public CacheProvider(IDistributedCache cache, PurviewSettings purviewSettings) + { + this._cache = cache; + this._purviewSettings = purviewSettings; + } + + /// + /// Get a value from the cache. + /// + /// The type of the key in the cache. Used for serialization. + /// The type of the value in the cache. Used for serialization. + /// The key to look up in the cache. + /// A cancellation token for the async operation. + /// The value in the cache. Null or default if no value is present. + public async Task GetAsync(TKey key, CancellationToken cancellationToken) + { + JsonTypeInfo keyTypeInfo = (JsonTypeInfo)PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(TKey)); + string serializedKey = JsonSerializer.Serialize(key, keyTypeInfo); + byte[]? data = await this._cache.GetAsync(serializedKey, cancellationToken).ConfigureAwait(false); + if (data == null) + { + return default; + } + + JsonTypeInfo valueTypeInfo = (JsonTypeInfo)PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(TValue)); + + return JsonSerializer.Deserialize(data, valueTypeInfo); + } + + /// + /// Set a value in the cache. + /// + /// The type of the key in the cache. Used for serialization. + /// The type of the value in the cache. Used for serialization. + /// The key to identify the cache entry. + /// The value to cache. + /// A cancellation token for the async operation. + /// A task for the async operation. + public Task SetAsync(TKey key, TValue value, CancellationToken cancellationToken) + { + JsonTypeInfo keyTypeInfo = (JsonTypeInfo)PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(TKey)); + string serializedKey = JsonSerializer.Serialize(key, keyTypeInfo); + JsonTypeInfo valueTypeInfo = (JsonTypeInfo)PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(TValue)); + byte[] serializedValue = JsonSerializer.SerializeToUtf8Bytes(value, valueTypeInfo); + + DistributedCacheEntryOptions cacheOptions = new() { AbsoluteExpirationRelativeToNow = this._purviewSettings.CacheTTL }; + + return this._cache.SetAsync(serializedKey, serializedValue, cacheOptions, cancellationToken); + } + + /// + /// Removes a value from the cache. + /// + /// The type of the key. + /// The key to identify the cache entry. + /// The cancellation token for the async operation. + /// A task for the async operation. + public Task RemoveAsync(TKey key, CancellationToken cancellationToken) + { + JsonTypeInfo keyTypeInfo = (JsonTypeInfo)PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(TKey)); + string serializedKey = JsonSerializer.Serialize(key, keyTypeInfo); + + return this._cache.RemoveAsync(serializedKey, cancellationToken); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/ChannelHandler.cs b/dotnet/src/Microsoft.Agents.AI.Purview/ChannelHandler.cs new file mode 100644 index 0000000000..ed3111fb3f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/ChannelHandler.cs @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading.Channels; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Purview.Models.Jobs; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Agents.AI.Purview; + +/// +/// Handler class for background job management. +/// +internal class ChannelHandler : IChannelHandler +{ + private readonly Channel _jobChannel; + private readonly List _channelListeners; + private readonly ILogger _logger; + private readonly PurviewSettings _purviewSettings; + + /// + /// Creates a new instance of JobHandler. + /// + /// The purview integration settings. + /// The logger used for logging job information. + /// The job channel used for queuing and reading background jobs. + public ChannelHandler(PurviewSettings purviewSettings, ILogger logger, Channel jobChannel) + { + this._purviewSettings = purviewSettings; + this._logger = logger; + this._jobChannel = jobChannel; + + this._channelListeners = new List(this._purviewSettings.MaxConcurrentJobConsumers); + } + + /// + public void QueueJob(BackgroundJobBase job) + { + try + { + if (job == null) + { + throw new PurviewJobException("Cannot queue null job."); + } + + if (this._channelListeners.Count == 0) + { + this._logger.LogWarning("No listeners are available to process the job."); + throw new PurviewJobException("No listeners are available to process the job."); + } + + bool canQueue = this._jobChannel.Writer.TryWrite(job); + + if (!canQueue) + { + int jobCount = this._jobChannel.Reader.Count; + this._logger.LogError("Could not queue a job for background processing."); + + if (this._jobChannel.Reader.Completion.IsCompleted) + { + throw new PurviewJobException("Job channel is closed or completed. Cannot queue job."); + } + else if (jobCount >= this._purviewSettings.PendingBackgroundJobLimit) + { + throw new PurviewJobLimitExceededException($"Job queue is full. Current pending jobs: {jobCount}. Maximum number of queued jobs: {this._purviewSettings.PendingBackgroundJobLimit}"); + } + else + { + throw new PurviewJobException("Could not queue job for background processing."); + } + } + } + catch (Exception e) + { + if (this._purviewSettings.IgnoreExceptions) + { + this._logger.LogError(e, "Error queuing job: {ExceptionMessage}", e.Message); + } + else + { + throw; + } + } + } + + /// + public void AddRunner(Func, Task> runnerTask) + { + this._channelListeners.Add(Task.Run(async () => await runnerTask(this._jobChannel).ConfigureAwait(false))); + } + + /// + public async Task StopAndWaitForCompletionAsync() + { + this._jobChannel.Writer.Complete(); + await this._jobChannel.Reader.Completion.ConfigureAwait(false); + await Task.WhenAll(this._channelListeners).ConfigureAwait(false); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Constants.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Constants.cs new file mode 100644 index 0000000000..610f0748bc --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Constants.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Purview; + +/// +/// Shared constants for the Purview service. +/// +internal static class Constants +{ + /// + /// The odata type property name used in requests and responses. + /// + public const string ODataTypePropertyName = "@odata.type"; + + /// + /// The OData Graph namespace used for odata types. + /// + public const string ODataGraphNamespace = "microsoft.graph"; + + /// + /// The name of the property that contains the conversation id. + /// + public const string ConversationId = "conversationId"; + + /// + /// The name of the property that contains the user id. + /// + public const string UserId = "userId"; +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewAuthenticationException.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewAuthenticationException.cs new file mode 100644 index 0000000000..83f80f3eb8 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewAuthenticationException.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.AI.Purview; + +/// +/// Exception for authentication errors related to Purview. +/// +public class PurviewAuthenticationException : PurviewException +{ + /// + public PurviewAuthenticationException(string message) + : base(message) + { + } + + /// + public PurviewAuthenticationException() : base() + { + } + + /// + public PurviewAuthenticationException(string? message, Exception? innerException) : base(message, innerException) + { + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewException.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewException.cs new file mode 100644 index 0000000000..36c859d9b1 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewException.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.AI.Purview; + +/// +/// General base exception type for Purview service errors. +/// +public class PurviewException : Exception +{ + /// + public PurviewException(string message) + : base(message) + { + } + + /// + public PurviewException() : base() + { + } + + /// + public PurviewException(string? message, Exception? innerException) : base(message, innerException) + { + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewJobException.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewJobException.cs new file mode 100644 index 0000000000..1737b70f1f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewJobException.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.AI.Purview; + +/// +/// Represents errors that occur during the execution of a Purview job. +/// +/// This exception is thrown when a Purview job encounters an error that prevents it from completing successfully. +internal class PurviewJobException : PurviewException +{ + /// + public PurviewJobException(string message) : base(message) + { + } + + /// + public PurviewJobException() : base() + { + } + + /// + public PurviewJobException(string? message, Exception? innerException) : base(message, innerException) + { + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewJobLimitExceededException.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewJobLimitExceededException.cs new file mode 100644 index 0000000000..7560000a55 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewJobLimitExceededException.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.AI.Purview; + +/// +/// Represents an exception that is thrown when the maximum number of concurrent Purview jobs has been exceeded. +/// +/// This exception indicates that the Purview service has reached its limit for concurrent job executions. +internal class PurviewJobLimitExceededException : PurviewJobException +{ + /// + public PurviewJobLimitExceededException(string message) : base(message) + { + } + + /// + public PurviewJobLimitExceededException() : base() + { + } + + /// + public PurviewJobLimitExceededException(string? message, Exception? innerException) : base(message, innerException) + { + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewPaymentRequiredException.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewPaymentRequiredException.cs new file mode 100644 index 0000000000..28a6c70323 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewPaymentRequiredException.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.AI.Purview; + +/// +/// Exception for payment required errors related to Purview. +/// +public class PurviewPaymentRequiredException : PurviewException +{ + /// + public PurviewPaymentRequiredException(string message) : base(message) + { + } + + /// + public PurviewPaymentRequiredException() : base() + { + } + + /// + public PurviewPaymentRequiredException(string? message, Exception? innerException) : base(message, innerException) + { + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewRateLimitException.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewRateLimitException.cs new file mode 100644 index 0000000000..71483886d2 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewRateLimitException.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.AI.Purview; + +/// +/// Exception for rate limit exceeded errors from Purview service. +/// +public class PurviewRateLimitException : PurviewException +{ + /// + public PurviewRateLimitException(string message) + : base(message) + { + } + + /// + public PurviewRateLimitException() : base() + { + } + + /// + public PurviewRateLimitException(string? message, Exception? innerException) : base(message, innerException) + { + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewRequestException.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewRequestException.cs new file mode 100644 index 0000000000..a34fad6ce4 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewRequestException.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Net; + +namespace Microsoft.Agents.AI.Purview; + +/// +/// Exception for general http request errors from Purview. +/// +public class PurviewRequestException : PurviewException +{ + /// + /// HTTP status code returned by the Purview service. + /// + public HttpStatusCode StatusCode { get; } + + /// + public PurviewRequestException(HttpStatusCode statusCode, string endpointName) + : base($"Failed to call {endpointName}. Status code: {statusCode}") + { + this.StatusCode = statusCode; + } + + /// + public PurviewRequestException(string message) + : base(message) + { + } + + /// + public PurviewRequestException() : base() + { + } + + /// + public PurviewRequestException(string? message, Exception? innerException) : base(message, innerException) + { + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/ICacheProvider.cs b/dotnet/src/Microsoft.Agents.AI.Purview/ICacheProvider.cs new file mode 100644 index 0000000000..6d6dad527c --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/ICacheProvider.cs @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.Purview; + +/// +/// Manages caching of values. +/// +internal interface ICacheProvider +{ + /// + /// Get a value from the cache. + /// + /// The type of the key in the cache. Used for serialization. + /// The type of the value in the cache. Used for serialization. + /// The key to look up in the cache. + /// A cancellation token for the async operation. + /// The value in the cache. Null or default if no value is present. + Task GetAsync(TKey key, CancellationToken cancellationToken); + + /// + /// Set a value in the cache. + /// + /// The type of the key in the cache. Used for serialization. + /// The type of the value in the cache. Used for serialization. + /// The key to identify the cache entry. + /// The value to cache. + /// A cancellation token for the async operation. + /// A task for the async operation. + Task SetAsync(TKey key, TValue value, CancellationToken cancellationToken); + + /// + /// Removes a value from the cache. + /// + /// The type of the key. + /// The key to identify the cache entry. + /// The cancellation token for the async operation. + /// A task for the async operation. + Task RemoveAsync(TKey key, CancellationToken cancellationToken); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/IChannelHandler.cs b/dotnet/src/Microsoft.Agents.AI.Purview/IChannelHandler.cs new file mode 100644 index 0000000000..d8593abd48 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/IChannelHandler.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Channels; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Purview.Models.Jobs; + +namespace Microsoft.Agents.AI.Purview; + +/// +/// Interface for a class that controls background job processing. +/// +internal interface IChannelHandler +{ + /// + /// Queue a job for background processing. + /// + /// The job queued for background processing. + void QueueJob(BackgroundJobBase job); + + /// + /// Add a runner to the channel handler. + /// + /// The runner task used to process jobs. + void AddRunner(Func, Task> runnerTask); + + /// + /// Stop the channel and wait for all runners to complete + /// + /// A task representing the job. + Task StopAndWaitForCompletionAsync(); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/IPurviewClient.cs b/dotnet/src/Microsoft.Agents.AI.Purview/IPurviewClient.cs new file mode 100644 index 0000000000..00de9051ef --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/IPurviewClient.cs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Purview.Models.Common; +using Microsoft.Agents.AI.Purview.Models.Requests; +using Microsoft.Agents.AI.Purview.Models.Responses; + +namespace Microsoft.Agents.AI.Purview; + +/// +/// Defines methods for interacting with the Purview service, including content processing, +/// protection scope management, and activity tracking. +/// +/// This interface provides methods to interact with various Purview APIs. It includes processing content, managing protection +/// scopes, and sending content activity data. Implementations of this interface are expected to handle communication +/// with the Purview service and manage any necessary authentication or error handling. +internal interface IPurviewClient +{ + /// + /// Get user info from auth token. + /// + /// The cancellation token used to cancel async processing. + /// The default tenant id used to retrieve the token and its info. + /// The token info from the token. + /// Throw if the token was invalid or could not be retrieved. + Task GetUserInfoFromTokenAsync(CancellationToken cancellationToken, string? tenantId = default); + + /// + /// Call ProcessContent API. + /// + /// The request containing the content to process. + /// The cancellation token used to cancel async processing. + /// The response from the Purview API. + /// Thrown for validation, auth, and network errors. + Task ProcessContentAsync(ProcessContentRequest request, CancellationToken cancellationToken); + + /// + /// Call user ProtectionScope API. + /// + /// The request containing the protection scopes metadata. + /// The cancellation token used to cancel async processing. + /// The protection scopes that apply to the data sent in the request. + /// Thrown for validation, auth, and network errors. + Task GetProtectionScopesAsync(ProtectionScopesRequest request, CancellationToken cancellationToken); + + /// + /// Call contentActivities API. + /// + /// The request containing the content metadata. Used to generate interaction records. + /// The cancellation token used to cancel async processing. + /// The response from the Purview API. + /// Thrown for validation, auth, and network errors. + Task SendContentActivitiesAsync(ContentActivitiesRequest request, CancellationToken cancellationToken); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/IScopedContentProcessor.cs b/dotnet/src/Microsoft.Agents.AI.Purview/IScopedContentProcessor.cs new file mode 100644 index 0000000000..059e7c4d2d --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/IScopedContentProcessor.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Purview.Models.Common; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Purview; + +/// +/// Orchestrates the processing of scoped content by combining protection scope, process content, and content activities operations. +/// +internal interface IScopedContentProcessor +{ + /// + /// Process a list of messages. + /// The list of messages should be a prompt or response. + /// + /// A list of objects sent to the agent or received from the agent.. + /// The thread where the messages were sent. + /// An activity to indicate prompt or response. + /// Purview settings containing tenant id, app name, etc. + /// The user who sent the prompt or is receiving the response. + /// Cancellation token. + /// A bool indicating if the request should be blocked and the user id of the user who made the request. + Task<(bool shouldBlock, string? userId)> ProcessMessagesAsync(IEnumerable messages, string? threadId, Activity activity, PurviewSettings purviewSettings, string? userId, CancellationToken cancellationToken); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Microsoft.Agents.AI.Purview.csproj b/dotnet/src/Microsoft.Agents.AI.Purview/Microsoft.Agents.AI.Purview.csproj new file mode 100644 index 0000000000..20eca86359 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Microsoft.Agents.AI.Purview.csproj @@ -0,0 +1,43 @@ + + + + $(ProjectsTargetFrameworks) + $(ProjectsDebugTargetFrameworks) + alpha + + + + true + true + true + + + + + + + + + + + + + + + + + + Microsoft.Agents.AI.Purview + Tools to connect generative AI apps to Microsoft Purview. + + + + + + + + + $(NoWarn);CA1812 + + + \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/AIAgentInfo.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/AIAgentInfo.cs new file mode 100644 index 0000000000..15c1fbab00 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/AIAgentInfo.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Info about an AI agent associated with the content. +/// +internal sealed class AIAgentInfo +{ + /// + /// Gets or sets agent id. + /// + [JsonPropertyName("identifier")] + public string? Identifier { get; set; } + + /// + /// Gets or sets agent name. + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// Gets or sets agent version. + /// + [JsonPropertyName("version")] + public string? Version { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/AIInteractionPlugin.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/AIInteractionPlugin.cs new file mode 100644 index 0000000000..d9b56f3911 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/AIInteractionPlugin.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Represents a plugin used in an AI interaction within the Purview SDK. +/// +internal sealed class AIInteractionPlugin +{ + /// + /// Gets or sets Plugin id. + /// + [JsonPropertyName("identifier")] + public string? Identifier { get; set; } + + /// + /// Gets or sets Plugin Name. + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// Gets or sets Plugin Version. + /// + [JsonPropertyName("version")] + public string? Version { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/AccessedResourceDetails.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/AccessedResourceDetails.cs new file mode 100644 index 0000000000..e9a18543c6 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/AccessedResourceDetails.cs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Information about a resource accessed during a conversation. +/// +internal sealed class AccessedResourceDetails +{ + /// + /// Resource ID. + /// + [JsonPropertyName("identifier")] + public string? Identifier { get; set; } + + /// + /// Resource name. + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// Resource URL. + /// + [JsonPropertyName("url")] + public string? Url { get; set; } + + /// + /// Sensitivity label id detected on the resource. + /// + [JsonPropertyName("labelId")] + public string? LabelId { get; set; } + + /// + /// Access type performed on the resource. + /// + [JsonPropertyName("accessType")] + public ResourceAccessType AccessType { get; set; } + + /// + /// Status of the access operation. + /// + [JsonPropertyName("status")] + public ResourceAccessStatus Status { get; set; } + + /// + /// Indicates if cross prompt injection was detected. + /// + [JsonPropertyName("isCrossPromptInjectionDetected")] + public bool? IsCrossPromptInjectionDetected { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/Activity.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/Activity.cs new file mode 100644 index 0000000000..5f9fdeb9d7 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/Activity.cs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Runtime.Serialization; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Activity definitions +/// +[DataContract] +[JsonConverter(typeof(JsonStringEnumConverter))] +internal enum Activity : int +{ + /// + /// Unknown activity + /// + [EnumMember(Value = "unknown")] + Unknown = 0, + + /// + /// Upload text + /// + [EnumMember(Value = "uploadText")] + UploadText = 1, + + /// + /// Upload file + /// + [EnumMember(Value = "uploadFile")] + UploadFile = 2, + + /// + /// Download text + /// + [EnumMember(Value = "downloadText")] + DownloadText = 3, + + /// + /// Download file + /// + [EnumMember(Value = "downloadFile")] + DownloadFile = 4, +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ActivityMetadata.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ActivityMetadata.cs new file mode 100644 index 0000000000..deefc24560 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ActivityMetadata.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Runtime.Serialization; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Request for metadata information +/// +[DataContract] +internal sealed class ActivityMetadata +{ + /// + /// Initializes a new instance of the class. + /// + /// The activity performed with the content. + public ActivityMetadata(Activity activity) + { + this.Activity = activity; + } + + /// + /// The activity performed with the content. + /// + [DataMember] + [JsonConverter(typeof(JsonStringEnumConverter))] + [JsonPropertyName("activity")] + public Activity Activity { get; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ClassificationErrorBase.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ClassificationErrorBase.cs new file mode 100644 index 0000000000..e52bf9ebb4 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ClassificationErrorBase.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Base error contract returned when some exception occurs. +/// +[JsonDerivedType(typeof(ProcessingError))] +internal class ClassificationErrorBase +{ + /// + /// Gets or sets the error code. + /// + [JsonPropertyName("code")] + public string? ErrorCode { get; set; } + + /// + /// Gets or sets the message. + /// + [JsonPropertyName("message")] + public string? Message { get; set; } + + /// + /// Gets or sets target of error. + /// + [JsonPropertyName("target")] + public string? Target { get; set; } + + /// + /// Gets or sets an object containing more specific information than the current object about the error. + /// It can't be a Dictionary because OData will make ClassificationErrorBase open type. It's not expected behavior. + /// + [JsonPropertyName("innerError")] + public ClassificationInnerError? InnerError { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ClassificationInnerError.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ClassificationInnerError.cs new file mode 100644 index 0000000000..1133529188 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ClassificationInnerError.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Inner classification error. +/// +internal sealed class ClassificationInnerError +{ + /// + /// Gets or sets date of error. + /// + [JsonPropertyName("date")] + public DateTime? Date { get; set; } + + /// + /// Gets or sets error code. + /// + [JsonPropertyName("code")] + public string? ErrorCode { get; set; } + + /// + /// Gets or sets client request ID. + /// + [JsonPropertyName("clientRequestId")] + public string? ClientRequestId { get; set; } + + /// + /// Gets or sets Activity ID. + /// + [JsonPropertyName("activityId")] + public string? ActivityId { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ContentBase.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ContentBase.cs new file mode 100644 index 0000000000..9619d27fc8 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ContentBase.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Base class for content items to be processed by the Purview SDK. +/// +[JsonDerivedType(typeof(PurviewTextContent))] +[JsonDerivedType(typeof(PurviewBinaryContent))] +internal abstract class ContentBase : GraphDataTypeBase +{ + /// + /// Creates a new instance of the class. + /// + /// The graph data type of the content. + public ContentBase(string dataType) : base(dataType) + { + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ContentProcessingErrorType.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ContentProcessingErrorType.cs new file mode 100644 index 0000000000..3d57a02aee --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ContentProcessingErrorType.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Type of error that occurred during content processing. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +internal enum ContentProcessingErrorType +{ + /// + /// Error is transient. + /// + Transient, + + /// + /// Error is permanent. + /// + Permanent, + + /// + /// Unknown future value placeholder. + /// + UnknownFutureValue +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ContentToProcess.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ContentToProcess.cs new file mode 100644 index 0000000000..9e2e5824f3 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ContentToProcess.cs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Runtime.Serialization; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Content to be processed by process content. +/// +internal sealed class ContentToProcess +{ + /// + /// Creates a new instance of ContentToProcess. + /// + /// The content to send and its associated ids. + /// Metadata about the activity performed with the content. + /// Metadata about the device that produced the content. + /// Metadata about the application integrating with Purview. + /// Metadata about the application being protected by Purview. + public ContentToProcess( + List contentEntries, + ActivityMetadata activityMetadata, + DeviceMetadata deviceMetadata, + IntegratedAppMetadata integratedAppMetadata, + ProtectedAppMetadata protectedAppMetadata) + { + this.ContentEntries = contentEntries; + this.ActivityMetadata = activityMetadata; + this.DeviceMetadata = deviceMetadata; + this.IntegratedAppMetadata = integratedAppMetadata; + this.ProtectedAppMetadata = protectedAppMetadata; + } + + /// + /// Gets or sets the content entries. + /// List of activities supported by caller. It is used to trim response to activities interesting to the caller. + /// + [JsonPropertyName("contentEntries")] + public List ContentEntries { get; set; } + + /// + /// Activity metadata + /// + [DataMember] + [JsonPropertyName("activityMetadata")] + public ActivityMetadata ActivityMetadata { get; set; } + + /// + /// Device metadata + /// + [DataMember] + [JsonPropertyName("deviceMetadata")] + public DeviceMetadata DeviceMetadata { get; set; } + + /// + /// Integrated app metadata + /// + [DataMember] + [JsonPropertyName("integratedAppMetadata")] + public IntegratedAppMetadata IntegratedAppMetadata { get; set; } + + /// + /// Protected app metadata + /// + [DataMember] + [JsonPropertyName("protectedAppMetadata")] + public ProtectedAppMetadata ProtectedAppMetadata { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/DeviceMetadata.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/DeviceMetadata.cs new file mode 100644 index 0000000000..3a60686be3 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/DeviceMetadata.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Endpoint device Metdata +/// +internal sealed class DeviceMetadata +{ + /// + /// Device type + /// + [JsonPropertyName("deviceType")] + public string? DeviceType { get; set; } + + /// + /// The ip address of the device. + /// + [JsonPropertyName("ipAddress")] + public string? IpAddress { get; set; } + + /// + /// OS specifications + /// + [JsonPropertyName("operatingSystemSpecifications")] + public OperatingSystemSpecifications? OperatingSystemSpecifications { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/DlpAction.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/DlpAction.cs new file mode 100644 index 0000000000..8eda013588 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/DlpAction.cs @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Defines all the actions for DLP. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +internal enum DlpAction +{ + /// + /// The DLP action to notify user. + /// + NotifyUser, + + /// + /// The DLP action is block. + /// + BlockAccess, + + /// + /// The DLP action to apply restrictions on device. + /// + DeviceRestriction, + + /// + /// The DLP action to apply restrictions on browsers. + /// + BrowserRestriction, + + /// + /// The DLP action to generate an alert + /// + GenerateAlert, + + /// + /// The DLP action to generate an incident report + /// + GenerateIncidentReportAction, + + /// + /// The DLP action to block anonymous link access in SPO + /// + SPBlockAnonymousAccess, + + /// + /// DLP Action to disallow guest access in SPO + /// + SPRuntimeAccessControl, + + /// + /// DLP No Op action for NotifyUser. Used in Block Access V2 rule + /// + SPSharingNotifyUser, + + /// + /// DLP No Op action for GIR. Used in Block Access V2 rule + /// + SPSharingGenerateIncidentReport, + + /// + /// Restrict access action for data in motion scenarios. + /// Advanced version of BlockAccess which can take both enforced restriction mode (Audit, Block, etc.) + /// and action triggers (Print, SaveToLocal, etc.) as parameters. + /// + RestrictAccess, +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/DlpActionInfo.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/DlpActionInfo.cs new file mode 100644 index 0000000000..a5846acadc --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/DlpActionInfo.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Base class to define DLP Actions. +/// +internal sealed class DlpActionInfo +{ + /// + /// Gets or sets the type of the DLP action. + /// + [JsonPropertyName("action")] + public DlpAction Action { get; set; } + + /// + /// The type of restriction action to take. + /// + [JsonPropertyName("restrictionAction")] + public RestrictionAction? RestrictionAction { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ErrorDetails.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ErrorDetails.cs new file mode 100644 index 0000000000..dd79ee13ce --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ErrorDetails.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Represents the details of an error. +/// +internal sealed class ErrorDetails +{ + /// + /// Gets or sets the error code. + /// + [JsonPropertyName("code")] + public string? Code { get; set; } + + /// + /// Gets or sets the error message. + /// + [JsonPropertyName("message")] + public string? Message { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ExecutionMode.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ExecutionMode.cs new file mode 100644 index 0000000000..3fecfbb3f4 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ExecutionMode.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Request execution mode +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +internal enum ExecutionMode : int +{ + /// + /// Evaluate inline. + /// + EvaluateInline = 1, + + /// + /// Evaluate offline. + /// + EvaluateOffline = 2, + + /// + /// Unknown future value. + /// + UnknownFutureValue = 3 +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/GraphDataTypeBase.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/GraphDataTypeBase.cs new file mode 100644 index 0000000000..b4334fdb43 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/GraphDataTypeBase.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Base class for all graph data types used in the Purview SDK. +/// +internal abstract class GraphDataTypeBase +{ + /// + /// Create a new instance of the class. + /// + /// The data type of the graph object. + public GraphDataTypeBase(string dataType) + { + this.DataType = dataType; + } + + /// + /// The @odata.type property name used in the JSON representation of the object. + /// + [JsonPropertyName(Constants.ODataTypePropertyName)] + public string DataType { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/IntegratedAppMetadata.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/IntegratedAppMetadata.cs new file mode 100644 index 0000000000..1a5e8b5e13 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/IntegratedAppMetadata.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Runtime.Serialization; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Request for metadata information +/// +[JsonDerivedType(typeof(ProtectedAppMetadata))] +internal class IntegratedAppMetadata +{ + /// + /// Application name + /// + [DataMember] + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// Application version + /// + [DataMember] + [JsonPropertyName("version")] + public string? Version { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/OperatingSystemSpecifications.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/OperatingSystemSpecifications.cs new file mode 100644 index 0000000000..3ea8837177 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/OperatingSystemSpecifications.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Operating System Specifications +/// +internal sealed class OperatingSystemSpecifications +{ + /// + /// OS platform + /// + [JsonPropertyName("operatingSystemPlatform")] + public string? OperatingSystemPlatform { get; set; } + + /// + /// OS version + /// + [JsonPropertyName("operatingSystemVersion")] + public string? OperatingSystemVersion { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PolicyBinding.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PolicyBinding.cs new file mode 100644 index 0000000000..9898f62e01 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PolicyBinding.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Represents user scoping information, i.e. which users are affected by the policy. +/// +internal sealed class PolicyBinding +{ + /// + /// Gets or sets the users to be included. + /// + [JsonPropertyName("inclusions")] + public ICollection? Inclusions { get; set; } + + /// + /// Gets or sets the users to be excluded. + /// Exclusions may not be present in the response, thus this property is nullable. + /// + [JsonPropertyName("exclusions")] + public ICollection? Exclusions { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PolicyLocation.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PolicyLocation.cs new file mode 100644 index 0000000000..c0a40974e5 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PolicyLocation.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Represents a location to which policy is applicable. +/// +internal sealed class PolicyLocation : GraphDataTypeBase +{ + /// + /// Creates a new instance of the class. + /// + /// The graph data type of the PolicyLocation object. + /// THe value of the policy location: app id, domain, etc. + public PolicyLocation(string dataType, string value) : base(dataType) + { + this.Value = value; + } + + /// + /// Gets or sets the applicable value for location. + /// + [JsonPropertyName("value")] + public string Value { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PolicyPivotProperty.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PolicyPivotProperty.cs new file mode 100644 index 0000000000..d56a374842 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PolicyPivotProperty.cs @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Runtime.Serialization; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Property for policy scoping response to aggregate on +/// +[DataContract] +[JsonConverter(typeof(JsonStringEnumConverter))] +internal enum PolicyPivotProperty : int +{ + /// + /// Unknown activity + /// + [EnumMember] + [JsonPropertyName("none")] + None = 0, + + /// + /// Pivot on Activity + /// + [EnumMember] + [JsonPropertyName("activity")] + Activity = 1, + + /// + /// Pivot on location + /// + [EnumMember] + [JsonPropertyName("location")] + Location = 2, + + /// + /// Pivot on location + /// + [EnumMember] + [JsonPropertyName("unknownFutureValue")] + UnknownFutureValue = 3, +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PolicyScope.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PolicyScope.cs new file mode 100644 index 0000000000..f00e941d35 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PolicyScope.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Represents a scope for policy protection. +/// +internal sealed class PolicyScopeBase +{ + /// + /// Gets or sets the locations to be protected, e.g. domains or URLs. + /// + [JsonPropertyName("locations")] + public ICollection? Locations { get; set; } + + /// + /// Gets or sets the activities to be protected, e.g. uploadText, downloadText. + /// + [JsonPropertyName("activities")] + public ProtectionScopeActivities Activities { get; set; } + + /// + /// Gets or sets how policy should be executed - fire-and-forget or wait for completion. + /// + [JsonPropertyName("executionMode")] + public ExecutionMode ExecutionMode { get; set; } + + /// + /// Gets or sets the enforcement actions to be taken on activities and locations from this scope. + /// There may be no actions in the response. + /// + [JsonPropertyName("policyActions")] + public ICollection? PolicyActions { get; set; } + + /// + /// Gets or sets information about policy applicability to a specific user. + /// + [JsonPropertyName("policyScope")] + public PolicyBinding? PolicyScope { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessContentMetadataBase.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessContentMetadataBase.cs new file mode 100644 index 0000000000..51f4936e82 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessContentMetadataBase.cs @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Base class for process content metadata. +/// +[JsonDerivedType(typeof(ProcessConversationMetadata))] +[JsonDerivedType(typeof(ProcessFileMetadata))] +internal abstract class ProcessContentMetadataBase : GraphDataTypeBase +{ + private const string ProcessConversationMetadataDataType = Constants.ODataGraphNamespace + ".processConversationMetadata"; + + /// + /// Creates a new instance of ProcessContentMetadataBase. + /// + /// The content that will be processed. + /// The unique identifier for the content. + /// Indicates if the content is truncated. + /// The name of the content. + public ProcessContentMetadataBase(ContentBase content, string identifier, bool isTruncated, string name) : base(ProcessConversationMetadataDataType) + { + this.Identifier = identifier; + this.IsTruncated = isTruncated; + this.Content = content; + this.Name = name; + } + + /// + /// Gets or sets the identifier. + /// Unique id for the content. It is specific to the enforcement plane. Path is used as item unique identifier, e.g., guid of a message in the conversation, file URL, storage file path, message ID, etc. + /// + [JsonPropertyName("identifier")] + public string Identifier { get; set; } + + /// + /// Gets or sets the content. + /// The content to be processed. + /// + [JsonPropertyName("content")] + public ContentBase Content { get; set; } + + /// + /// Gets or sets the name. + /// Name of the content, e.g., file name or web page title. + /// + [JsonPropertyName("name")] + public string Name { get; set; } + + /// + /// Gets or sets the correlationId. + /// Identifier to group multiple contents. + /// + [JsonPropertyName("correlationId")] + public string? CorrelationId { get; set; } + + /// + /// Gets or sets the sequenceNumber. + /// Sequence in which the content was originally generated. + /// + [JsonPropertyName("sequenceNumber")] + public long? SequenceNumber { get; set; } + + /// + /// Gets or sets the length. + /// Content length in bytes. + /// + [JsonPropertyName("length")] + public long? Length { get; set; } + + /// + /// Gets or sets the isTruncated. + /// Indicates if the original content has been truncated, e.g., to meet text or file size limits. + /// + [JsonPropertyName("isTruncated")] + public bool IsTruncated { get; set; } + + /// + /// Gets or sets the createdDateTime. + /// When the content was created. E.g., file created time or the time when a message was sent. + /// + [JsonPropertyName("createdDateTime")] + public DateTimeOffset CreatedDateTime { get; set; } = DateTime.UtcNow; + + /// + /// Gets or sets the modifiedDateTime. + /// When the content was last modified. E.g., file last modified time. For content created on the fly, such as messaging, whenModified and whenCreated are expected to be the same. + /// + [JsonPropertyName("modifiedDateTime")] + public DateTimeOffset? ModifiedDateTime { get; set; } = DateTime.UtcNow; +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessConversationMetadata.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessConversationMetadata.cs new file mode 100644 index 0000000000..86bedb9248 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessConversationMetadata.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Represents metadata for conversation content to be processed by the Purview SDK. +/// +internal sealed class ProcessConversationMetadata : ProcessContentMetadataBase +{ + private const string ProcessConversationMetadataDataType = Constants.ODataGraphNamespace + ".processConversationMetadata"; + + /// + /// Initializes a new instance of the class. + /// + public ProcessConversationMetadata(ContentBase contentBase, string identifier, bool isTruncated, string name) : base(contentBase, identifier, isTruncated, name) + { + this.DataType = ProcessConversationMetadataDataType; + } + + /// + /// Gets or sets the parent message ID for nested conversations. + /// + [JsonPropertyName("parentMessageId")] + public string? ParentMessageId { get; set; } + + /// + /// Gets or sets the accessed resources during message generation for bot messages. + /// + [JsonPropertyName("accessedResources_v2")] + public List? AccessedResources { get; set; } + + /// + /// Gets or sets the plugins used during message generation for bot messages. + /// + [JsonPropertyName("plugins")] + public List? Plugins { get; set; } + + /// + /// Gets or sets the collection of AI agent information. + /// + [JsonPropertyName("agents")] + public List? Agents { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessFileMetadata.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessFileMetadata.cs new file mode 100644 index 0000000000..a9f1749bed --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessFileMetadata.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Represents metadata for a file content to be processed by the Purview SDK. +/// +internal sealed class ProcessFileMetadata : ProcessContentMetadataBase +{ + private const string ProcessFileMetadataDataType = Constants.ODataGraphNamespace + ".processFileMetadata"; + + /// + /// Initializes a new instance of the class. + /// + public ProcessFileMetadata(ContentBase contentBase, string identifier, bool isTruncated, string name) : base(contentBase, identifier, isTruncated, name) + { + this.DataType = ProcessFileMetadataDataType; + } + + /// + /// Gets or sets the owner ID. + /// + [JsonPropertyName("ownerId")] + public string? OwnerId { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessingError.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessingError.cs new file mode 100644 index 0000000000..4852d5ca8a --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessingError.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Contains information about a processing error. +/// +internal sealed class ProcessingError : ClassificationErrorBase +{ + /// + /// Details about the error. + /// + [JsonPropertyName("details")] + public List? Details { get; set; } + + /// + /// Gets or sets the error type. + /// + [JsonPropertyName("type")] + public ContentProcessingErrorType? Type { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProtectedAppMetadata.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProtectedAppMetadata.cs new file mode 100644 index 0000000000..984a4168e7 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProtectedAppMetadata.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Represents metadata for a protected application that is integrated with Purview. +/// +internal sealed class ProtectedAppMetadata : IntegratedAppMetadata +{ + /// + /// Creates a new instance of the class. + /// + /// The location information of the protected app's data. + public ProtectedAppMetadata(PolicyLocation applicationLocation) + { + this.ApplicationLocation = applicationLocation; + } + + /// + /// The location of the application. + /// + [JsonPropertyName("applicationLocation")] + public PolicyLocation ApplicationLocation { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProtectionScopeActivities.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProtectionScopeActivities.cs new file mode 100644 index 0000000000..6c93a76124 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProtectionScopeActivities.cs @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Runtime.Serialization; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Activities that can be protected by the Purview Protection Scopes API. +/// +[Flags] +[DataContract] +[JsonConverter(typeof(JsonStringEnumConverter))] +internal enum ProtectionScopeActivities +{ + /// + /// None. + /// + [EnumMember(Value = "none")] + None = 0, + + /// + /// Upload text activity. + /// + [EnumMember(Value = "uploadText")] + UploadText = 1, + + /// + /// Upload file activity. + /// + [EnumMember(Value = "uploadFile")] + UploadFile = 2, + + /// + /// Download text activity. + /// + [EnumMember(Value = "downloadText")] + DownloadText = 4, + + /// + /// Download file activity. + /// + [EnumMember(Value = "downloadFile")] + DownloadFile = 8, + + /// + /// Unknown future value. + /// + [EnumMember(Value = "unknownFutureValue")] + UnknownFutureValue = 16 +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProtectionScopeState.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProtectionScopeState.cs new file mode 100644 index 0000000000..8fc7a534ad --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProtectionScopeState.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Indicates status of protection scope changes. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +internal enum ProtectionScopeState +{ + /// + /// Scope state hasn't changed. + /// + NotModified = 0, + + /// + /// Scope state has changed. + /// + Modified = 1, + + /// + /// Unknown value placeholder for future use. + /// + UnknownFutureValue = 2 +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProtectionScopesCacheKey.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProtectionScopesCacheKey.cs new file mode 100644 index 0000000000..2c772cbcb0 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProtectionScopesCacheKey.cs @@ -0,0 +1,91 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Linq; +using Microsoft.Agents.AI.Purview.Models.Requests; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// A cache key for storing protection scope responses. +/// +internal sealed class ProtectionScopesCacheKey +{ + /// + /// Creates a new instance of . + /// + /// The entra id of the user who made the interaction. + /// The tenant id of the user who made the interaction. + /// The activity performed with the data. + /// The location where the data came from. + /// The property to pivot on. + /// Metadata about the device that made the interaction. + /// Metadata about the app that is integrating with Purview. + public ProtectionScopesCacheKey( + string userId, + string tenantId, + ProtectionScopeActivities activities, + PolicyLocation? location, + PolicyPivotProperty? pivotOn, + DeviceMetadata? deviceMetadata, + IntegratedAppMetadata? integratedAppMetadata) + { + this.UserId = userId; + this.TenantId = tenantId; + this.Activities = activities; + this.Location = location; + this.PivotOn = pivotOn; + this.DeviceMetadata = deviceMetadata; + this.IntegratedAppMetadata = integratedAppMetadata; + } + + /// + /// Creates a mew instance of . + /// + /// A protection scopes request. + public ProtectionScopesCacheKey( + ProtectionScopesRequest request) : this( + request.UserId, + request.TenantId, + request.Activities, + request.Locations.FirstOrDefault(), + request.PivotOn, + request.DeviceMetadata, + request.IntegratedAppMetadata) + { + } + + /// + /// The id of the user making the request. + /// + public string UserId { get; set; } + + /// + /// The id of the tenant containing the user making the request. + /// + public string TenantId { get; set; } + + /// + /// The activity performed with the content. + /// + public ProtectionScopeActivities Activities { get; set; } + + /// + /// The location of the application. + /// + public PolicyLocation? Location { get; set; } + + /// + /// The property used to pivot the policy evaluation. + /// + public PolicyPivotProperty? PivotOn { get; set; } + + /// + /// Metadata about the device used to access the content. + /// + public DeviceMetadata? DeviceMetadata { get; set; } + + /// + /// Metadata about the integrated app used to access the content. + /// + public IntegratedAppMetadata? IntegratedAppMetadata { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PurviewBinaryContent.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PurviewBinaryContent.cs new file mode 100644 index 0000000000..0d65ac341d --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PurviewBinaryContent.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Represents a binary content item to be processed. +/// +internal sealed class PurviewBinaryContent : ContentBase +{ + private const string BinaryContentDataType = Constants.ODataGraphNamespace + ".binaryContent"; + + /// + /// Initializes a new instance of the class. + /// + /// The binary content in byte array format. + public PurviewBinaryContent(byte[] data) : base(BinaryContentDataType) + { + this.Data = data; + } + + /// + /// Gets or sets the binary data. + /// + [JsonPropertyName("data")] + public byte[] Data { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PurviewTextContent.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PurviewTextContent.cs new file mode 100644 index 0000000000..cfd03ae6ce --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PurviewTextContent.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Represents a text content item to be processed. +/// +internal sealed class PurviewTextContent : ContentBase +{ + private const string TextContentDataType = Constants.ODataGraphNamespace + ".textContent"; + + /// + /// Initializes a new instance of the class. + /// + /// The text content in string format. + public PurviewTextContent(string data) : base(TextContentDataType) + { + this.Data = data; + } + + /// + /// Gets or sets the text data. + /// + [JsonPropertyName("data")] + public string Data { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ResourceAccessStatus.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ResourceAccessStatus.cs new file mode 100644 index 0000000000..623f138e8b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ResourceAccessStatus.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Runtime.Serialization; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Status of the access operation. +/// +[DataContract] +[JsonConverter(typeof(JsonStringEnumConverter))] +internal enum ResourceAccessStatus +{ + /// + /// Represents failed access to the resource. + /// + [EnumMember(Value = "failure")] + Failure = 0, + + /// + /// Represents successful access to the resource. + /// + [EnumMember(Value = "success")] + Success = 1, + + /// + /// Unknown future value. + /// + [EnumMember(Value = "unknownFutureValue")] + UnknownFutureValue = 2 +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ResourceAccessType.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ResourceAccessType.cs new file mode 100644 index 0000000000..cb4e3b0cab --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ResourceAccessType.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Runtime.Serialization; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Access type performed on the resource. +/// +[Flags] +[DataContract] +[JsonConverter(typeof(JsonStringEnumConverter))] +internal enum ResourceAccessType : long +{ + /// + /// No access type. + /// + [EnumMember(Value = "none")] + None = 0, + + /// + /// Read access. + /// + [EnumMember(Value = "read")] + Read = 1 << 0, + + /// + /// Write access. + /// + [EnumMember(Value = "write")] + Write = 1 << 1, + + /// + /// Create access. + /// + [EnumMember(Value = "create")] + Create = 1 << 2, + + /// + /// Unknown future value. + /// + [EnumMember(Value = "unknownFutureValue")] + UnknownFutureValue = 1 << 3 +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/RestrictionAction.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/RestrictionAction.cs new file mode 100644 index 0000000000..ea13ec36a6 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/RestrictionAction.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Restriction actions for devices. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +internal enum RestrictionAction +{ + /// + /// Warn Action. + /// + Warn, + + /// + /// Audit action. + /// + Audit, + + /// + /// Block action. + /// + Block, + + /// + /// Allow action + /// + Allow +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/Scope.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/Scope.cs new file mode 100644 index 0000000000..9fc4de38fe --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/Scope.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Represents tenant/user/group scopes. +/// +internal sealed class Scope +{ + /// + /// The odata type of the scope used to identify what type of scope was returned. + /// + [JsonPropertyName("@odata.type")] + public string? ODataType { get; set; } + + /// + /// Gets or sets the scope identifier. + /// + [JsonPropertyName("identity")] + public string? Identity { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/TokenInfo.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/TokenInfo.cs new file mode 100644 index 0000000000..bd1338dd64 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/TokenInfo.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Info pulled from an auth token. +/// +internal sealed class TokenInfo +{ + /// + /// The entra id of the authenticated user. This is null if the auth token is not a user token. + /// + public string? UserId { get; set; } + + /// + /// The tenant id of the auth token. + /// + public string? TenantId { get; set; } + + /// + /// The client id of the auth token. + /// + public string? ClientId { get; set; } + + /// + /// Gets a value indicating whether the token is associated with a user. + /// + public bool IsUserToken => !string.IsNullOrEmpty(this.UserId); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Jobs/BackgroundJobBase.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Jobs/BackgroundJobBase.cs new file mode 100644 index 0000000000..ab8cc8a588 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Jobs/BackgroundJobBase.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Purview.Models.Jobs; + +/// +/// Abstract base class for background jobs. +/// +internal abstract class BackgroundJobBase +{ +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Jobs/ContentActivityJob.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Jobs/ContentActivityJob.cs new file mode 100644 index 0000000000..513af7f331 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Jobs/ContentActivityJob.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.Purview.Models.Requests; + +namespace Microsoft.Agents.AI.Purview.Models.Jobs; + +/// +/// Class representing a job to send content activities to the Purview service. +/// +internal sealed class ContentActivityJob : BackgroundJobBase +{ + /// + /// Create a new instance of the class. + /// + /// The content activities request to be sent in the background. + public ContentActivityJob(ContentActivitiesRequest request) + { + this.Request = request; + } + + /// + /// The request to send to the Purview service. + /// + public ContentActivitiesRequest Request { get; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Jobs/ProcessContentJob.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Jobs/ProcessContentJob.cs new file mode 100644 index 0000000000..768588f9d7 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Jobs/ProcessContentJob.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.Purview.Models.Requests; + +namespace Microsoft.Agents.AI.Purview.Models.Jobs; + +/// +/// Class representing a job to process content. +/// +internal sealed class ProcessContentJob : BackgroundJobBase +{ + /// + /// Initializes a new instance of the class. + /// + /// The process content request to be sent in the background. + public ProcessContentJob(ProcessContentRequest request) + { + this.Request = request; + } + + /// + /// The request to process content. + /// + public ProcessContentRequest Request { get; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Requests/ContentActivitiesRequest.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Requests/ContentActivitiesRequest.cs new file mode 100644 index 0000000000..a754a5a56f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Requests/ContentActivitiesRequest.cs @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Text.Json.Serialization; +using Microsoft.Agents.AI.Purview.Models.Common; + +namespace Microsoft.Agents.AI.Purview.Models.Requests; + +/// +/// A request class used for contentActivity requests. +/// +internal sealed class ContentActivitiesRequest +{ + /// + /// Initializes a new instance of the class. + /// + /// The entra id of the user who performed the activity. + /// The tenant id of the user who performed the activity. + /// The metadata about the content that was sent. + /// The correlation id of the request. + /// The scope identifier of the protection scopes associated with this request. + public ContentActivitiesRequest(string userId, string tenantId, ContentToProcess contentMetadata, Guid correlationId = default, string? scopeIdentifier = null) + { + this.UserId = userId ?? throw new ArgumentNullException(nameof(userId)); + this.TenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId)); + this.ContentMetadata = contentMetadata ?? throw new ArgumentNullException(nameof(contentMetadata)); + this.CorrelationId = correlationId == default ? Guid.NewGuid() : correlationId; + this.ScopeIdentifier = scopeIdentifier; + } + + /// + /// Gets or sets the ID of the signal. + /// + [JsonPropertyName("id")] + public string Id { get; set; } = Guid.NewGuid().ToString(); + + /// + /// Gets or sets the user ID of the content that is generating the signal. + /// + [JsonPropertyName("userId")] + public string UserId { get; set; } + + /// + /// Gets or sets the scope identifier for the signal. + /// + [JsonPropertyName("scopeIdentifier")] + public string? ScopeIdentifier { get; set; } + + /// + /// Gets or sets the content and associated content metadata for the content used to generate the signal. + /// + [JsonPropertyName("contentMetadata")] + public ContentToProcess ContentMetadata { get; set; } + + /// + /// Gets or sets the correlation ID for the signal. + /// + [JsonIgnore] + public Guid CorrelationId { get; set; } + + /// + /// Gets or sets the tenant id for the signal. + /// + [JsonIgnore] + public string TenantId { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Requests/ProcessContentRequest.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Requests/ProcessContentRequest.cs new file mode 100644 index 0000000000..f8e9602cef --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Requests/ProcessContentRequest.cs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Text.Json.Serialization; +using Microsoft.Agents.AI.Purview.Models.Common; + +namespace Microsoft.Agents.AI.Purview.Models.Requests; + +/// +/// Request for ProcessContent API +/// +internal sealed class ProcessContentRequest +{ + /// + /// Creates a new instance of ProcessContentRequest. + /// + /// The content and its metadata that will be processed. + /// The entra user id of the user making the request. + /// The tenant id of the user making the request. + public ProcessContentRequest(ContentToProcess contentToProcess, string userId, string tenantId) + { + this.ContentToProcess = contentToProcess; + this.UserId = userId; + this.TenantId = tenantId; + } + + /// + /// The content to process. + /// + [JsonPropertyName("contentToProcess")] + public ContentToProcess ContentToProcess { get; set; } + + /// + /// The user id of the user making the request. + /// + [JsonIgnore] + public string UserId { get; set; } + + /// + /// The correlation id of the request. + /// + [JsonIgnore] + public Guid CorrelationId { get; set; } = Guid.NewGuid(); + + /// + /// The tenant id of the user making the request. + /// + [JsonIgnore] + public string TenantId { get; set; } + + /// + /// The identifier of the cached protection scopes. + /// + [JsonIgnore] + internal string? ScopeIdentifier { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Requests/ProtectionScopesRequest.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Requests/ProtectionScopesRequest.cs new file mode 100644 index 0000000000..04aba59aff --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Requests/ProtectionScopesRequest.cs @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Runtime.Serialization; +using System.Text.Json.Serialization; +using Microsoft.Agents.AI.Purview.Models.Common; + +namespace Microsoft.Agents.AI.Purview.Models.Requests; + +/// +/// Request model for user protection scopes requests. +/// +[DataContract] +internal sealed class ProtectionScopesRequest +{ + /// + /// Creates a new instance of ProtectionScopesRequest. + /// + /// The entra id of the user who made the interaction. + /// The tenant id of the user who made the interaction. + public ProtectionScopesRequest(string userId, string tenantId) + { + this.UserId = userId; + this.TenantId = tenantId; + } + + /// + /// Activities to include in the scope + /// + [DataMember] + [JsonPropertyName("activities")] + public ProtectionScopeActivities Activities { get; set; } + + /// + /// Gets or sets the locations to compute protection scopes for. + /// + [JsonPropertyName("locations")] + public ICollection Locations { get; set; } = Array.Empty(); + + /// + /// Response aggregation pivot + /// + [DataMember] + [JsonPropertyName("pivotOn")] + public PolicyPivotProperty? PivotOn { get; set; } + + /// + /// Device metadata + /// + [DataMember] + [JsonPropertyName("deviceMetadata")] + public DeviceMetadata? DeviceMetadata { get; set; } + + /// + /// Integrated app metadata + /// + [DataMember] + [JsonPropertyName("integratedAppMetadata")] + public IntegratedAppMetadata? IntegratedAppMetadata { get; set; } + + /// + /// The correlation id of the request. + /// + [JsonIgnore] + public Guid CorrelationId { get; set; } = Guid.NewGuid(); + + /// + /// Scope ID, used to detect stale client scoping information + /// + [DataMember] + [JsonIgnore] + public string ScopeIdentifier { get; set; } = string.Empty; + + /// + /// The id of the user making the request. + /// + [JsonIgnore] + public string UserId { get; set; } + + /// + /// The tenant id of the user making the request. + /// + [JsonIgnore] + public string TenantId { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Responses/ContentActivitiesResponse.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Responses/ContentActivitiesResponse.cs new file mode 100644 index 0000000000..afdc21618e --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Responses/ContentActivitiesResponse.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Net; +using System.Text.Json.Serialization; +using Microsoft.Agents.AI.Purview.Models.Common; + +namespace Microsoft.Agents.AI.Purview.Models.Responses; + +/// +/// Represents the response for content activities requests. +/// +internal sealed class ContentActivitiesResponse +{ + /// + /// Gets or sets the HTTP status code associated with the response. + /// + [JsonIgnore] + public HttpStatusCode StatusCode { get; set; } + + /// + /// Details about any errors returned by the request. + /// + [JsonPropertyName("error")] + public ErrorDetails? Error { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Responses/ProcessContentResponse.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Responses/ProcessContentResponse.cs new file mode 100644 index 0000000000..c685c7786f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Responses/ProcessContentResponse.cs @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Runtime.Serialization; +using System.Text.Json.Serialization; +using Microsoft.Agents.AI.Purview.Models.Common; + +namespace Microsoft.Agents.AI.Purview.Models.Responses; + +/// +/// The response of a process content evaluation. +/// +internal sealed class ProcessContentResponse +{ + /// + /// Gets or sets the evaluation id. + /// + [Key] + public string? Id { get; set; } + + /// + /// Gets or sets the status of protection scope changes. + /// + [DataMember] + [JsonPropertyName("protectionScopeState")] + public ProtectionScopeState? ProtectionScopeState { get; set; } + + /// + /// Gets or sets the policy actions to take. + /// + [DataMember] + [JsonPropertyName("policyActions")] + public IReadOnlyList? PolicyActions { get; set; } + + /// + /// Gets or sets error information about the evaluation. + /// + [DataMember] + [JsonPropertyName("processingErrors")] + public IReadOnlyList? ProcessingErrors { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Responses/ProtectionScopesResponse.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Responses/ProtectionScopesResponse.cs new file mode 100644 index 0000000000..fb9b0603d8 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Responses/ProtectionScopesResponse.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json.Serialization; +using Microsoft.Agents.AI.Purview.Models.Common; + +namespace Microsoft.Agents.AI.Purview.Models.Responses; + +/// +/// A response object containing protection scopes for a tenant. +/// +internal sealed class ProtectionScopesResponse +{ + /// + /// The identifier used for caching the user protection scopes. + /// + public string? ScopeIdentifier { get; set; } + + /// + /// The user protection scopes. + /// + [JsonPropertyName("value")] + public IReadOnlyCollection? Scopes { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/PurviewAgent.cs b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewAgent.cs new file mode 100644 index 0000000000..fd2a1950e9 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewAgent.cs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Purview; + +/// +/// A middleware agent that connects to Microsoft Purview. +/// +internal class PurviewAgent : AIAgent, IDisposable +{ + private readonly AIAgent _innerAgent; + private readonly PurviewWrapper _purviewWrapper; + + /// + /// Initializes a new instance of the class. + /// + /// The agent-framework agent that the middleware wraps. + /// The purview wrapper used to interact with the Purview service. + public PurviewAgent(AIAgent innerAgent, PurviewWrapper purviewWrapper) + { + this._innerAgent = innerAgent; + this._purviewWrapper = purviewWrapper; + } + + /// + public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null) + { + return this._innerAgent.DeserializeThread(serializedThread, jsonSerializerOptions); + } + + /// + public override AgentThread GetNewThread() + { + return this._innerAgent.GetNewThread(); + } + + /// + public override Task RunAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + { + return this._purviewWrapper.ProcessAgentContentAsync(messages, thread, options, this._innerAgent, cancellationToken); + } + + /// + public override async IAsyncEnumerable RunStreamingAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var response = await this._purviewWrapper.ProcessAgentContentAsync(messages, thread, options, this._innerAgent, cancellationToken).ConfigureAwait(false); + foreach (var update in response.ToAgentRunResponseUpdates()) + { + yield return update; + } + } + + /// + public void Dispose() + { + if (this._innerAgent is IDisposable disposableAgent) + { + disposableAgent.Dispose(); + } + + this._purviewWrapper.Dispose(); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/PurviewAppLocation.cs b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewAppLocation.cs new file mode 100644 index 0000000000..5e5d7af96f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewAppLocation.cs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.Agents.AI.Purview.Models.Common; + +namespace Microsoft.Agents.AI.Purview; + +/// +/// An identifier representing the app's location for Purview policy evaluation. +/// +public class PurviewAppLocation +{ + /// + /// Creates a new instance of . + /// + /// The type of location. + /// The value of the location. + public PurviewAppLocation(PurviewLocationType locationType, string locationValue) + { + this.LocationType = locationType; + this.LocationValue = locationValue; + } + + /// + /// The type of location. + /// + public PurviewLocationType LocationType { get; set; } + + /// + /// The location value. + /// + public string LocationValue { get; set; } + + /// + /// Returns the model for this . + /// + /// PolicyLocation request model. + /// Thrown when an invalid location type is provided. + internal PolicyLocation GetPolicyLocation() + { + switch (this.LocationType) + { + case PurviewLocationType.Application: + return new PolicyLocation($"{Constants.ODataGraphNamespace}.policyLocationApplication", this.LocationValue); + case PurviewLocationType.Uri: + return new PolicyLocation($"{Constants.ODataGraphNamespace}.policyLocationUrl", this.LocationValue); + case PurviewLocationType.Domain: + return new PolicyLocation($"{Constants.ODataGraphNamespace}.policyLocationDomain", this.LocationValue); + default: + throw new InvalidOperationException("Invalid location type."); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/PurviewChatClient.cs b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewChatClient.cs new file mode 100644 index 0000000000..fded26c0ae --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewChatClient.cs @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Purview; + +/// +/// A middleware chat client that connects to Microsoft Purview. +/// +internal class PurviewChatClient : IChatClient +{ + private readonly IChatClient _innerChatClient; + private readonly PurviewWrapper _purviewWrapper; + + /// + /// Initializes a new instance of the class. + /// + /// The inner chat client to wrap. + /// The purview wrapper used to interact with the Purview service. + public PurviewChatClient(IChatClient innerChatClient, PurviewWrapper purviewWrapper) + { + this._innerChatClient = innerChatClient; + this._purviewWrapper = purviewWrapper; + } + + /// + public void Dispose() + { + this._purviewWrapper.Dispose(); + this._innerChatClient.Dispose(); + } + + /// + public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + return this._purviewWrapper.ProcessChatContentAsync(messages, options, this._innerChatClient, cancellationToken); + } + + /// + public object? GetService(Type serviceType, object? serviceKey = null) + { + return this._innerChatClient.GetService(serviceType, serviceKey); + } + + /// + public async IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + Task responseTask = this._purviewWrapper.ProcessChatContentAsync(messages, options, this._innerChatClient, cancellationToken); + + foreach (var update in (await responseTask.ConfigureAwait(false)).ToChatResponseUpdates()) + { + yield return update; + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/PurviewClient.cs b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewClient.cs new file mode 100644 index 0000000000..7fade4eabb --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewClient.cs @@ -0,0 +1,311 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using System.Threading; +using System.Threading.Tasks; +using Azure.Core; +using Microsoft.Agents.AI.Purview.Models.Common; +using Microsoft.Agents.AI.Purview.Models.Requests; +using Microsoft.Agents.AI.Purview.Models.Responses; +using Microsoft.Agents.AI.Purview.Serialization; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Microsoft.Agents.AI.Purview; + +/// +/// Client for calling Purview APIs. +/// +internal sealed class PurviewClient : IPurviewClient +{ + private readonly TokenCredential _tokenCredential; + private readonly HttpClient _httpClient; + private readonly string[] _scopes; + private readonly string _graphUri; + private readonly ILogger _logger; + + private static PurviewException CreateExceptionForStatusCode(HttpStatusCode statusCode, string endpointName) + { + // .net framework does not support TooManyRequests, so we have to convert to an int. + switch ((int)statusCode) + { + case 429: + return new PurviewRateLimitException($"Rate limit exceeded for {endpointName}."); + case 401: + case 403: + return new PurviewAuthenticationException($"Unauthorized access to {endpointName}. Status code: {statusCode}"); + case 402: + return new PurviewPaymentRequiredException($"Payment required for {endpointName}. Status code: {statusCode}"); + default: + return new PurviewRequestException(statusCode, endpointName); + } + } + + /// + /// Creates a new instance. + /// + /// The token credential used to authenticate with Purview. + /// The settings used for purview requests. + /// The HttpClient used to make network requests to Purview. + /// The logger used to log information from the middleware. + public PurviewClient(TokenCredential tokenCredential, PurviewSettings purviewSettings, HttpClient httpClient, ILogger logger) + { + this._tokenCredential = tokenCredential; + this._httpClient = httpClient; + + this._scopes = new string[] { $"https://{purviewSettings.GraphBaseUri.Host}/.default" }; + this._graphUri = purviewSettings.GraphBaseUri.ToString().TrimEnd('/'); + this._logger = logger ?? NullLogger.Instance; + } + + private static TokenInfo ExtractTokenInfo(string tokenString) + { + // Split JWT and decode payload + string[] parts = tokenString.Split('.'); + if (parts.Length < 2) + { + throw new PurviewRequestException("Invalid JWT access token format."); + } + + string payload = parts[1]; + // Pad base64 string if needed + int mod4 = payload.Length % 4; + if (mod4 > 0) + { + payload += new string('=', 4 - mod4); + } + + byte[] bytes = Convert.FromBase64String(payload.Replace('-', '+').Replace('_', '/')); + string json = Encoding.UTF8.GetString(bytes); + + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + string? objectId = root.TryGetProperty("oid", out var oidProp) ? oidProp.GetString() : null; + string? idType = root.TryGetProperty("idtyp", out var idtypProp) ? idtypProp.GetString() : null; + string? tenant = root.TryGetProperty("tid", out var tidProp) ? tidProp.GetString() : null; + string? clientId = root.TryGetProperty("appid", out var appidProp) ? appidProp.GetString() : null; + + string? userId = idType == "user" ? objectId : null; + + return new TokenInfo + { + UserId = userId, + TenantId = tenant, + ClientId = clientId + }; + } + + /// + public async Task GetUserInfoFromTokenAsync(CancellationToken cancellationToken, string? tenantId = default) + { + TokenRequestContext tokenRequestContext = tenantId == null ? new(this._scopes) : new(this._scopes, tenantId: tenantId); + AccessToken token = await this._tokenCredential.GetTokenAsync(tokenRequestContext, cancellationToken).ConfigureAwait(false); + + string tokenString = token.Token; + + return ExtractTokenInfo(tokenString); + } + + /// + public async Task ProcessContentAsync(ProcessContentRequest request, CancellationToken cancellationToken) + { + var token = await this._tokenCredential.GetTokenAsync(new TokenRequestContext(this._scopes, tenantId: request.TenantId), cancellationToken).ConfigureAwait(false); + string userId = request.UserId; + + string uri = $"{this._graphUri}/users/{userId}/dataSecurityAndGovernance/processContent"; + + using (HttpRequestMessage message = new(HttpMethod.Post, new Uri(uri))) + { + message.Headers.Add("Authorization", $"Bearer {token.Token}"); + message.Headers.Add("User-Agent", "agent-framework-dotnet"); + + if (request.ScopeIdentifier != null) + { + message.Headers.Add("If-None-Match", request.ScopeIdentifier); + } + + string content = JsonSerializer.Serialize(request, PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ProcessContentRequest))); + message.Content = new StringContent(content, Encoding.UTF8, "application/json"); + + HttpResponseMessage response; + try + { + response = await this._httpClient.SendAsync(message, cancellationToken).ConfigureAwait(false); + } + catch (HttpRequestException e) + { + this._logger.LogError(e, "Http error while processing content."); + throw new PurviewRequestException("Http error occurred while processing content.", e); + } + +#if NET5_0_OR_GREATER + // Pass the cancellation token if that method is available. + string responseContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); +#else + string responseContent = await response.Content.ReadAsStringAsync().ConfigureAwait(false); +#endif + + if (response.StatusCode == HttpStatusCode.OK || response.StatusCode == HttpStatusCode.Accepted) + { + ProcessContentResponse? deserializedResponse; + try + { + JsonTypeInfo typeInfo = (JsonTypeInfo)PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ProcessContentResponse)); + deserializedResponse = JsonSerializer.Deserialize(responseContent, typeInfo); + } + catch (JsonException jsonException) + { + const string DeserializeExceptionError = "Failed to deserialize ProcessContent response."; + this._logger.LogError(jsonException, DeserializeExceptionError); + throw new PurviewRequestException(DeserializeExceptionError, jsonException); + } + + if (deserializedResponse != null) + { + return deserializedResponse; + } + + const string DeserializeError = "Failed to deserialize ProcessContent response. Response was null."; + this._logger.LogError(DeserializeError); + throw new PurviewRequestException(DeserializeError); + } + + this._logger.LogError("Failed to process content. Status code: {StatusCode}", response.StatusCode); + throw CreateExceptionForStatusCode(response.StatusCode, "processContent"); + } + } + + /// + public async Task GetProtectionScopesAsync(ProtectionScopesRequest request, CancellationToken cancellationToken) + { + var token = await this._tokenCredential.GetTokenAsync(new TokenRequestContext(this._scopes), cancellationToken).ConfigureAwait(false); + string userId = request.UserId; + + string uri = $"{this._graphUri}/users/{userId}/dataSecurityAndGovernance/protectionScopes/compute"; + + using (HttpRequestMessage message = new(HttpMethod.Post, new Uri(uri))) + { + message.Headers.Add("Authorization", $"Bearer {token.Token}"); + message.Headers.Add("User-Agent", "agent-framework-dotnet"); + + var typeinfo = PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ProtectionScopesRequest)); + string content = JsonSerializer.Serialize(request, typeinfo); + message.Content = new StringContent(content, Encoding.UTF8, "application/json"); + + HttpResponseMessage response; + try + { + response = await this._httpClient.SendAsync(message, cancellationToken).ConfigureAwait(false); + } + catch (HttpRequestException e) + { + this._logger.LogError(e, "Http error while retrieving protection scopes."); + throw new PurviewRequestException("Http error occurred while retrieving protection scopes.", e); + } + + if (response.StatusCode == HttpStatusCode.OK) + { +#if NET5_0_OR_GREATER + // Pass the cancellation token if that method is available. + string responseContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); +#else + string responseContent = await response.Content.ReadAsStringAsync().ConfigureAwait(false); +#endif + ProtectionScopesResponse? deserializedResponse; + try + { + JsonTypeInfo typeInfo = (JsonTypeInfo)PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ProtectionScopesResponse)); + deserializedResponse = JsonSerializer.Deserialize(responseContent, typeInfo); + } + catch (JsonException jsonException) + { + const string DeserializeExceptionError = "Failed to deserialize ProtectionScopes response."; + this._logger.LogError(jsonException, DeserializeExceptionError); + throw new PurviewRequestException(DeserializeExceptionError, jsonException); + } + + if (deserializedResponse != null) + { + deserializedResponse.ScopeIdentifier = response.Headers.ETag?.Tag; + return deserializedResponse; + } + + const string DeserializeError = "Failed to deserialize ProtectionScopes response."; + this._logger.LogError(DeserializeError); + throw new PurviewRequestException(DeserializeError); + } + + this._logger.LogError("Failed to retrieve protection scopes. Status code: {StatusCode}", response.StatusCode); + throw CreateExceptionForStatusCode(response.StatusCode, "protectionScopes/compute"); + } + } + + /// + public async Task SendContentActivitiesAsync(ContentActivitiesRequest request, CancellationToken cancellationToken) + { + var token = await this._tokenCredential.GetTokenAsync(new TokenRequestContext(this._scopes), cancellationToken).ConfigureAwait(false); + string userId = request.UserId; + + string uri = $"{this._graphUri}/{userId}/dataSecurityAndGovernance/activities/contentActivities"; + + using (HttpRequestMessage message = new(HttpMethod.Post, new Uri(uri))) + { + message.Headers.Add("Authorization", $"Bearer {token.Token}"); + message.Headers.Add("User-Agent", "agent-framework-dotnet"); + string content = JsonSerializer.Serialize(request, PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ContentActivitiesRequest))); + message.Content = new StringContent(content, Encoding.UTF8, "application/json"); + HttpResponseMessage response; + + try + { + response = await this._httpClient.SendAsync(message, cancellationToken).ConfigureAwait(false); + } + catch (HttpRequestException e) + { + this._logger.LogError(e, "Http error while creating content activities."); + throw new PurviewRequestException("Http error occurred while creating content activities.", e); + } + + if (response.StatusCode == HttpStatusCode.Created) + { +#if NET5_0_OR_GREATER + // Pass the cancellation token if that method is available. + string responseContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); +#else + string responseContent = await response.Content.ReadAsStringAsync().ConfigureAwait(false); +#endif + ContentActivitiesResponse? deserializedResponse; + + try + { + JsonTypeInfo typeInfo = (JsonTypeInfo)PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ContentActivitiesResponse)); + deserializedResponse = JsonSerializer.Deserialize(responseContent, typeInfo); + } + catch (JsonException jsonException) + { + const string DeserializeExceptionError = "Failed to deserialize ContentActivities response."; + this._logger.LogError(jsonException, DeserializeExceptionError); + throw new PurviewRequestException(DeserializeExceptionError, jsonException); + } + + if (deserializedResponse != null) + { + return deserializedResponse; + } + + const string DeserializeError = "Failed to deserialize ContentActivities response."; + this._logger.LogError(DeserializeError); + throw new PurviewRequestException(DeserializeError); + } + + this._logger.LogError("Failed to create content activities. Status code: {StatusCode}", response.StatusCode); + throw CreateExceptionForStatusCode(response.StatusCode, "contentActivities"); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/PurviewExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewExtensions.cs new file mode 100644 index 0000000000..cdeb395d67 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewExtensions.cs @@ -0,0 +1,122 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Net.Http; +using System.Threading.Channels; +using Azure.Core; +using Microsoft.Agents.AI.Purview.Models.Jobs; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; + +namespace Microsoft.Agents.AI.Purview; + +/// +/// Extension methods to add Purview capabilities to an . +/// +public static class PurviewExtensions +{ + private static PurviewWrapper CreateWrapper(TokenCredential tokenCredential, PurviewSettings purviewSettings, ILogger? logger = null, IDistributedCache? cache = null) + { + MemoryDistributedCacheOptions options = new() + { + SizeLimit = purviewSettings.InMemoryCacheSizeLimit, + }; + + IDistributedCache distributedCache = cache ?? new MemoryDistributedCache(Options.Create(options)); + + ServiceCollection services = new(); + services.AddSingleton(tokenCredential); + services.AddSingleton(purviewSettings); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(distributedCache); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(logger ?? NullLogger.Instance); + services.AddSingleton(); + services.AddSingleton(Channel.CreateBounded(purviewSettings.PendingBackgroundJobLimit)); + services.AddSingleton(); + services.AddSingleton(); + ServiceProvider serviceProvider = services.BuildServiceProvider(); + + return serviceProvider.GetRequiredService(); + } + + /// + /// Adds Purview capabilities to an . + /// + /// The AI Agent builder for the . + /// The token credential used to authenticate with Purview. + /// The settings for communication with Purview. + /// The logger to use for logging. + /// The distributed cache to use for caching Purview responses. An in memory cache will be used if this is null. + /// The updated + public static AIAgentBuilder WithPurview(this AIAgentBuilder builder, TokenCredential tokenCredential, PurviewSettings purviewSettings, ILogger? logger = null, IDistributedCache? cache = null) + { + PurviewWrapper purviewWrapper = CreateWrapper(tokenCredential, purviewSettings, logger, cache); + return builder.Use((innerAgent) => new PurviewAgent(innerAgent, purviewWrapper)); + } + + /// + /// Adds Purview capabilities to a . + /// + /// The chat client builder for the . + /// The token credential used to authenticate with Purview. + /// The settings for communication with Purview. + /// The logger to use for logging. + /// The distributed cache to use for caching Purview responses. An in memory cache will be used if this is null. + /// The updated + public static ChatClientBuilder WithPurview(this ChatClientBuilder builder, TokenCredential tokenCredential, PurviewSettings purviewSettings, ILogger? logger = null, IDistributedCache? cache = null) + { + PurviewWrapper purviewWrapper = CreateWrapper(tokenCredential, purviewSettings, logger, cache); + return builder.Use((innerChatClient) => new PurviewChatClient(innerChatClient, purviewWrapper)); + } + + /// + /// Creates a Purview middleware function for use with a . + /// + /// The token credential used to authenticate with Purview. + /// The settings for communication with Purview. + /// The logger to use for logging. + /// The distributed cache to use for caching Purview responses. An in memory cache will be used if this is null. + /// A chat middleware delegate. + public static Func PurviewChatMiddleware(TokenCredential tokenCredential, PurviewSettings purviewSettings, ILogger? logger = null, IDistributedCache? cache = null) + { + PurviewWrapper purviewWrapper = CreateWrapper(tokenCredential, purviewSettings, logger, cache); + return (innerChatClient) => new PurviewChatClient(innerChatClient, purviewWrapper); + } + + /// + /// Creates a Purview middleware function for use with an . + /// + /// The token credential used to authenticate with Purview. + /// The settings for communication with Purview. + /// The logger to use for logging. + /// The distributed cache to use for caching Purview responses. An in memory cache will be used if this is null. + /// An agent middleware delegate. + public static Func PurviewAgentMiddleware(TokenCredential tokenCredential, PurviewSettings purviewSettings, ILogger? logger = null, IDistributedCache? cache = null) + { + PurviewWrapper purviewWrapper = CreateWrapper(tokenCredential, purviewSettings, logger, cache); + return (innerAgent) => new PurviewAgent(innerAgent, purviewWrapper); + } + + /// + /// Sets the user id for a message. + /// + /// The message. + /// The id of the owner of the message. + public static void SetUserId(this ChatMessage message, Guid userId) + { + if (message.AdditionalProperties == null) + { + message.AdditionalProperties = new AdditionalPropertiesDictionary(); + } + + message.AdditionalProperties[Constants.UserId] = userId.ToString(); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/PurviewLocationType.cs b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewLocationType.cs new file mode 100644 index 0000000000..4fcc145f0b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewLocationType.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Purview; + +/// +/// The type of location for Purview policy evaluation. +/// +public enum PurviewLocationType +{ + /// + /// An application location. + /// + Application, + + /// + /// A URI location. + /// + Uri, + + /// + /// A domain name location. + /// + Domain +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/PurviewSettings.cs b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewSettings.cs new file mode 100644 index 0000000000..cb400805c6 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewSettings.cs @@ -0,0 +1,88 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.AI.Purview; + +/// +/// Represents the configuration settings for a Purview application, including tenant information, application name, and +/// optional default user settings. +/// +/// This class is used to encapsulate the necessary configuration details for interacting with Purview +/// services. It includes the tenant ID and application name, which are required, and an optional default user ID that +/// can be used for requests where a specific user ID is not provided. +public class PurviewSettings +{ + /// + /// Initializes a new instance of the class. + /// + /// The publicly visible name of the application. + public PurviewSettings(string appName) + { + this.AppName = appName; + } + + /// + /// The publicly visible app name of the application. + /// + public string AppName { get; set; } + + /// + /// The version string of the application. + /// + public string? AppVersion { get; set; } + + /// + /// The tenant id of the user making the request. + /// If this is not provided, the tenant id will be inferred from the token. + /// + public string? TenantId { get; set; } + + /// + /// Gets or sets the location of the Purview resource. + /// If this is not provided, a location containing the client id will be used instead. + /// + public PurviewAppLocation? PurviewAppLocation { get; set; } + + /// + /// Gets or sets a flag indicating whether to ignore exceptions when processing Purview requests. False by default. + /// If set to true, exceptions calling Purview will be logged but not thrown. + /// + public bool IgnoreExceptions { get; set; } + + /// + /// Gets or sets the base URI for the Microsoft Graph API. + /// Set to graph v1.0 by default. + /// + public Uri GraphBaseUri { get; set; } = new Uri("https://graph.microsoft.com/v1.0/"); + + /// + /// Gets or sets the message to display when a prompt is blocked by Purview policies. + /// + public string BlockedPromptMessage { get; set; } = "Prompt blocked by policies"; + + /// + /// Gets or sets the message to display when a response is blocked by Purview policies. + /// + public string BlockedResponseMessage { get; set; } = "Response blocked by policies"; + + /// + /// The size limit of the default in memory cache in bytes. This only applies if no cache is provided when creating Purview resources. + /// + public long? InMemoryCacheSizeLimit { get; set; } = 100_000_000; + + /// + /// The TTL of each cache entry. + /// + public TimeSpan CacheTTL { get; set; } = TimeSpan.FromMinutes(30); + + /// + /// The maximum number of background jobs that can be queued up. + /// + public int PendingBackgroundJobLimit { get; set; } = 100; + + /// + /// The maximum number of concurrent job consumers. + /// + public int MaxConcurrentJobConsumers { get; set; } = 10; +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/PurviewWrapper.cs b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewWrapper.cs new file mode 100644 index 0000000000..c8316a4e21 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewWrapper.cs @@ -0,0 +1,181 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Purview.Models.Common; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Agents.AI.Purview; + +/// +/// A delegating agent that connects to Microsoft Purview. +/// +internal sealed class PurviewWrapper : IDisposable +{ + private readonly ILogger _logger; + private readonly IScopedContentProcessor _scopedProcessor; + private readonly PurviewSettings _purviewSettings; + private readonly IChannelHandler _channelHandler; + + /// + /// Creates a new instance. + /// + /// The scoped processor used to orchestrate the calls to Purview. + /// The settings for Purview integration. + /// The logger used for logging. + /// The channel handler used to queue background jobs and add job runners. + public PurviewWrapper(IScopedContentProcessor scopedProcessor, PurviewSettings purviewSettings, ILogger logger, IChannelHandler channelHandler) + { + this._scopedProcessor = scopedProcessor; + this._purviewSettings = purviewSettings; + this._logger = logger; + this._channelHandler = channelHandler; + } + + private static string GetThreadIdFromAgentThread(AgentThread? thread, IEnumerable messages) + { + if (thread is ChatClientAgentThread chatClientAgentThread && + chatClientAgentThread.ConversationId != null) + { + return chatClientAgentThread.ConversationId; + } + + foreach (ChatMessage message in messages) + { + if (message.AdditionalProperties != null && + message.AdditionalProperties.TryGetValue(Constants.ConversationId, out object? conversationId) && + conversationId != null) + { + return conversationId.ToString() ?? Guid.NewGuid().ToString(); + } + } + + return Guid.NewGuid().ToString(); + } + + /// + /// Processes a prompt and response exchange at a chat client level. + /// + /// The messages sent to the chat client. + /// The chat options used with the chat client. + /// The wrapped chat client. + /// The cancellation token used to interrupt async operations. + /// The chat client's response. This could be the response from the chat client or a message indicating that Purview has blocked the prompt or response. + public async Task ProcessChatContentAsync(IEnumerable messages, ChatOptions? options, IChatClient innerChatClient, CancellationToken cancellationToken) + { + string? resolvedUserId = null; + + try + { + (bool shouldBlockPrompt, resolvedUserId) = await this._scopedProcessor.ProcessMessagesAsync(messages, options?.ConversationId, Activity.UploadText, this._purviewSettings, null, cancellationToken).ConfigureAwait(false); + if (shouldBlockPrompt) + { + this._logger.LogInformation("Prompt blocked by policy. Sending message: {Message}", this._purviewSettings.BlockedPromptMessage); + return new ChatResponse(new ChatMessage(ChatRole.System, this._purviewSettings.BlockedPromptMessage)); + } + } + catch (Exception ex) + { + this._logger.LogError(ex, "Error processing prompt: {ExceptionMessage}", ex.Message); + + if (!this._purviewSettings.IgnoreExceptions) + { + throw; + } + } + + ChatResponse response = await innerChatClient.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false); + + try + { + (bool shouldBlockResponse, _) = await this._scopedProcessor.ProcessMessagesAsync(response.Messages, options?.ConversationId, Activity.UploadText, this._purviewSettings, resolvedUserId, cancellationToken).ConfigureAwait(false); + if (shouldBlockResponse) + { + this._logger.LogInformation("Response blocked by policy. Sending message: {Message}", this._purviewSettings.BlockedResponseMessage); + return new ChatResponse(new ChatMessage(ChatRole.System, this._purviewSettings.BlockedResponseMessage)); + } + } + catch (Exception ex) + { + this._logger.LogError(ex, "Error processing response: {ExceptionMessage}", ex.Message); + + if (!this._purviewSettings.IgnoreExceptions) + { + throw; + } + } + + return response; + } + + /// + /// Processes a prompt and response exchange at an agent level. + /// + /// The messages sent to the agent. + /// The thread used for this agent conversation. + /// The options used with this agent. + /// The wrapped agent. + /// The cancellation token used to interrupt async operations. + /// The agent's response. This could be the response from the agent or a message indicating that Purview has blocked the prompt or response. + public async Task ProcessAgentContentAsync(IEnumerable messages, AgentThread? thread, AgentRunOptions? options, AIAgent innerAgent, CancellationToken cancellationToken) + { + string threadId = GetThreadIdFromAgentThread(thread, messages); + + string? resolvedUserId = null; + + try + { + (bool shouldBlockPrompt, resolvedUserId) = await this._scopedProcessor.ProcessMessagesAsync(messages, threadId, Activity.UploadText, this._purviewSettings, null, cancellationToken).ConfigureAwait(false); + + if (shouldBlockPrompt) + { + this._logger.LogInformation("Prompt blocked by policy. Sending message: {Message}", this._purviewSettings.BlockedPromptMessage); + return new AgentRunResponse(new ChatMessage(ChatRole.System, this._purviewSettings.BlockedPromptMessage)); + } + } + catch (Exception ex) + { + this._logger.LogError(ex, "Error processing prompt: {ExceptionMessage}", ex.Message); + + if (!this._purviewSettings.IgnoreExceptions) + { + throw; + } + } + + AgentRunResponse response = await innerAgent.RunAsync(messages, thread, options, cancellationToken).ConfigureAwait(false); + + try + { + (bool shouldBlockResponse, _) = await this._scopedProcessor.ProcessMessagesAsync(response.Messages, threadId, Activity.UploadText, this._purviewSettings, resolvedUserId, cancellationToken).ConfigureAwait(false); + + if (shouldBlockResponse) + { + this._logger.LogInformation("Response blocked by policy. Sending message: {Message}", this._purviewSettings.BlockedResponseMessage); + return new AgentRunResponse(new ChatMessage(ChatRole.System, this._purviewSettings.BlockedResponseMessage)); + } + } + catch (Exception ex) + { + this._logger.LogError(ex, "Error processing response: {ExceptionMessage}", ex.Message); + + if (!this._purviewSettings.IgnoreExceptions) + { + throw; + } + } + + return response; + } + + /// + public void Dispose() + { +#pragma warning disable VSTHRD002 // Need to wait for pending jobs to complete. + this._channelHandler.StopAndWaitForCompletionAsync().GetAwaiter().GetResult(); +#pragma warning restore VSTHRD002 // Need to wait for pending jobs to complete. + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/README.md b/dotnet/src/Microsoft.Agents.AI.Purview/README.md new file mode 100644 index 0000000000..3e46ceff65 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/README.md @@ -0,0 +1,263 @@ +# Microsoft Agent Framework - Purview Integration (Dotnet) + +The Purview plugin for the Microsoft Agent Framework adds Purview policy evaluation to the Microsoft Agent Framework. +It lets you enforce data security and governance policies on both the *prompt* (user input + conversation history) and the *model response* before they proceed further in your workflow. + +> Status: **Preview** + +### Key Features + +- Middleware-based policy enforcement (agent-level and chat-client level) +- Blocks or allows content at both ingress (prompt) and egress (response) +- Works with any `IChatClient` or `AIAgent` using the standard Agent Framework middleware pipeline. +- Authenticates to Purview using `TokenCredential`s +- Simple configuration using `PurviewSettings` +- Configurable caching using `IDistributedCache` +- `WithPurview` Extension methods to easily apply middleware to a `ChatClientBuilder` or `AIAgentBuilder` + +### When to Use +Add Purview when you need to: + +- Prevent sensitive or disallowed content from being sent to an LLM +- Prevent model output containing disallowed data from leaving the system +- Apply centrally managed policies without rewriting agent logic + +--- + + +## Quick Start + +``` csharp +using Azure.AI.OpenAI; +using Azure.Core; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Purview; +using Microsoft.Extensions.AI; + +Uri endpoint = new Uri("..."); // The endpoint of Azure OpenAI instance. +string deploymentName = "..."; // The deployment name of your Azure OpenAI instance ex: gpt-4o-mini +string purviewClientAppId = "..."; // The client id of your entra app registration. + +// 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 + }); + +IChatClient client = new AzureOpenAIClient( + new Uri(endpoint), + new AzureCliCredential()) + .GetOpenAIResponseClient(deploymentName) + .AsIChatClient() + .AsBuilder() + .WithPurview(browserCredential, new PurviewSettings("My Sample App")) + .Build(); + +using (client) +{ + 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)); + } +} +``` + +If a policy violation is detected on the prompt, the middleware interrupts the run and outputs the message: `"Prompt blocked by policies"`. If on the response, the result becomes `"Response blocked by policies"`. + +--- + +## Authentication + +The Purview middleware uses Azure.Core TokenCredential objects for authentication. + +The plugin requires the following Graph permissions: +- ProtectionScopes.Compute.All : [userProtectionScopeContainer](https://learn.microsoft.com/en-us/graph/api/userprotectionscopecontainer-compute) +- Content.Process.All : [processContent](https://learn.microsoft.com/en-us/graph/api/userdatasecurityandgovernance-processcontent) +- ContentActivity.Write : [contentActivity](https://learn.microsoft.com/en-us/graph/api/activitiescontainer-post-contentactivities) + +Authentication with user tokens is preferred. If authenticating with app tokens, the agent-framework caller will need to provide an entra user id for each `ChatMessage` send to the agent/client. This user id can be set using the `SetUserId` extension method, or by setting the `"userId"` field of the `AdditionalProperties` dictionary. + +``` csharp +// Manually +var message = new ChatMessage(ChatRole.User, promptText); +if (message.AdditionalProperties == null) +{ + message.AdditionalProperties = new AdditionalPropertiesDictionary(); +} +message.AdditionalProperties["userId"] = ""; + +// Or with the extension method +var message = new ChatMessage(ChatRole.User, promptText); +message.SetUserId(new Guid("")); +``` + +### Tenant Enablement for Purview +- The tenant requires an e5 license and consumptive billing setup. +- [Data Loss Prevention](https://learn.microsoft.com/en-us/purview/dlp-create-deploy-policy) or [Data Collection Policies](https://learn.microsoft.com/en-us/purview/collection-policies-policy-reference) policies that apply to the user are required to enable classification and message ingestion (Process Content API). Otherwise, messages will only be logged in Purview's Audit log (Content Activities API). + +## Configuration + +### Settings + +The Purview middleware can be customized and configured using the `PurviewSettings` class. + +#### `PurviewSettings` + +| Field | Type | Purpose | +| ----- | ---- | ------- | +| AppName | string | The publicly visible app name of the application. | +| AppVersion | string? | (Optional) The version string of the application. | +| TenantId | string? | (Optional) The tenant id of the user making the request. If not provided, this will be inferred from the token. | +| PurviewAppLocation | PurviewAppLocation? | (Optional) The location of the Purview resource used during policy evaluation. If not provided, a location containing the application client id will be used instead. | +| IgnoreExceptions | bool | (Optional, `false` by default) Determines if the exceptions thrown in the Purview middleware should be ignored. If set to true, exceptions will be logged but not thrown. | +| GraphBaseUri | Uri | (Optional, https://graph.microsoft.com/v1.0/ by default) The base URI used for calls to Purview's Microsoft Graph APIs. | +| BlockedPromptMessage | string | (Optional, `"Prompt blocked by policies"` by default) The message returned when a prompt is blocked by Purview. | +| BlockedResponseMessage | string | (Optional, `"Response blocked by policies"` by default) The message returned when a response is blocked by Purview. | +| InMemoryCacheSizeLimit | long? | (Optional, `100_000_000` by default) The size limit of the default in-memory cache in bytes. This only applies if no cache is provided when creating the Purview middleware. | +| CacheTTL | TimeSpan | (Optional, 30 minutes by default) The time to live of each cache entry. | +| PendingBackgroundJobLimit | int | (Optional, 100 by default) The maximum number of pending background jobs that can be queued in the middleware. | +| MaxConcurrentJobConsumers | int | (Optional, 10 by default) The maximum number of concurrent consumers that can run background jobs in the middleware. | + +#### `PurviewAppLocation` + +| Field | Type | Purpose | +| ----- | ---- | ------- | +| LocationType | PurviewLocationType | The type of the location: Application, Uri, Domain. | +| LocationValue | string | The value of the location. | + +#### Location + +The `PurviewAppLocation` field of the `PurviewSettings` object contains the location of the app which is used by Purview for policy evaluation (see [policyLocation](https://learn.microsoft.com/en-us/graph/api/resources/policylocation?view=graph-rest-1.0) for more information). +This location can be set to the URL of the agent app, the domain of the agent app, or the application id of the agent app. + +#### Example + +```csharp +var location = new PurviewAppLocation(PurviewLocationType.Uri, "https://contoso.com/chatagent"); +var settings = new PurviewSettings("My Sample App") +{ + AppVersion = "1.0", + TenantId = "your-tenant-id", + PurviewAppLocation = location, + IgnoreExceptions = false, + GraphBaseUri = new Uri("https://graph.microsoft.com/v1.0/"), + BlockedPromptMessage = "Prompt blocked by policies.", + BlockedResponseMessage = "Response blocked by policies.", + InMemoryCacheSizeLimit = 100_000_000, + CacheTTL = TimeSpan.FromMinutes(30), + PendingBackgroundJobLimit = 100, + MaxConcurrentJobConsumers = 10, +}; + +// ... Set up credential and client builder ... + +var client = builder.WithPurview(credential, settings).Build(); +``` + +#### Customizing Blocked Messages + +This is useful for: +- Providing more user-friendly error messages +- Including support contact information +- Localizing messages for different languages +- Adding branding or specific guidance for your application + +``` csharp +var settings = new PurviewSettings("My Sample App") +{ + BlockedPromptMessage = "Your request contains content that violates our policies. Please rephrase and try again.", + BlockedResponseMessage = "The response was blocked due to policy restrictions. Please contact support if you need assistance.", +}; +``` + +### Selecting Agent vs Chat Middleware + +Use the agent middleware when you already have / want the full agent pipeline: + +``` csharp +AIAgent agent = new AzureOpenAIClient( + new Uri(endpoint), + new AzureCliCredential()) + .GetChatClient(deploymentName) + .CreateAIAgent("You are a helpful assistant.") + .AsBuilder() + .WithPurview(browserCredential, new PurviewSettings("Agent Framework Test App")) + .Build(); +``` + +Use the chat middleware when you attach directly to a chat client (e.g. minimal agent shell or custom orchestration): + +``` csharp +IChatClient client = new AzureOpenAIClient( + new Uri(endpoint), + new AzureCliCredential()) + .GetOpenAIResponseClient(deploymentName) + .AsIChatClient() + .AsBuilder() + .WithPurview(browserCredential, new PurviewSettings("Agent Framework Test App")) + .Build(); +``` + +The policy logic is identical; the only difference is the hook point in the pipeline. + +--- + +## Middleware Lifecycle +1. Before sending the prompt to the agent, the middleware checks the app and user metadata against Purview's protection scopes and evaluates all the `ChatMessage`s in the prompt. +2. If the content was blocked, the middleware returns a `ChatResponse` or `AgentRunResponse` containing the `BlockedPromptMessage` text. The blocked content does not get passed to the agent. +3. If the evaluation did not block the content, the middleware passes the prompt data to the agent and waits for a response. +4. After receiving a response from the agent, the middleware calls Purview again to evaluate the response content. +5. If the content was blocked, the middleware returns a response containing the `BlockedResponseMessage`. + +The user id from the prompt message(s) is reused for the response evaluation so both evaluations map consistently to the same user. + +There are several optimizations to speed up Purview calls. Protection scope lookups (the first step in evaluation) are cached to minimize network calls. +If the policies allow content to be processed offline, the middleware will add the process content request to a channel and run it in a background worker. Similarly, the middleware will run a background request if no scopes apply and the interaction only has to be logged in Audit. + +## Exceptions +| Exception | Scenario | +| --------- | -------- | +| PurviewAuthenticationException | Token acquisition / validation issues | +| PurviewJobException | Errors thrown by a background job | +| PurviewJobLimitExceededException | Errors caused by exceeding the background job limit | +| PurviewPaymentRequiredException | 402 responses from the service | +| PurviewRateLimitException | 429 responses from the service | +| PurviewRequestException | Other errors related to Purview requests | +| PurviewException | Base class for all Purview plugin exceptions | + +Callers' exception handling can be fine-grained + +``` csharp +try +{ + // Code that uses Purview middleware +} +catch (PurviewPaymentRequiredException) +{ + this._logger.LogError("Payment required for Purview."); +} +catch (PurviewAuthenticationException) +{ + this._logger.LogError("Error authenticating to Purview."); +} +``` + +Or broad + +``` csharp +try +{ + // Code that uses Purview middleware +} +catch (PurviewException e) +{ + this._logger.LogError(e, "Purview middleware threw an exception.") +} +``` diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs b/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs new file mode 100644 index 0000000000..da9e61a22e --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs @@ -0,0 +1,358 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Purview.Models.Common; +using Microsoft.Agents.AI.Purview.Models.Jobs; +using Microsoft.Agents.AI.Purview.Models.Requests; +using Microsoft.Agents.AI.Purview.Models.Responses; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Purview; + +/// +/// Processor class that combines protectionScopes, processContent, and contentActivities calls. +/// +internal sealed class ScopedContentProcessor : IScopedContentProcessor +{ + private readonly IPurviewClient _purviewClient; + private readonly ICacheProvider _cacheProvider; + private readonly IChannelHandler _channelHandler; + + /// + /// Create a new instance of . + /// + /// The purview client to use for purview requests. + /// The cache used to store Purview data. + /// The channel handler used to manage background jobs. + public ScopedContentProcessor(IPurviewClient purviewClient, ICacheProvider cacheProvider, IChannelHandler channelHandler) + { + this._purviewClient = purviewClient; + this._cacheProvider = cacheProvider; + this._channelHandler = channelHandler; + } + + /// + public async Task<(bool shouldBlock, string? userId)> ProcessMessagesAsync(IEnumerable messages, string? threadId, Activity activity, PurviewSettings purviewSettings, string? userId, CancellationToken cancellationToken) + { + List pcRequests = await this.MapMessageToPCRequestsAsync(messages, threadId, activity, purviewSettings, userId, cancellationToken).ConfigureAwait(false); + + bool shouldBlock = false; + string? resolvedUserId = null; + + foreach (ProcessContentRequest pcRequest in pcRequests) + { + resolvedUserId = pcRequest.UserId; + ProcessContentResponse processContentResponse = await this.ProcessContentWithProtectionScopesAsync(pcRequest, cancellationToken).ConfigureAwait(false); + if (processContentResponse.PolicyActions?.Count > 0) + { + foreach (DlpActionInfo policyAction in processContentResponse.PolicyActions) + { + // We need to process all data before blocking, so set the flag and return it outside of this loop. + if (policyAction.Action == DlpAction.BlockAccess) + { + shouldBlock = true; + } + + if (policyAction.RestrictionAction == RestrictionAction.Block) + { + shouldBlock = true; + } + } + } + } + + return (shouldBlock, resolvedUserId); + } + + private static bool TryGetUserIdFromPayload(IEnumerable messages, out string? userId) + { + userId = null; + + foreach (ChatMessage message in messages) + { + if (message.AdditionalProperties != null && + message.AdditionalProperties.TryGetValue(Constants.UserId, out userId) && + !string.IsNullOrEmpty(userId)) + { + return true; + } + else if (Guid.TryParse(message.AuthorName, out Guid _)) + { + userId = message.AuthorName; + return true; + } + } + + return false; + } + + /// + /// Transform a list of ChatMessages into a list of ProcessContentRequests. + /// + /// The messages to transform. + /// The id of the message thread. + /// The activity performed on the content. + /// The settings used for purview integration. + /// The entra id of the user who made the interaction. + /// The cancellation token used to cancel async operations. + /// A list of process content requests. + private async Task> MapMessageToPCRequestsAsync(IEnumerable messages, string? threadId, Activity activity, PurviewSettings settings, string? userId, CancellationToken cancellationToken) + { + List pcRequests = new(); + TokenInfo? tokenInfo = null; + + bool needUserId = userId == null && TryGetUserIdFromPayload(messages, out userId); + + // Only get user info if the tenant id is null or if there's no location. + // If location is missing, we will create a new location using the client id. + if (settings.TenantId == null || + settings.PurviewAppLocation == null || + needUserId) + { + tokenInfo = await this._purviewClient.GetUserInfoFromTokenAsync(cancellationToken, settings.TenantId).ConfigureAwait(false); + } + + string tenantId = settings.TenantId ?? tokenInfo?.TenantId ?? throw new PurviewRequestException("No tenant id provided or inferred for Purview request. Please provide a tenant id in PurviewSettings or configure the TokenCredential to authenticate to a tenant."); + + foreach (ChatMessage message in messages) + { + string messageId = message.MessageId ?? Guid.NewGuid().ToString(); + ContentBase content = new PurviewTextContent(message.Text); + ProcessConversationMetadata conversationmetadata = new(content, messageId, false, $"Agent Framework Message {messageId}") + { + CorrelationId = threadId ?? Guid.NewGuid().ToString() + }; + ActivityMetadata activityMetadata = new(activity); + PolicyLocation policyLocation; + + if (settings.PurviewAppLocation != null) + { + policyLocation = settings.PurviewAppLocation.GetPolicyLocation(); + } + else if (tokenInfo?.ClientId != null) + { + policyLocation = new($"{Constants.ODataGraphNamespace}.policyLocationApplication", tokenInfo.ClientId); + } + else + { + throw new PurviewRequestException("No app location provided or inferred for Purview request. Please provide an app location in PurviewSettings or configure the TokenCredential to authenticate to an entra app."); + } + + string appVersion = !string.IsNullOrEmpty(settings.AppVersion) ? settings.AppVersion : "Unknown"; + + ProtectedAppMetadata protectedAppMetadata = new(policyLocation) + { + Name = settings.AppName, + Version = appVersion + }; + IntegratedAppMetadata integratedAppMetadata = new() + { + Name = settings.AppName, + Version = appVersion + }; + + DeviceMetadata deviceMetadata = new() + { + OperatingSystemSpecifications = new() + { + OperatingSystemPlatform = "Unknown", + OperatingSystemVersion = "Unknown" + } + }; + ContentToProcess contentToProcess = new(new List { conversationmetadata }, activityMetadata, deviceMetadata, integratedAppMetadata, protectedAppMetadata); + + if (userId == null && + tokenInfo?.UserId != null) + { + userId = tokenInfo.UserId; + } + + if (string.IsNullOrEmpty(userId)) + { + throw new PurviewRequestException("No user id provided or inferred for Purview request. Please provide an Entra user id in each message's AuthorName, set a default Entra user id in PurviewSettings, or configure the TokenCredential to authenticate to an Entra user."); + } + + ProcessContentRequest pcRequest = new(contentToProcess, userId, tenantId); + pcRequests.Add(pcRequest); + } + + return pcRequests; + } + + /// + /// Orchestrates process content and protection scopes calls. + /// + /// The process content request. + /// The cancellation token used to cancel async operations. + /// A process content response. This could be a response from the process content API or a response generated from a content activities call. + private async Task ProcessContentWithProtectionScopesAsync(ProcessContentRequest pcRequest, CancellationToken cancellationToken) + { + ProtectionScopesRequest psRequest = CreateProtectionScopesRequest(pcRequest, pcRequest.UserId, pcRequest.TenantId, pcRequest.CorrelationId); + + ProtectionScopesCacheKey cacheKey = new(psRequest); + + ProtectionScopesResponse? cacheResponse = await this._cacheProvider.GetAsync(cacheKey, cancellationToken).ConfigureAwait(false); + + ProtectionScopesResponse psResponse; + + if (cacheResponse != null) + { + psResponse = cacheResponse; + } + else + { + psResponse = await this._purviewClient.GetProtectionScopesAsync(psRequest, cancellationToken).ConfigureAwait(false); + await this._cacheProvider.SetAsync(cacheKey, psResponse, cancellationToken).ConfigureAwait(false); + } + + pcRequest.ScopeIdentifier = psResponse.ScopeIdentifier; + + (bool shouldProcess, List dlpActions, ExecutionMode executionMode) = CheckApplicableScopes(pcRequest, psResponse); + + if (shouldProcess) + { + if (executionMode == ExecutionMode.EvaluateOffline) + { + this._channelHandler.QueueJob(new ProcessContentJob(pcRequest)); + return new ProcessContentResponse(); + } + + ProcessContentResponse pcResponse = await this._purviewClient.ProcessContentAsync(pcRequest, cancellationToken).ConfigureAwait(false); + + if (pcResponse.ProtectionScopeState == ProtectionScopeState.Modified) + { + await this._cacheProvider.RemoveAsync(cacheKey, cancellationToken).ConfigureAwait(false); + } + + pcResponse = CombinePolicyActions(pcResponse, dlpActions); + return pcResponse; + } + + ContentActivitiesRequest caRequest = new(pcRequest.UserId, pcRequest.TenantId, pcRequest.ContentToProcess, pcRequest.CorrelationId); + this._channelHandler.QueueJob(new ContentActivityJob(caRequest)); + + return new ProcessContentResponse(); + } + + /// + /// Dedupe policy actions received from the service. + /// + /// The process content response which may contain DLP actions. + /// DLP actions returned from protection scopes. + /// The process content response with the protection scopes DLP actions added. Actions are deduplicated. + private static ProcessContentResponse CombinePolicyActions(ProcessContentResponse pcResponse, List? actionInfos) + { + if (actionInfos == null || actionInfos.Count == 0) + { + return pcResponse; + } + + if (pcResponse.PolicyActions == null) + { + pcResponse.PolicyActions = actionInfos; + return pcResponse; + } + + List pcActionInfos = new(pcResponse.PolicyActions); + pcActionInfos.AddRange(actionInfos); + pcResponse.PolicyActions = pcActionInfos; + return pcResponse; + } + + /// + /// Check if any scopes are applicable to the request. + /// + /// The process content request. + /// The protection scopes response that was returned for the process content request. + /// A bool indicating if the content needs to be processed. A list of applicable actions from the scopes response, and the execution mode for the process content request. + private static (bool shouldProcess, List dlpActions, ExecutionMode executionMode) CheckApplicableScopes(ProcessContentRequest pcRequest, ProtectionScopesResponse psResponse) + { + ProtectionScopeActivities requestActivity = TranslateActivity(pcRequest.ContentToProcess.ActivityMetadata.Activity); + + // The location data type is formatted as microsoft.graph.{locationType} + // Sometimes a '#' gets appended by graph during responses, so for the sake of simplicity, + // Split it by '.' and take the last segment. We'll do a case-insensitive endsWith later. + string[] locationSegments = pcRequest.ContentToProcess.ProtectedAppMetadata.ApplicationLocation.DataType.Split('.'); + string locationType = locationSegments.Length > 0 ? locationSegments[locationSegments.Length - 1] : pcRequest.ContentToProcess.ProtectedAppMetadata.ApplicationLocation.Value; + + string locationValue = pcRequest.ContentToProcess.ProtectedAppMetadata.ApplicationLocation.Value; + List dlpActions = new(); + bool shouldProcess = false; + ExecutionMode executionMode = ExecutionMode.EvaluateOffline; + + foreach (var scope in psResponse.Scopes ?? Array.Empty()) + { + bool activityMatch = scope.Activities.HasFlag(requestActivity); + bool locationMatch = false; + + foreach (var location in scope.Locations ?? Array.Empty()) + { + locationMatch = location.DataType.EndsWith(locationType, StringComparison.OrdinalIgnoreCase) && location.Value.Equals(locationValue, StringComparison.OrdinalIgnoreCase); + } + + if (activityMatch && locationMatch) + { + shouldProcess = true; + + if (scope.ExecutionMode == ExecutionMode.EvaluateInline) + { + executionMode = ExecutionMode.EvaluateInline; + } + + if (scope.PolicyActions != null) + { + dlpActions.AddRange(scope.PolicyActions); + } + } + } + + return (shouldProcess, dlpActions, executionMode); + } + + /// + /// Create a ProtectionScopesRequest for the given content ProcessContentRequest. + /// + /// The process content request. + /// The entra user id of the user who sent the data. + /// The tenant id of the user who sent the data. + /// The correlation id of the request. + /// The protection scopes request generated from the process content request. + private static ProtectionScopesRequest CreateProtectionScopesRequest(ProcessContentRequest pcRequest, string userId, string tenantId, Guid correlationId) + { + return new ProtectionScopesRequest(userId, tenantId) + { + Activities = TranslateActivity(pcRequest.ContentToProcess.ActivityMetadata.Activity), + Locations = new List { pcRequest.ContentToProcess.ProtectedAppMetadata.ApplicationLocation }, + DeviceMetadata = pcRequest.ContentToProcess.DeviceMetadata, + IntegratedAppMetadata = pcRequest.ContentToProcess.IntegratedAppMetadata, + CorrelationId = correlationId + }; + } + + /// + /// Map process content activity to protection scope activity. + /// + /// The process content activity. + /// The protection scopes activity. + private static ProtectionScopeActivities TranslateActivity(Activity activity) + { + switch (activity) + { + case Activity.Unknown: + return ProtectionScopeActivities.None; + case Activity.UploadText: + return ProtectionScopeActivities.UploadText; + case Activity.UploadFile: + return ProtectionScopeActivities.UploadFile; + case Activity.DownloadText: + return ProtectionScopeActivities.DownloadText; + case Activity.DownloadFile: + return ProtectionScopeActivities.DownloadFile; + default: + return ProtectionScopeActivities.UnknownFutureValue; + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Serialization/PurviewSerializationUtils.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Serialization/PurviewSerializationUtils.cs new file mode 100644 index 0000000000..320fbcd3b6 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Serialization/PurviewSerializationUtils.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Agents.AI.Purview.Models.Common; +using Microsoft.Agents.AI.Purview.Models.Requests; +using Microsoft.Agents.AI.Purview.Models.Responses; + +namespace Microsoft.Agents.AI.Purview.Serialization; + +/// +/// Source generation context for Purview serialization. +/// +[JsonSerializable(typeof(ProtectionScopesRequest))] +[JsonSerializable(typeof(ProtectionScopesResponse))] +[JsonSerializable(typeof(ProcessContentRequest))] +[JsonSerializable(typeof(ProcessContentResponse))] +[JsonSerializable(typeof(ContentActivitiesRequest))] +[JsonSerializable(typeof(ContentActivitiesResponse))] +[JsonSerializable(typeof(ProtectionScopesCacheKey))] +internal sealed partial class SourceGenerationContext : JsonSerializerContext; + +/// +/// Utility class for Purview serialization settings. +/// +internal static class PurviewSerializationUtils +{ + /// + /// Serialization settings for Purview. + /// + public static JsonSerializerOptions SerializationSettings { get; } = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + WriteIndented = false, + AllowTrailingCommas = false, + DictionaryKeyPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = SourceGenerationContext.Default, + }; +} diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs index 46f893e531..d04d9bb9fb 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs @@ -281,6 +281,8 @@ public sealed partial class ChatClientAgent : AIAgent base.GetService(serviceType, serviceKey) ?? (serviceType == typeof(AIAgentMetadata) ? this._agentMetadata : serviceType == typeof(IChatClient) ? this.ChatClient + : serviceType == typeof(ChatOptions) ? this._agentOptions?.ChatOptions + : serviceType == typeof(ChatClientAgentOptions) ? this._agentOptions : this.ChatClient.GetService(serviceType, serviceKey)); /// diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/InMemoryChatMessageStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/InMemoryChatMessageStoreTests.cs index 4c793d17f4..824fb62f6d 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/InMemoryChatMessageStoreTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/InMemoryChatMessageStoreTests.cs @@ -3,7 +3,9 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text.Encodings.Web; using System.Text.Json; +using System.Text.Json.Serialization.Metadata; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; @@ -114,6 +116,29 @@ public class InMemoryChatMessageStoreTests Assert.Equal("B", newStore[1].Text); } + [Fact] + public async Task SerializeAndDeserializeConstructorRoundtripsWithCustomAIContentAsync() + { + JsonSerializerOptions options = new(TestJsonSerializerContext.Default.Options) + { + TypeInfoResolver = JsonTypeInfoResolver.Combine(AgentAbstractionsJsonUtilities.DefaultOptions.TypeInfoResolver, TestJsonSerializerContext.Default), + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }; + options.AddAIContentType(typeDiscriminatorId: "testContent"); + + var store = new InMemoryChatMessageStore + { + new ChatMessage(ChatRole.User, [new TestAIContent("foo data")]), + }; + + var jsonElement = store.Serialize(options); + var newStore = new InMemoryChatMessageStore(jsonElement, options); + + Assert.Single(newStore); + var actualTestAIContent = Assert.IsType(newStore[0].Contents[0]); + Assert.Equal("foo data", actualTestAIContent.TestData); + } + [Fact] public async Task SerializeAndDeserializeWorksWithExperimentalContentTypesAsync() { @@ -558,4 +583,9 @@ public class InMemoryChatMessageStoreTests Assert.Equal("Hello", result[0].Text); reducerMock.Verify(r => r.ReduceAsync(It.IsAny>(), It.IsAny()), Times.Never); } + + public class TestAIContent(string testData) : AIContent + { + public string TestData => testData; + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/TestJsonSerializerContext.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/TestJsonSerializerContext.cs index b7c553d348..ec343504ab 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/TestJsonSerializerContext.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/TestJsonSerializerContext.cs @@ -22,4 +22,5 @@ namespace Microsoft.Agents.AI.Abstractions.UnitTests; [JsonSerializable(typeof(InMemoryAgentThread.InMemoryAgentThreadState))] [JsonSerializable(typeof(ServiceIdAgentThread.ServiceIdAgentThreadState))] [JsonSerializable(typeof(ServiceIdAgentThreadTests.EmptyObject))] +[JsonSerializable(typeof(InMemoryChatMessageStoreTests.TestAIContent))] internal sealed partial class TestJsonSerializerContext : JsonSerializerContext; diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/AgentEntityTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/AgentEntityTests.cs new file mode 100644 index 0000000000..73c230410c --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/AgentEntityTests.cs @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics; +using System.Reflection; +using Microsoft.DurableTask.Client; +using Microsoft.DurableTask.Client.Entities; +using Microsoft.DurableTask.Entities; +using Microsoft.Extensions.Configuration; +using OpenAI; +using Xunit.Abstractions; + +namespace Microsoft.Agents.AI.DurableTask.IntegrationTests; + +/// +/// Tests for scenarios where an external client interacts with Durable Task Agents. +/// +[Collection("Sequential")] +[Trait("Category", "Integration")] +public sealed class AgentEntityTests(ITestOutputHelper outputHelper) : IDisposable +{ + private static readonly TimeSpan s_defaultTimeout = Debugger.IsAttached + ? TimeSpan.FromMinutes(5) + : TimeSpan.FromSeconds(30); + + private static readonly IConfiguration s_configuration = + new ConfigurationBuilder() + .AddUserSecrets(Assembly.GetExecutingAssembly()) + .AddEnvironmentVariables() + .Build(); + + private readonly ITestOutputHelper _outputHelper = outputHelper; + private readonly CancellationTokenSource _cts = new(delay: s_defaultTimeout); + + private CancellationToken TestTimeoutToken => this._cts.Token; + + public void Dispose() => this._cts.Dispose(); + + [Fact] + public async Task EntityNamePrefixAsync() + { + // Setup + AIAgent simpleAgent = TestHelper.GetAzureOpenAIChatClient(s_configuration).CreateAIAgent( + name: "TestAgent", + instructions: "You are a helpful assistant that always responds with a friendly greeting." + ); + + using TestHelper testHelper = TestHelper.Start([simpleAgent], this._outputHelper); + + // A proxy agent is needed to call the hosted test agent + AIAgent simpleAgentProxy = simpleAgent.AsDurableAgentProxy(testHelper.Services); + + AgentThread thread = simpleAgentProxy.GetNewThread(); + + DurableTaskClient client = testHelper.GetClient(); + + AgentSessionId sessionId = thread.GetService(); + EntityInstanceId expectedEntityId = new($"dafx-{simpleAgent.Name}", sessionId.Key); + + EntityMetadata? entity = await client.Entities.GetEntityAsync(expectedEntityId, false, this.TestTimeoutToken); + + Assert.Null(entity); + + // Act: send a prompt to the agent + await simpleAgentProxy.RunAsync( + message: "Hello!", + thread, + cancellationToken: this.TestTimeoutToken); + + // Assert: verify the agent state was stored with the correct entity name prefix + entity = await client.Entities.GetEntityAsync(expectedEntityId, false, this.TestTimeoutToken); + + Assert.NotNull(entity); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/ExternalClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/ExternalClientTests.cs new file mode 100644 index 0000000000..241e05a843 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/ExternalClientTests.cs @@ -0,0 +1,215 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.ComponentModel; +using System.Diagnostics; +using System.Reflection; +using Microsoft.Agents.AI.DurableTask.IntegrationTests.Logging; +using Microsoft.DurableTask; +using Microsoft.DurableTask.Client; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Configuration; +using OpenAI; +using Xunit.Abstractions; + +namespace Microsoft.Agents.AI.DurableTask.IntegrationTests; + +/// +/// Tests for scenarios where an external client interacts with Durable Task Agents. +/// +[Collection("Sequential")] +[Trait("Category", "Integration")] +public sealed class ExternalClientTests(ITestOutputHelper outputHelper) : IDisposable +{ + private static readonly TimeSpan s_defaultTimeout = Debugger.IsAttached + ? TimeSpan.FromMinutes(5) + : TimeSpan.FromSeconds(30); + + private static readonly IConfiguration s_configuration = + new ConfigurationBuilder() + .AddUserSecrets(Assembly.GetExecutingAssembly()) + .AddEnvironmentVariables() + .Build(); + + private readonly ITestOutputHelper _outputHelper = outputHelper; + private readonly CancellationTokenSource _cts = new(delay: s_defaultTimeout); + + private CancellationToken TestTimeoutToken => this._cts.Token; + + public void Dispose() => this._cts.Dispose(); + + [Fact] + public async Task SimplePromptAsync() + { + // Setup + AIAgent simpleAgent = TestHelper.GetAzureOpenAIChatClient(s_configuration).CreateAIAgent( + instructions: "You are a helpful assistant that always responds with a friendly greeting.", + name: "TestAgent"); + + using TestHelper testHelper = TestHelper.Start([simpleAgent], this._outputHelper); + + // A proxy agent is needed to call the hosted test agent + AIAgent simpleAgentProxy = simpleAgent.AsDurableAgentProxy(testHelper.Services); + + // Act: send a prompt to the agent and wait for a response + AgentThread thread = simpleAgentProxy.GetNewThread(); + await simpleAgentProxy.RunAsync( + message: "Hello!", + thread, + cancellationToken: this.TestTimeoutToken); + + AgentRunResponse response = await simpleAgentProxy.RunAsync( + message: "Repeat what you just said but say it like a pirate", + thread, + cancellationToken: this.TestTimeoutToken); + + // Assert: verify the agent responded appropriately + // We can't predict the exact response, but we can check that there is one response + Assert.NotNull(response); + Assert.NotEmpty(response.Text); + + // Assert: verify the expected log entries were created in the expected category + IReadOnlyCollection logs = testHelper.GetLogs(); + Assert.NotEmpty(logs); + List agentLogs = [.. logs.Where(log => log.Category.Contains(simpleAgent.Name!)).ToList()]; + Assert.NotEmpty(agentLogs); + Assert.Contains(agentLogs, log => log.EventId.Name == "LogAgentRequest" && log.Message.Contains("Hello!")); + Assert.Contains(agentLogs, log => log.EventId.Name == "LogAgentResponse"); + } + + [Fact] + public async Task CallFunctionToolsAsync() + { + int weatherToolInvocationCount = 0; + int packingListToolInvocationCount = 0; + + string GetWeather(string location) + { + weatherToolInvocationCount++; + return $"The weather in {location} is sunny with a high of 75°F and a low of 55°F."; + } + + string SuggestPackingList(string weather, bool isSunny) + { + packingListToolInvocationCount++; + return isSunny ? "Pack sunglasses and sunscreen." : "Pack a raincoat and umbrella."; + } + + AIAgent tripPlanningAgent = TestHelper.GetAzureOpenAIChatClient(s_configuration).CreateAIAgent( + instructions: "You are a trip planning assistant. Use the weather tool and packing list tool as needed.", + name: "TripPlanningAgent", + description: "An agent to help plan your day trips", + tools: [AIFunctionFactory.Create(GetWeather), AIFunctionFactory.Create(SuggestPackingList)] + ); + + using TestHelper testHelper = TestHelper.Start([tripPlanningAgent], this._outputHelper); + AIAgent tripPlanningAgentProxy = tripPlanningAgent.AsDurableAgentProxy(testHelper.Services); + + // Act: send a prompt to the agent + AgentRunResponse response = await tripPlanningAgentProxy.RunAsync( + message: "Help me figure out what to pack for my Seattle trip next Sunday", + cancellationToken: this.TestTimeoutToken); + + // Assert: verify the agent responded appropriately + // We can't predict the exact response, but we can check that there is one response + Assert.NotNull(response); + Assert.NotEmpty(response.Text); + + // Assert: verify the expected log entries were created in the expected category + IReadOnlyCollection logs = testHelper.GetLogs(); + Assert.NotEmpty(logs); + + List agentLogs = [.. logs.Where(log => log.Category.Contains(tripPlanningAgent.Name!)).ToList()]; + Assert.NotEmpty(agentLogs); + Assert.Contains(agentLogs, log => log.EventId.Name == "LogAgentRequest" && log.Message.Contains("Seattle trip")); + Assert.Contains(agentLogs, log => log.EventId.Name == "LogAgentResponse"); + + // Assert: verify the tools were called + Assert.Equal(1, weatherToolInvocationCount); + Assert.Equal(1, packingListToolInvocationCount); + } + + [Fact] + public async Task CallLongRunningFunctionToolsAsync() + { + [Description("Starts a greeting workflow and returns the workflow instance ID")] + string StartWorkflowTool(string name) + { + return DurableAgentContext.Current.ScheduleNewOrchestration(nameof(RunWorkflowAsync), input: name); + } + + [Description("Gets the current status of a previously started workflow. A null response means the workflow has not started yet.")] + static async Task GetWorkflowStatusToolAsync(string instanceId) + { + OrchestrationMetadata? status = await DurableAgentContext.Current.GetOrchestrationStatusAsync( + instanceId, + includeDetails: true); + if (status == null) + { + // If the status is not found, wait a bit before returning null to give the workflow time to start + await Task.Delay(TimeSpan.FromSeconds(1)); + } + + return status; + } + + async Task RunWorkflowAsync(TaskOrchestrationContext context, string name) + { + // 1. Get agent and create a session + DurableAIAgent agent = context.GetAgent("SimpleAgent"); + AgentThread thread = agent.GetNewThread(); + + // 2. Call an agent and tell it my name + await agent.RunAsync($"My name is {name}.", thread); + + // 3. Call the agent again with the same thread (ask it to tell me my name) + AgentRunResponse response = await agent.RunAsync("What is my name?", thread); + + return response.Text; + } + + using TestHelper testHelper = TestHelper.Start( + this._outputHelper, + configureAgents: agents => + { + // This is the agent that will be used to start the workflow + agents.AddAIAgentFactory( + "WorkflowAgent", + sp => TestHelper.GetAzureOpenAIChatClient(s_configuration).CreateAIAgent( + name: "WorkflowAgent", + instructions: "You can start greeting workflows and check their status.", + services: sp, + tools: [ + AIFunctionFactory.Create(StartWorkflowTool), + AIFunctionFactory.Create(GetWorkflowStatusToolAsync) + ])); + + // This is the agent that will be called by the workflow + agents.AddAIAgent(TestHelper.GetAzureOpenAIChatClient(s_configuration).CreateAIAgent( + name: "SimpleAgent", + instructions: "You are a simple assistant." + )); + }, + durableTaskRegistry: registry => registry.AddOrchestratorFunc(nameof(RunWorkflowAsync), RunWorkflowAsync)); + + AIAgent workflowManagerAgentProxy = testHelper.Services.GetDurableAgentProxy("WorkflowAgent"); + + // Act: send a prompt to the agent + AgentThread thread = workflowManagerAgentProxy.GetNewThread(); + await workflowManagerAgentProxy.RunAsync( + message: "Start a greeting workflow for \"John Doe\".", + thread, + cancellationToken: this.TestTimeoutToken); + + // Act: prompt it again to wait for the workflow to complete + AgentRunResponse response = await workflowManagerAgentProxy.RunAsync( + message: "Wait for the workflow to complete and tell me the result.", + thread, + cancellationToken: this.TestTimeoutToken); + + // Assert: verify the agent responded appropriately + // We can't predict the exact response, but we can check that there is one response + Assert.NotNull(response); + Assert.NotEmpty(response.Text); + Assert.Contains("John Doe", response.Text); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/Logging/LogEntry.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/Logging/LogEntry.cs new file mode 100644 index 0000000000..fa9eddaeb4 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/Logging/LogEntry.cs @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.Logging; + +namespace Microsoft.Agents.AI.DurableTask.IntegrationTests.Logging; + +internal sealed class LogEntry( + string category, + LogLevel level, + EventId eventId, + Exception? exception, + string message, + object? state, + IReadOnlyList> contextProperties) +{ + public string Category { get; } = category; + + public DateTime Timestamp { get; } = DateTime.Now; + + public EventId EventId { get; } = eventId; + + public LogLevel LogLevel { get; } = level; + + public Exception? Exception { get; } = exception; + + public string Message { get; } = message; + + public object? State { get; } = state; + + public IReadOnlyList> ContextProperties { get; } = contextProperties; + + public override string ToString() + { + string properties = this.ContextProperties.Count > 0 + ? $"[{string.Join(", ", this.ContextProperties.Select(kvp => $"{kvp.Key}={kvp.Value}"))}] " + : string.Empty; + + string eventName = this.EventId.Name ?? string.Empty; + string output = $"{this.Timestamp:o} [{this.Category}] {eventName} {properties}{this.Message}"; + + if (this.Exception is not null) + { + output += Environment.NewLine + this.Exception; + } + + return output; + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/Logging/TestLogger.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/Logging/TestLogger.cs new file mode 100644 index 0000000000..ca80b8cf7b --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/Logging/TestLogger.cs @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Concurrent; +using Microsoft.Extensions.Logging; +using Xunit.Abstractions; + +namespace Microsoft.Agents.AI.DurableTask.IntegrationTests.Logging; + +internal sealed class TestLogger(string category, ITestOutputHelper output) : ILogger +{ + private readonly string _category = category; + private readonly ITestOutputHelper _output = output; + private readonly ConcurrentQueue _entries = new(); + + public IReadOnlyCollection GetLogs() => this._entries; + + public void ClearLogs() => this._entries.Clear(); + + IDisposable? ILogger.BeginScope(TState state) => null; + + bool ILogger.IsEnabled(LogLevel logLevel) => true; + + void ILogger.Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + LogEntry entry = new( + category: this._category, + level: logLevel, + eventId: eventId, + exception: exception, + message: formatter(state, exception), + state: state, + contextProperties: []); + + this._entries.Enqueue(entry); + + try + { + this._output.WriteLine(entry.ToString()); + } + catch (InvalidOperationException) + { + // Expected when tests are shutting down + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/Logging/TestLoggerProvider.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/Logging/TestLoggerProvider.cs new file mode 100644 index 0000000000..7019852e5e --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/Logging/TestLoggerProvider.cs @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Concurrent; +using Microsoft.Extensions.Logging; +using Xunit.Abstractions; + +namespace Microsoft.Agents.AI.DurableTask.IntegrationTests.Logging; + +internal sealed class TestLoggerProvider(ITestOutputHelper output) : ILoggerProvider +{ + private readonly ITestOutputHelper _output = output ?? throw new ArgumentNullException(nameof(output)); + private readonly ConcurrentDictionary _loggers = new(StringComparer.OrdinalIgnoreCase); + + public bool TryGetLogs(string category, out IReadOnlyCollection logs) + { + if (this._loggers.TryGetValue(category, out TestLogger? logger)) + { + logs = logger.GetLogs(); + return true; + } + + logs = []; + return false; + } + + public IReadOnlyCollection GetAllLogs() + { + return this._loggers.Values + .OfType() + .SelectMany(logger => logger.GetLogs()) + .ToList() + .AsReadOnly(); + } + + public void Clear() + { + foreach (TestLogger logger in this._loggers.Values.OfType()) + { + logger.ClearLogs(); + } + } + + ILogger ILoggerProvider.CreateLogger(string categoryName) + { + return this._loggers.GetOrAdd(categoryName, _ => new TestLogger(categoryName, this._output)); + } + + void IDisposable.Dispose() + { + // no-op + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/Microsoft.Agents.AI.DurableTask.IntegrationTests.csproj b/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/Microsoft.Agents.AI.DurableTask.IntegrationTests.csproj new file mode 100644 index 0000000000..7150e74bd8 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/Microsoft.Agents.AI.DurableTask.IntegrationTests.csproj @@ -0,0 +1,23 @@ + + + + $(ProjectsCoreTargetFrameworks) + $(ProjectsDebugCoreTargetFrameworks) + enable + b7762d10-e29b-4bb1-8b74-b6d69a667dd4 + + + + + + + + + + + + + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/TestHelper.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/TestHelper.cs new file mode 100644 index 0000000000..15526621d1 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/TestHelper.cs @@ -0,0 +1,139 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Azure; +using Azure.AI.OpenAI; +using Azure.Identity; +using Microsoft.Agents.AI.DurableTask.IntegrationTests.Logging; +using Microsoft.DurableTask; +using Microsoft.DurableTask.Client; +using Microsoft.DurableTask.Client.AzureManaged; +using Microsoft.DurableTask.Worker; +using Microsoft.DurableTask.Worker.AzureManaged; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using OpenAI.Chat; +using Xunit.Abstractions; + +namespace Microsoft.Agents.AI.DurableTask.IntegrationTests; + +internal sealed class TestHelper : IDisposable +{ + private readonly TestLoggerProvider _loggerProvider; + private readonly IHost _host; + private readonly DurableTaskClient _client; + + // The static Start method should be used to create instances of this class. + private TestHelper( + TestLoggerProvider loggerProvider, + IHost host, + DurableTaskClient client) + { + this._loggerProvider = loggerProvider; + this._host = host; + this._client = client; + } + + public IServiceProvider Services => this._host.Services; + + public void Dispose() + { + this._host.Dispose(); + } + + public bool TryGetLogs(string category, out IReadOnlyCollection logs) + => this._loggerProvider.TryGetLogs(category, out logs); + + public static TestHelper Start( + AIAgent[] agents, + ITestOutputHelper outputHelper, + Action? durableTaskRegistry = null) + { + return BuildAndStartTestHelper( + outputHelper, + options => options.AddAIAgents(agents), + durableTaskRegistry); + } + + public static TestHelper Start( + ITestOutputHelper outputHelper, + Action configureAgents, + Action? durableTaskRegistry = null) + { + return BuildAndStartTestHelper( + outputHelper, + configureAgents, + durableTaskRegistry); + } + + public DurableTaskClient GetClient() => this._client; + + private static TestHelper BuildAndStartTestHelper( + ITestOutputHelper outputHelper, + Action configureAgents, + Action? durableTaskRegistry) + { + TestLoggerProvider loggerProvider = new(outputHelper); + + IHost host = Host.CreateDefaultBuilder() + .ConfigureServices((ctx, services) => + { + string dtsConnectionString = GetDurableTaskSchedulerConnectionString(ctx.Configuration); + + // Register durable agents using the caller-supplied registration action and + // apply the default chat client for agents that don't supply one themselves. + services.ConfigureDurableAgents( + options => configureAgents(options), + workerBuilder: builder => + { + builder.UseDurableTaskScheduler(dtsConnectionString); + if (durableTaskRegistry != null) + { + builder.AddTasks(durableTaskRegistry); + } + }, + clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString)); + }) + .ConfigureLogging((_, logging) => + { + logging.AddProvider(loggerProvider); + logging.SetMinimumLevel(LogLevel.Debug); + }) + .Build(); + host.Start(); + + DurableTaskClient client = host.Services.GetRequiredService(); + return new TestHelper(loggerProvider, host, client); + } + + private static string GetDurableTaskSchedulerConnectionString(IConfiguration configuration) + { + // The default value is for local development using the Durable Task Scheduler emulator. + return configuration["DURABLE_TASK_SCHEDULER_CONNECTION_STRING"] + ?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None"; + } + + internal static ChatClient GetAzureOpenAIChatClient(IConfiguration configuration) + { + string azureOpenAiEndpoint = configuration["AZURE_OPENAI_ENDPOINT"] ?? + throw new InvalidOperationException("The required AZURE_OPENAI_ENDPOINT env variable is not set."); + string azureOpenAiDeploymentName = configuration["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"] ?? + throw new InvalidOperationException("The required AZURE_OPENAI_CHAT_DEPLOYMENT_NAME env variable is not set."); + + // Check if AZURE_OPENAI_KEY is provided for key-based authentication. + // NOTE: This is not used for automated tests, but can be useful for local development. + string? azureOpenAiKey = configuration["AZURE_OPENAI_KEY"]; + + AzureOpenAIClient client = !string.IsNullOrEmpty(azureOpenAiKey) + ? new AzureOpenAIClient(new Uri(azureOpenAiEndpoint), new AzureKeyCredential(azureOpenAiKey)) + : new AzureOpenAIClient(new Uri(azureOpenAiEndpoint), new AzureCliCredential()); + + return client.GetChatClient(azureOpenAiDeploymentName); + } + + internal IReadOnlyCollection GetLogs() + { + return this._loggerProvider.GetAllLogs(); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/AgentSessionIdTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/AgentSessionIdTests.cs new file mode 100644 index 0000000000..03d171b7b3 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/AgentSessionIdTests.cs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.DurableTask.Entities; + +namespace Microsoft.Agents.AI.DurableTask.UnitTests; + +public sealed class AgentSessionIdTests +{ + [Fact] + public void ParseValidSessionId() + { + const string Name = "test-agent"; + const string Key = "12345"; + string sessionIdString = $"@dafx-{Name}@{Key}"; + AgentSessionId sessionId = AgentSessionId.Parse(sessionIdString); + + Assert.Equal(Name, sessionId.Name); + Assert.Equal(Key, sessionId.Key); + } + + [Fact] + public void ParseInvalidSessionId() + { + const string InvalidSessionIdString = "@test-agent@12345"; // Missing "dafx-" prefix + Assert.Throws(() => AgentSessionId.Parse(InvalidSessionIdString)); + } + + [Fact] + public void FromEntityId() + { + const string Name = "test-agent"; + const string Key = "12345"; + + EntityInstanceId entityId = new($"dafx-{Name}", Key); + AgentSessionId sessionId = (AgentSessionId)entityId; + + Assert.Equal(Name, sessionId.Name); + Assert.Equal(Key, sessionId.Key); + } + + [Fact] + public void FromInvalidEntityId() + { + const string Name = "test-agent"; + const string Key = "12345"; + + EntityInstanceId entityId = new(Name, Key); // Missing "dafx-" prefix + + Assert.Throws(() => + { + // This assignment should throw an exception because + // the entity ID is not a valid agent session ID. + AgentSessionId sessionId = entityId; + }); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/DurableAgentThreadTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/DurableAgentThreadTests.cs new file mode 100644 index 0000000000..7e5a776beb --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/DurableAgentThreadTests.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; + +namespace Microsoft.Agents.AI.DurableTask.UnitTests; + +public sealed class DurableAgentThreadTests +{ + [Fact] + public void BuiltInSerialization() + { + AgentSessionId sessionId = AgentSessionId.WithRandomKey("test-agent"); + AgentThread thread = new DurableAgentThread(sessionId); + + JsonElement serializedThread = thread.Serialize(); + + // Expected format: "{\"sessionId\":\"@dafx-test-agent@\"}" + string expectedSerializedThread = $"{{\"sessionId\":\"@dafx-{sessionId.Name}@{sessionId.Key}\"}}"; + Assert.Equal(expectedSerializedThread, serializedThread.ToString()); + + DurableAgentThread deserializedThread = DurableAgentThread.Deserialize(serializedThread); + Assert.Equal(sessionId, deserializedThread.SessionId); + } + + [Fact] + public void STJSerialization() + { + AgentSessionId sessionId = AgentSessionId.WithRandomKey("test-agent"); + AgentThread thread = new DurableAgentThread(sessionId); + + // Need to specify the type explicitly because STJ, unlike other serializers, + // does serialization based on the static type of the object, not the runtime type. + string serializedThread = JsonSerializer.Serialize(thread, typeof(DurableAgentThread)); + + // Expected format: "{\"sessionId\":\"@dafx-test-agent@\"}" + string expectedSerializedThread = $"{{\"sessionId\":\"@dafx-{sessionId.Name}@{sessionId.Key}\"}}"; + Assert.Equal(expectedSerializedThread, serializedThread); + + DurableAgentThread? deserializedThread = JsonSerializer.Deserialize(serializedThread); + Assert.NotNull(deserializedThread); + Assert.Equal(sessionId, deserializedThread.SessionId); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Microsoft.Agents.AI.DurableTask.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Microsoft.Agents.AI.DurableTask.UnitTests.csproj new file mode 100644 index 0000000000..b413733f2b --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Microsoft.Agents.AI.DurableTask.UnitTests.csproj @@ -0,0 +1,14 @@ + + + + $(ProjectsCoreTargetFrameworks) + $(ProjectsDebugCoreTargetFrameworks) + enable + b7762d10-e29b-4bb1-8b74-b6d69a667dd4 + + + + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateContentTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateContentTests.cs new file mode 100644 index 0000000000..2fda1178e1 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateContentTests.cs @@ -0,0 +1,324 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using Microsoft.Agents.AI.DurableTask.State; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.DurableTask.Tests.Unit.State; + +public sealed class DurableAgentStateContentTests +{ + private static readonly JsonTypeInfo s_stateContentTypeInfo = + DurableAgentStateJsonContext.Default.GetTypeInfo(typeof(DurableAgentStateContent))!; + + [Fact] + public void ErrorContentSerializationDeserialization() + { + // Arrange + ErrorContent errorContent = new("message") + { + Details = "details", + ErrorCode = "code" + }; + + DurableAgentStateContent durableContent = DurableAgentStateContent.FromAIContent(errorContent); + + // Act + string jsonContent = JsonSerializer.Serialize(durableContent, s_stateContentTypeInfo); + + DurableAgentStateContent? convertedJsonContent = + (DurableAgentStateContent?)JsonSerializer.Deserialize(jsonContent, s_stateContentTypeInfo); + + // Assert + Assert.NotNull(convertedJsonContent); + + AIContent convertedContent = convertedJsonContent.ToAIContent(); + + ErrorContent convertedErrorContent = Assert.IsType(convertedContent); + + Assert.Equal(errorContent.Message, convertedErrorContent.Message); + Assert.Equal(errorContent.Details, convertedErrorContent.Details); + Assert.Equal(errorContent.ErrorCode, convertedErrorContent.ErrorCode); + } + + [Fact] + public void TextContentSerializationDeserialization() + { + // Arrange + TextContent textContent = new("Hello, world!"); + + DurableAgentStateContent durableContent = DurableAgentStateContent.FromAIContent(textContent); + + // Act + string jsonContent = JsonSerializer.Serialize(durableContent, s_stateContentTypeInfo); + + DurableAgentStateContent? convertedJsonContent = + (DurableAgentStateContent?)JsonSerializer.Deserialize(jsonContent, s_stateContentTypeInfo); + + // Assert + Assert.NotNull(convertedJsonContent); + + AIContent convertedContent = convertedJsonContent.ToAIContent(); + + TextContent convertedTextContent = Assert.IsType(convertedContent); + + Assert.Equal(textContent.Text, convertedTextContent.Text); + } + + [Fact] + public void FunctionCallContentSerializationDeserialization() + { + // Arrange + FunctionCallContent functionCallContent = new( + "call-123", + "MyFunction", + new Dictionary + { + { "param1", 42 }, + { "param2", "value" } + }); + + DurableAgentStateContent durableContent = DurableAgentStateContent.FromAIContent(functionCallContent); + + // Act + string jsonContent = JsonSerializer.Serialize(durableContent, s_stateContentTypeInfo); + + DurableAgentStateContent? convertedJsonContent = + (DurableAgentStateContent?)JsonSerializer.Deserialize(jsonContent, s_stateContentTypeInfo); + + // Assert + Assert.NotNull(convertedJsonContent); + + AIContent convertedContent = convertedJsonContent.ToAIContent(); + + FunctionCallContent convertedFunctionCallContent = Assert.IsType(convertedContent); + + Assert.Equal(functionCallContent.CallId, convertedFunctionCallContent.CallId); + Assert.Equal(functionCallContent.Name, convertedFunctionCallContent.Name); + + Assert.NotNull(functionCallContent.Arguments); + Assert.NotNull(convertedFunctionCallContent.Arguments); + Assert.Equal(functionCallContent.Arguments.Keys.Order(), convertedFunctionCallContent.Arguments.Keys.Order()); + + // NOTE: Deserialized dictionaries will have JSON element values rather than the original native types, + // so we only check the keys here. + foreach (string key in functionCallContent.Arguments.Keys) + { + Assert.Equal( + JsonSerializer.Serialize(functionCallContent.Arguments[key]), + JsonSerializer.Serialize(convertedFunctionCallContent.Arguments[key])); + } + } + + [Fact] + public void FunctionResultContentSerializationDeserialization() + { + // Arrange + FunctionResultContent functionResultContent = new("call-123", "return value"); + + DurableAgentStateContent durableContent = DurableAgentStateContent.FromAIContent(functionResultContent); + + // Act + string jsonContent = JsonSerializer.Serialize(durableContent, s_stateContentTypeInfo); + + DurableAgentStateContent? convertedJsonContent = + (DurableAgentStateContent?)JsonSerializer.Deserialize(jsonContent, s_stateContentTypeInfo); + + // Assert + Assert.NotNull(convertedJsonContent); + + AIContent convertedContent = convertedJsonContent.ToAIContent(); + + FunctionResultContent convertedFunctionResultContent = Assert.IsType(convertedContent); + + Assert.Equal(functionResultContent.CallId, convertedFunctionResultContent.CallId); + // NOTE: We serialize both results to JSON for comparison since deserialized objects will be + // JSON elements rather than the original native types. + Assert.Equal( + JsonSerializer.Serialize(functionResultContent.Result), + JsonSerializer.Serialize(convertedFunctionResultContent.Result)); + } + + [Theory] + [InlineData("data:text/plain;base64,SGVsbG8sIFdvcmxkIQ==", null)] // Valid data URI containing media type; pass null for separate mediaType parameter. + [InlineData("data:;base64,SGVsbG8sIFdvcmxkIQ==", "text/plain")] // Valid data URI without media type; pass media + public void DataContentSerializationDeserialization(string dataUri, string? mediaType) + { + // Arrange + DataContent dataContent = new(dataUri, mediaType); + + DurableAgentStateContent durableContent = DurableAgentStateContent.FromAIContent(dataContent); + + // Act + string jsonContent = JsonSerializer.Serialize(durableContent, s_stateContentTypeInfo); + + DurableAgentStateContent? convertedJsonContent = + (DurableAgentStateContent?)JsonSerializer.Deserialize(jsonContent, s_stateContentTypeInfo); + + // Assert + Assert.NotNull(convertedJsonContent); + + AIContent convertedContent = convertedJsonContent.ToAIContent(); + + DataContent convertedDataContent = Assert.IsType(convertedContent); + + Assert.Equal(dataContent.Uri, convertedDataContent.Uri); + Assert.Equal(dataContent.MediaType, convertedDataContent.MediaType); + } + + [Fact] + public void HostedFileContentSerializationDeserialization() + { + // Arrange + HostedFileContent hostedFileContent = new("file-123"); + + DurableAgentStateContent durableContent = DurableAgentStateContent.FromAIContent(hostedFileContent); + + // Act + string jsonContent = JsonSerializer.Serialize(durableContent, s_stateContentTypeInfo); + + DurableAgentStateContent? convertedJsonContent = + (DurableAgentStateContent?)JsonSerializer.Deserialize(jsonContent, s_stateContentTypeInfo); + + // Assert + Assert.NotNull(convertedJsonContent); + + AIContent convertedContent = convertedJsonContent.ToAIContent(); + + HostedFileContent convertedHostedFileContent = Assert.IsType(convertedContent); + + Assert.Equal(hostedFileContent.FileId, convertedHostedFileContent.FileId); + } + + [Fact] + public void HostedVectorStoreContentSerializationDeserialization() + { + // Arrange + HostedVectorStoreContent hostedVectorStoreContent = new("vs-123"); + + DurableAgentStateContent durableContent = DurableAgentStateContent.FromAIContent(hostedVectorStoreContent); + + // Act + string jsonContent = JsonSerializer.Serialize(durableContent, s_stateContentTypeInfo); + + DurableAgentStateContent? convertedJsonContent = + (DurableAgentStateContent?)JsonSerializer.Deserialize(jsonContent, s_stateContentTypeInfo); + + // Assert + Assert.NotNull(convertedJsonContent); + + AIContent convertedContent = convertedJsonContent.ToAIContent(); + + HostedVectorStoreContent convertedHostedVectorStoreContent = Assert.IsType(convertedContent); + + Assert.Equal(hostedVectorStoreContent.VectorStoreId, convertedHostedVectorStoreContent.VectorStoreId); + } + + [Fact] + public void TextReasoningContentSerializationDeserialization() + { + // Arrange + TextReasoningContent textReasoningContent = new("Reasoning chain..."); + + DurableAgentStateContent durableContent = DurableAgentStateContent.FromAIContent(textReasoningContent); + + // Act + string jsonContent = JsonSerializer.Serialize(durableContent, s_stateContentTypeInfo); + + DurableAgentStateContent? convertedJsonContent = + (DurableAgentStateContent?)JsonSerializer.Deserialize(jsonContent, s_stateContentTypeInfo); + + // Assert + Assert.NotNull(convertedJsonContent); + + AIContent convertedContent = convertedJsonContent.ToAIContent(); + + TextReasoningContent convertedTextReasoningContent = Assert.IsType(convertedContent); + + Assert.Equal(textReasoningContent.Text, convertedTextReasoningContent.Text); + } + + [Fact] + public void UriContentSerializationDeserialization() + { + // Arrange + UriContent uriContent = new(new Uri("https://example.com"), "text/html"); + + DurableAgentStateContent durableContent = DurableAgentStateContent.FromAIContent(uriContent); + + // Act + string jsonContent = JsonSerializer.Serialize(durableContent, s_stateContentTypeInfo); + + DurableAgentStateContent? convertedJsonContent = + (DurableAgentStateContent?)JsonSerializer.Deserialize(jsonContent, s_stateContentTypeInfo); + + // Assert + Assert.NotNull(convertedJsonContent); + + AIContent convertedContent = convertedJsonContent.ToAIContent(); + + UriContent convertedUriContent = Assert.IsType(convertedContent); + + Assert.Equal(uriContent.Uri, convertedUriContent.Uri); + Assert.Equal(uriContent.MediaType, convertedUriContent.MediaType); + } + + [Fact] + public void UsageContentSerializationDeserialization() + { + // Arrange + UsageDetails usageDetails = new() + { + InputTokenCount = 10, + OutputTokenCount = 5, + TotalTokenCount = 15 + }; + + UsageContent usageContent = new(usageDetails); + + DurableAgentStateContent durableContent = DurableAgentStateContent.FromAIContent(usageContent); + + // Act + string jsonContent = JsonSerializer.Serialize(durableContent, s_stateContentTypeInfo); + + DurableAgentStateContent? convertedJsonContent = + (DurableAgentStateContent?)JsonSerializer.Deserialize(jsonContent, s_stateContentTypeInfo); + + // Assert + Assert.NotNull(convertedJsonContent); + + AIContent convertedContent = convertedJsonContent.ToAIContent(); + + UsageContent convertedUsageContent = Assert.IsType(convertedContent); + + Assert.NotNull(convertedUsageContent.Details); + Assert.Equal(usageDetails.InputTokenCount, convertedUsageContent.Details.InputTokenCount); + Assert.Equal(usageDetails.OutputTokenCount, convertedUsageContent.Details.OutputTokenCount); + Assert.Equal(usageDetails.TotalTokenCount, convertedUsageContent.Details.TotalTokenCount); + } + + [Fact] + public void UnknownContentSerializationDeserialization() + { + // Arrange + TextContent originalContent = new("Some unknown content"); + + DurableAgentStateContent durableContent = DurableAgentStateUnknownContent.FromUnknownContent(originalContent); + + // Act + string jsonContent = JsonSerializer.Serialize(durableContent, s_stateContentTypeInfo); + + DurableAgentStateContent? convertedJsonContent = + (DurableAgentStateContent?)JsonSerializer.Deserialize(jsonContent, s_stateContentTypeInfo); + + // Assert + Assert.NotNull(convertedJsonContent); + + AIContent convertedContent = convertedJsonContent.ToAIContent(); + + TextContent convertedTextContent = Assert.IsType(convertedContent); + + Assert.Equal(originalContent.Text, convertedTextContent.Text); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateMessageTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateMessageTests.cs new file mode 100644 index 0000000000..343644d911 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateMessageTests.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using Microsoft.Agents.AI.DurableTask.State; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.DurableTask.Tests.Unit.State; + +public sealed class DurableAgentStateMessageTests +{ + [Fact] + public void MessageSerializationDeserialization() + { + // Arrange + TextContent textContent = new("Hello, world!"); + ChatMessage message = new(ChatRole.User, [textContent]) + { + AuthorName = "User123", + CreatedAt = DateTimeOffset.UtcNow + }; + + DurableAgentStateMessage durableMessage = DurableAgentStateMessage.FromChatMessage(message); + + // Act + string jsonContent = JsonSerializer.Serialize( + durableMessage, + DurableAgentStateJsonContext.Default.GetTypeInfo(typeof(DurableAgentStateMessage))!); + + DurableAgentStateMessage? convertedJsonContent = (DurableAgentStateMessage?)JsonSerializer.Deserialize( + jsonContent, + DurableAgentStateJsonContext.Default.GetTypeInfo(typeof(DurableAgentStateMessage))!); + + // Assert + Assert.NotNull(convertedJsonContent); + + ChatMessage convertedMessage = convertedJsonContent.ToChatMessage(); + + Assert.Equal(message.AuthorName, convertedMessage.AuthorName); + Assert.Equal(message.CreatedAt, convertedMessage.CreatedAt); + Assert.Equal(message.Role, convertedMessage.Role); + + AIContent convertedContent = Assert.Single(convertedMessage.Contents); + TextContent convertedTextContent = Assert.IsType(convertedContent); + + Assert.Equal(textContent.Text, convertedTextContent.Text); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateTests.cs new file mode 100644 index 0000000000..f8ce5c6dec --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateTests.cs @@ -0,0 +1,170 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using Microsoft.Agents.AI.DurableTask.State; + +namespace Microsoft.Agents.AI.DurableTask.Tests.Unit.State; + +public sealed class DurableAgentStateTests +{ + [Fact] + public void InvalidVersion() + { + // Arrange + const string JsonText = """ + { + "schemaVersion": "hello" + } + """; + + // Act & Assert + Assert.Throws( + () => JsonSerializer.Deserialize(JsonText, DurableAgentStateJsonContext.Default.DurableAgentState)); + } + + [Fact] + public void BreakingVersion() + { + // Arrange + const string JsonText = """ + { + "schemaVersion": "2.0.0" + } + """; + + // Act & Assert + Assert.Throws( + () => JsonSerializer.Deserialize(JsonText, DurableAgentStateJsonContext.Default.DurableAgentState)); + } + + [Fact] + public void MissingData() + { + // Arrange + const string JsonText = """ + { + "schemaVersion": "1.0.0" + } + """; + + // Act & Assert + Assert.Throws( + () => JsonSerializer.Deserialize(JsonText, DurableAgentStateJsonContext.Default.DurableAgentState)); + } + + [Fact] + public void ExtraData() + { + // Arrange + const string JsonText = """ + { + "schemaVersion": "1.0.0", + "data": { + "conversationHistory": [], + "extraField": "someValue" + } + } + """; + + // Act + DurableAgentState? state = JsonSerializer.Deserialize(JsonText, DurableAgentStateJsonContext.Default.DurableAgentState); + + // Assert + Assert.NotNull(state?.Data?.ExtensionData); + + Assert.True(state.Data.ExtensionData!.ContainsKey("extraField")); + Assert.Equal("someValue", state.Data.ExtensionData["extraField"]!.ToString()); + + // Act + string jsonState = JsonSerializer.Serialize(state, DurableAgentStateJsonContext.Default.DurableAgentState); + JsonDocument? jsonDocument = JsonSerializer.Deserialize(jsonState); + + // Assert + Assert.NotNull(jsonDocument); + Assert.True(jsonDocument.RootElement.TryGetProperty("data", out JsonElement dataElement)); + Assert.True(dataElement.TryGetProperty("extraField", out JsonElement extraFieldElement)); + Assert.Equal("someValue", extraFieldElement.ToString()); + } + + [Fact] + public void BasicState() + { + // Arrange + const string JsonText = """ + { + "schemaVersion": "1.0.0", + "data": { + "conversationHistory": [ + { + "$type": "request", + "correlationId": "12345", + "createdAt": "2024-01-01T12:00:00Z", + "messages": [ + { + "role": "user", + "contents": [ + { + "$type": "text", + "text": "Hello, agent!" + } + ] + } + ] + }, + { + "$type": "response", + "correlationId": "12345", + "createdAt": "2024-01-01T12:01:00Z", + "messages": [ + { + "role": "agent", + "contents": [ + { + "$type": "text", + "text": "Hi user!" + } + ] + } + ] + } + ] + } + } + """; + + // Act + DurableAgentState? state = JsonSerializer.Deserialize( + JsonText, + DurableAgentStateJsonContext.Default.DurableAgentState); + + // Assert + Assert.NotNull(state); + Assert.Equal("1.0.0", state.SchemaVersion); + Assert.NotNull(state.Data); + + Assert.Collection(state.Data.ConversationHistory, + entry => + { + Assert.IsType(entry); + Assert.Equal("12345", entry.CorrelationId); + Assert.Equal(DateTimeOffset.Parse("2024-01-01T12:00:00Z"), entry.CreatedAt); + Assert.Single(entry.Messages); + Assert.Equal("user", entry.Messages[0].Role); + DurableAgentStateContent content = Assert.Single(entry.Messages[0].Contents); + DurableAgentStateTextContent textContent = Assert.IsType(content); + Assert.Equal("Hello, agent!", textContent.Text); + }, + entry => + { + Assert.IsType(entry); + Assert.Equal("12345", entry.CorrelationId); + Assert.Equal(DateTimeOffset.Parse("2024-01-01T12:01:00Z"), entry.CreatedAt); + Assert.Single(entry.Messages); + Assert.Equal("agent", entry.Messages[0].Role); + Assert.Single(entry.Messages[0].Contents); + DurableAgentStateContent content = Assert.Single(entry.Messages[0].Contents); + DurableAgentStateTextContent textContent = Assert.IsType(content); + Assert.Equal("Hi user!", textContent.Text); + }); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests.csproj new file mode 100644 index 0000000000..ae816efb7f --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests.csproj @@ -0,0 +1,19 @@ + + + + $(ProjectsCoreTargetFrameworks) + $(ProjectsDebugCoreTargetFrameworks) + enable + b7762d10-e29b-4bb1-8b74-b6d69a667dd4 + + + + + + + + + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/SamplesValidation.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/SamplesValidation.cs new file mode 100644 index 0000000000..f2bfcf234c --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/SamplesValidation.cs @@ -0,0 +1,816 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics; +using System.Reflection; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using Xunit.Abstractions; + +namespace Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests; + +[Collection("Samples")] +[Trait("Category", "SampleValidation")] +public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLifetime +{ + private const string AzureFunctionsPort = "7071"; + private const string AzuritePort = "10000"; + private const string DtsPort = "8080"; + + private static readonly string s_dotnetTargetFramework = GetTargetFramework(); + private static readonly HttpClient s_sharedHttpClient = new(); + private static readonly IConfiguration s_configuration = + new ConfigurationBuilder() + .AddUserSecrets(Assembly.GetExecutingAssembly()) + .AddEnvironmentVariables() + .Build(); + + private static bool s_infrastructureStarted; + private static readonly TimeSpan s_orchestrationTimeout = TimeSpan.FromMinutes(1); + private static readonly string s_samplesPath = Path.GetFullPath( + Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "..", "..", "..", "..", "..", "samples", "AzureFunctions")); + + private readonly ITestOutputHelper _outputHelper = outputHelper; + + async Task IAsyncLifetime.InitializeAsync() + { + if (!s_infrastructureStarted) + { + await this.StartSharedInfrastructureAsync(); + s_infrastructureStarted = true; + } + } + + async Task IAsyncLifetime.DisposeAsync() + { + // Nothing to clean up + await Task.CompletedTask; + } + + [Fact] + public async Task SingleAgentSampleValidationAsync() + { + string samplePath = Path.Combine(s_samplesPath, "01_SingleAgent"); + await this.RunSampleTestAsync(samplePath, async (logs) => + { + Uri startUri = new($"http://localhost:{AzureFunctionsPort}/api/agents/Joker/run"); + this._outputHelper.WriteLine($"Starting single agent orchestration via POST request to {startUri}..."); + + // Test the agent endpoint as described in the README + const string RequestBody = "Tell me a joke about a pirate."; + using HttpContent content = new StringContent(RequestBody, Encoding.UTF8, "text/plain"); + + using HttpResponseMessage response = await s_sharedHttpClient.PostAsync(startUri, content); + + // The response is expected to be a plain text response with the agent's reply (the joke) + Assert.True(response.IsSuccessStatusCode, $"Agent request failed with status: {response.StatusCode}"); + Assert.Equal("text/plain", response.Content.Headers.ContentType?.MediaType); + string responseText = await response.Content.ReadAsStringAsync(); + Assert.NotEmpty(responseText); + this._outputHelper.WriteLine($"Agent run response: {responseText}"); + + // The response headers should include the agent thread ID, which can be used to continue the conversation. + string? threadId = response.Headers.GetValues("x-ms-thread-id")?.FirstOrDefault(); + Assert.NotNull(threadId); + + this._outputHelper.WriteLine($"Agent thread ID: {threadId}"); + Assert.StartsWith("@dafx-joker@", threadId); + + // Wait for up to 30 seconds to see if the agent response is available in the logs + await this.WaitForConditionAsync( + condition: () => + { + lock (logs) + { + bool exists = logs.Any( + log => log.Message.Contains("Response:") && log.Message.Contains(threadId)); + return Task.FromResult(exists); + } + }, + message: "Agent response is available", + timeout: TimeSpan.FromSeconds(30)); + }); + } + + [Fact] + public async Task SingleAgentOrchestrationChainingSampleValidationAsync() + { + string samplePath = Path.Combine(s_samplesPath, "02_AgentOrchestration_Chaining"); + await this.RunSampleTestAsync(samplePath, async (logs) => + { + Uri startUri = new($"http://localhost:{AzureFunctionsPort}/api/singleagent/run"); + this._outputHelper.WriteLine($"Starting single agent orchestration via POST request to {startUri}..."); + + // Start the orchestration + using HttpResponseMessage startResponse = await s_sharedHttpClient.PostAsync(startUri, content: null); + + Assert.True( + startResponse.IsSuccessStatusCode, + $"Start orchestration failed with status: {startResponse.StatusCode}"); + string startResponseText = await startResponse.Content.ReadAsStringAsync(); + JsonElement startResult = JsonSerializer.Deserialize(startResponseText); + + Assert.True(startResult.TryGetProperty("statusQueryGetUri", out JsonElement statusUriElement)); + Uri statusUri = new(statusUriElement.GetString()!); + + // Wait for orchestration to complete + await this.WaitForOrchestrationCompletionAsync(statusUri); + + // Verify the final result + using HttpResponseMessage statusResponse = await s_sharedHttpClient.GetAsync(statusUri); + Assert.True( + statusResponse.IsSuccessStatusCode, + $"Status check failed with status: {statusResponse.StatusCode}"); + + string statusText = await statusResponse.Content.ReadAsStringAsync(); + JsonElement statusResult = JsonSerializer.Deserialize(statusText); + + Assert.Equal("Completed", statusResult.GetProperty("runtimeStatus").GetString()); + Assert.True(statusResult.TryGetProperty("output", out JsonElement outputElement)); + string? output = outputElement.GetString(); + + // Can't really validate the output since it's non-deterministic, but we can at least check it's non-empty + Assert.NotNull(output); + Assert.True(output.Length > 20, "Output is unexpectedly short"); + }); + } + + [Fact] + public async Task MultiAgentOrchestrationConcurrentSampleValidationAsync() + { + string samplePath = Path.Combine(s_samplesPath, "03_AgentOrchestration_Concurrency"); + await this.RunSampleTestAsync(samplePath, async (logs) => + { + // Start the multi-agent orchestration + const string RequestBody = "What is temperature?"; + using HttpContent content = new StringContent(RequestBody, Encoding.UTF8, "text/plain"); + + Uri startUri = new($"http://localhost:{AzureFunctionsPort}/api/multiagent/run"); + this._outputHelper.WriteLine($"Starting multi agent orchestration via POST request to {startUri}..."); + using HttpResponseMessage startResponse = await s_sharedHttpClient.PostAsync(startUri, content); + + Assert.True(startResponse.IsSuccessStatusCode, $"Start orchestration failed with status: {startResponse.StatusCode}"); + string startResponseText = await startResponse.Content.ReadAsStringAsync(); + JsonElement startResult = JsonSerializer.Deserialize(startResponseText); + + Assert.True(startResult.TryGetProperty("instanceId", out JsonElement instanceIdElement)); + Assert.True(startResult.TryGetProperty("statusQueryGetUri", out JsonElement statusUriElement)); + + Uri statusUri = new(statusUriElement.GetString()!); + + // Wait for orchestration to complete + await this.WaitForOrchestrationCompletionAsync(statusUri); + + // Verify the final result + using HttpResponseMessage statusResponse = await s_sharedHttpClient.GetAsync(statusUri); + Assert.True(statusResponse.IsSuccessStatusCode, $"Status check failed with status: {statusResponse.StatusCode}"); + + string statusText = await statusResponse.Content.ReadAsStringAsync(); + JsonElement statusResult = JsonSerializer.Deserialize(statusText); + + Assert.Equal("Completed", statusResult.GetProperty("runtimeStatus").GetString()); + Assert.True(statusResult.TryGetProperty("output", out JsonElement outputElement)); + + // Verify both physicist and chemist responses are present + Assert.True(outputElement.TryGetProperty("physicist", out JsonElement physicistElement)); + Assert.True(outputElement.TryGetProperty("chemist", out JsonElement chemistElement)); + + string physicistResponse = physicistElement.GetString()!; + string chemistResponse = chemistElement.GetString()!; + + Assert.NotEmpty(physicistResponse); + Assert.NotEmpty(chemistResponse); + Assert.Contains("temperature", physicistResponse, StringComparison.OrdinalIgnoreCase); + Assert.Contains("temperature", chemistResponse, StringComparison.OrdinalIgnoreCase); + }); + } + + [Fact] + public async Task MultiAgentOrchestrationConditionalsSampleValidationAsync() + { + string samplePath = Path.Combine(s_samplesPath, "04_AgentOrchestration_Conditionals"); + await this.RunSampleTestAsync(samplePath, async (logs) => + { + // Test with legitimate email + await this.TestSpamDetectionAsync("email-001", + "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!", + expectedSpam: false); + + // Test with spam email + await this.TestSpamDetectionAsync("email-002", + "URGENT! You've won $1,000,000! Click here now to claim your prize! Limited time offer! Don't miss out!", + expectedSpam: true); + }); + } + + [Fact] + public async Task SingleAgentOrchestrationHITLSampleValidationAsync() + { + string samplePath = Path.Combine(s_samplesPath, "05_AgentOrchestration_HITL"); + + await this.RunSampleTestAsync(samplePath, async (logs) => + { + // Start the HITL orchestration with short timeout for testing + // TODO: Add validation for the approval case + object requestBody = new + { + topic = "The Future of Artificial Intelligence", + max_review_attempts = 3, + approval_timeout_hours = 0.001 // Very short timeout for testing + }; + + string jsonContent = JsonSerializer.Serialize(requestBody); + using HttpContent content = new StringContent(jsonContent, Encoding.UTF8, "application/json"); + + Uri startUri = new($"http://localhost:{AzureFunctionsPort}/api/hitl/run"); + this._outputHelper.WriteLine($"Starting HITL orchestration via POST request to {startUri}..."); + using HttpResponseMessage startResponse = await s_sharedHttpClient.PostAsync(startUri, content); + + Assert.True( + startResponse.IsSuccessStatusCode, + $"Start HITL orchestration failed with status: {startResponse.StatusCode}"); + string startResponseText = await startResponse.Content.ReadAsStringAsync(); + JsonElement startResult = JsonSerializer.Deserialize(startResponseText); + + Assert.True(startResult.TryGetProperty("statusQueryGetUri", out JsonElement statusUriElement)); + Uri statusUri = new(statusUriElement.GetString()!); + + // Wait for orchestration to complete (it should timeout due to short timeout) + await this.WaitForOrchestrationCompletionAsync(statusUri); + + // Verify the final result + using HttpResponseMessage statusResponse = await s_sharedHttpClient.GetAsync(statusUri); + Assert.True( + statusResponse.IsSuccessStatusCode, + $"Status check failed with status: {statusResponse.StatusCode}"); + + string statusText = await statusResponse.Content.ReadAsStringAsync(); + this._outputHelper.WriteLine($"HITL orchestration status text: {statusText}"); + + JsonElement statusResult = JsonSerializer.Deserialize(statusText); + + // The orchestration should complete with a failed status due to timeout + Assert.Equal("Failed", statusResult.GetProperty("runtimeStatus").GetString()); + Assert.True(statusResult.TryGetProperty("failureDetails", out JsonElement failureDetailsElement)); + Assert.True(failureDetailsElement.TryGetProperty("ErrorType", out JsonElement errorTypeElement)); + Assert.Equal("System.TimeoutException", errorTypeElement.GetString()); + Assert.True(failureDetailsElement.TryGetProperty("ErrorMessage", out JsonElement errorMessageElement)); + Assert.StartsWith("Human approval timed out", errorMessageElement.GetString()); + }); + } + + [Fact] + public async Task LongRunningToolsSampleValidationAsync() + { + string samplePath = Path.Combine(s_samplesPath, "06_LongRunningTools"); + + await this.RunSampleTestAsync(samplePath, async (logs) => + { + // Test starting an agent that schedules a content generation orchestration + const string Prompt = "Start a content generation workflow for the topic 'The Future of Artificial Intelligence'"; + using HttpContent messageContent = new StringContent(Prompt, Encoding.UTF8, "text/plain"); + + Uri runAgentUri = new($"http://localhost:{AzureFunctionsPort}/api/agents/publisher/run"); + + this._outputHelper.WriteLine($"Starting agent tool orchestration via POST request to {runAgentUri}..."); + using HttpResponseMessage startResponse = await s_sharedHttpClient.PostAsync(runAgentUri, messageContent); + + Assert.True( + startResponse.IsSuccessStatusCode, + $"Start agent request failed with status: {startResponse.StatusCode}"); + + string startResponseText = await startResponse.Content.ReadAsStringAsync(); + this._outputHelper.WriteLine($"Agent response: {startResponseText}"); + + // The response should be deserializable as an AgentRunResponse object and have a valid thread ID + startResponse.Headers.TryGetValues("x-ms-thread-id", out IEnumerable? agentIdValues); + string? threadId = agentIdValues?.FirstOrDefault(); + Assert.NotNull(threadId); + Assert.StartsWith("@dafx-publisher@", threadId); + + // Wait for the orchestration to report that it's waiting for human approval + await this.WaitForConditionAsync( + condition: () => + { + // For now, we have to rely on the logs to check for the "NOTIFICATION" message that gets generated by the activity function. + // TODO: Synchronously prompt the agent for status + lock (logs) + { + bool exists = logs.Any(log => log.Message.Contains("NOTIFICATION: Please review the following content for approval")); + return Task.FromResult(exists); + } + }, + message: "Orchestration is requesting human feedback", + timeout: TimeSpan.FromSeconds(60)); + + // Approve the content + Uri approvalUri = new($"{runAgentUri}?thread_id={threadId}"); + using HttpContent approvalContent = new StringContent("Approve the content", Encoding.UTF8, "text/plain"); + using HttpResponseMessage approvalResponse = await s_sharedHttpClient.PostAsync(approvalUri, approvalContent); + Assert.True(approvalResponse.IsSuccessStatusCode, $"Approve content request failed with status: {approvalResponse.StatusCode}"); + + // Wait for the publish notification to be logged + await this.WaitForConditionAsync( + condition: () => + { + lock (logs) + { + // TODO: Synchronously prompt the agent for status + bool exists = logs.Any(log => log.Message.Contains("PUBLISHING: Content has been published successfully")); + return Task.FromResult(exists); + } + }, + message: "Content published notification is logged", + timeout: TimeSpan.FromSeconds(60)); + + // Verify the final orchestration status by asking the agent for the status + Uri statusUri = new($"{runAgentUri}?thread_id={threadId}"); + await this.WaitForConditionAsync( + condition: async () => + { + this._outputHelper.WriteLine($"Checking status of orchestration at {statusUri}..."); + + using StringContent content = new("Get the status of the workflow", Encoding.UTF8, "text/plain"); + using HttpResponseMessage statusResponse = await s_sharedHttpClient.PostAsync(statusUri, content); + Assert.True( + statusResponse.IsSuccessStatusCode, + $"Status check failed with status: {statusResponse.StatusCode}"); + string statusText = await statusResponse.Content.ReadAsStringAsync(); + this._outputHelper.WriteLine($"Status text: {statusText}"); + + bool isCompleted = statusText.Contains("Completed", StringComparison.OrdinalIgnoreCase); + bool hasContent = statusText.Contains( + "The Future of Artificial Intelligence", + StringComparison.OrdinalIgnoreCase); + return isCompleted && hasContent; + }, + message: "Orchestration is completed", + timeout: TimeSpan.FromSeconds(60)); + }); + } + + [Fact] + public async Task AgentAsMcpToolAsync() + { + string samplePath = Path.Combine(s_samplesPath, "07_AgentAsMcpTool"); + await this.RunSampleTestAsync(samplePath, async (logs) => + { + IClientTransport clientTransport = new HttpClientTransport(new() + { + Endpoint = new Uri($"http://localhost:{AzureFunctionsPort}/runtime/webhooks/mcp") + }); + + await using McpClient mcpClient = await McpClient.CreateAsync(clientTransport!); + + // Ensure the expected tools are present. + IList tools = await mcpClient.ListToolsAsync(); + + Assert.Single(tools, t => t.Name == "StockAdvisor"); + Assert.Single(tools, t => t.Name == "PlantAdvisor"); + + // Invoke the tools to verify they work as expected. + string stockPriceResponse = await this.InvokeMcpToolAsync(mcpClient, "StockAdvisor", "MSFT ATH"); + string plantSuggestionResponse = await this.InvokeMcpToolAsync(mcpClient, "PlantAdvisor", "Low light plant"); + Assert.NotEmpty(stockPriceResponse); + Assert.NotEmpty(plantSuggestionResponse); + + // Wait for up to 30 seconds to see if the agent responses are available in the logs + await this.WaitForConditionAsync( + condition: () => + { + lock (logs) + { + bool expectedLogsPresent = logs.Count(log => log.Message.Contains("Response:")) >= 2; + return Task.FromResult(expectedLogsPresent); + } + }, + message: "Agent response is available", + timeout: TimeSpan.FromSeconds(30)); + }); + } + + private async Task InvokeMcpToolAsync(McpClient mcpClient, string toolName, string query) + { + this._outputHelper.WriteLine($"Invoking MCP tool '{toolName}'..."); + + CallToolResult result = await mcpClient.CallToolAsync( + toolName, + arguments: new Dictionary { { "query", query } }); + + string toolCallResult = ((TextContentBlock)result.Content[0]).Text; + this._outputHelper.WriteLine($"MCP tool '{toolName}' response: {toolCallResult}"); + + return toolCallResult; + } + + private async Task TestSpamDetectionAsync(string emailId, string emailContent, bool expectedSpam) + { + object requestBody = new + { + email_id = emailId, + email_content = emailContent + }; + + string jsonContent = JsonSerializer.Serialize(requestBody); + using HttpContent content = new StringContent(jsonContent, Encoding.UTF8, "application/json"); + + Uri startUri = new($"http://localhost:{AzureFunctionsPort}/api/spamdetection/run"); + this._outputHelper.WriteLine($"Starting spam detection orchestration via POST request to {startUri}..."); + using HttpResponseMessage startResponse = await s_sharedHttpClient.PostAsync(startUri, content); + + Assert.True(startResponse.IsSuccessStatusCode, $"Start orchestration failed with status: {startResponse.StatusCode}"); + string startResponseText = await startResponse.Content.ReadAsStringAsync(); + JsonElement startResult = JsonSerializer.Deserialize(startResponseText); + + Assert.True(startResult.TryGetProperty("statusQueryGetUri", out JsonElement statusUriElement)); + Uri statusUri = new(statusUriElement.GetString()!); + + // Wait for orchestration to complete + await this.WaitForOrchestrationCompletionAsync(statusUri); + + // Verify the final result + using HttpResponseMessage statusResponse = await s_sharedHttpClient.GetAsync(statusUri); + Assert.True(statusResponse.IsSuccessStatusCode, $"Status check failed with status: {statusResponse.StatusCode}"); + + string statusText = await statusResponse.Content.ReadAsStringAsync(); + JsonElement statusResult = JsonSerializer.Deserialize(statusText); + + Assert.Equal("Completed", statusResult.GetProperty("runtimeStatus").GetString()); + Assert.True(statusResult.TryGetProperty("output", out JsonElement outputElement)); + + string output = outputElement.GetString()!; + Assert.NotEmpty(output); + + if (expectedSpam) + { + Assert.Contains("spam", output, StringComparison.OrdinalIgnoreCase); + } + else + { + Assert.Contains("sent", output, StringComparison.OrdinalIgnoreCase); + } + } + + private async Task StartSharedInfrastructureAsync() + { + // Start Azurite if it's not already running + if (!await this.IsAzuriteRunningAsync()) + { + await this.StartDockerContainerAsync( + containerName: "azurite", + image: "mcr.microsoft.com/azure-storage/azurite", + ports: ["-p", "10000:10000", "-p", "10001:10001", "-p", "10002:10002"]); + + // Wait for Azurite + await this.WaitForConditionAsync(this.IsAzuriteRunningAsync, "Azurite is running", TimeSpan.FromSeconds(30)); + } + + // Start DTS emulator if it's not already running + if (!await this.IsDtsEmulatorRunningAsync()) + { + await this.StartDockerContainerAsync( + containerName: "dts-emulator", + image: "mcr.microsoft.com/dts/dts-emulator:latest", + ports: ["-p", "8080:8080", "-p", "8082:8082"]); + + // Wait for DTS emulator + await this.WaitForConditionAsync( + condition: this.IsDtsEmulatorRunningAsync, + message: "DTS emulator is running", + timeout: TimeSpan.FromSeconds(30)); + } + } + + private async Task IsAzuriteRunningAsync() + { + this._outputHelper.WriteLine( + $"Checking if Azurite is running at http://localhost:{AzuritePort}/devstoreaccount1..."); + + try + { + using CancellationTokenSource timeoutCts = new(TimeSpan.FromSeconds(30)); + + // Example output when pinging Azurite: + // $ curl -i http://localhost:10000/devstoreaccount1?comp=list + // HTTP/1.1 403 Server failed to authenticate the request. + // Server: Azurite-Blob/3.34.0 + // x-ms-error-code: AuthorizationFailure + // x-ms-request-id: 6cd21522-bb0f-40f6-962c-fa174f17aa30 + // content-type: application/xml + // Date: Mon, 20 Oct 2025 23:52:02 GMT + // Connection: keep-alive + // Keep-Alive: timeout=5 + // Transfer-Encoding: chunked + using HttpResponseMessage response = await s_sharedHttpClient.GetAsync( + requestUri: new Uri($"http://localhost:{AzuritePort}/devstoreaccount1?comp=list"), + cancellationToken: timeoutCts.Token); + if (response.Headers.TryGetValues( + "Server", + out IEnumerable? serverValues) && serverValues.Any(s => s.StartsWith("Azurite", StringComparison.OrdinalIgnoreCase))) + { + this._outputHelper.WriteLine($"Azurite is running, server: {string.Join(", ", serverValues)}"); + return true; + } + + this._outputHelper.WriteLine($"Azurite is not running. Status code: {response.StatusCode}"); + return false; + } + catch (HttpRequestException ex) + { + this._outputHelper.WriteLine($"Azurite is not running: {ex.Message}"); + return false; + } + } + + private async Task IsDtsEmulatorRunningAsync() + { + this._outputHelper.WriteLine($"Checking if DTS emulator is running at http://localhost:{DtsPort}/healthz..."); + + // DTS emulator doesn't support HTTP/1.1, so we need to use HTTP/2.0 + using HttpClient http2Client = new() + { + DefaultRequestVersion = new Version(2, 0), + DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact + }; + + try + { + using CancellationTokenSource timeoutCts = new(TimeSpan.FromSeconds(30)); + using HttpResponseMessage response = await http2Client.GetAsync(new Uri($"http://localhost:{DtsPort}/healthz"), timeoutCts.Token); + if (response.Content.Headers.ContentLength > 0) + { + string content = await response.Content.ReadAsStringAsync(timeoutCts.Token); + this._outputHelper.WriteLine($"DTS emulator health check response: {content}"); + } + + if (response.IsSuccessStatusCode) + { + this._outputHelper.WriteLine("DTS emulator is running"); + return true; + } + + this._outputHelper.WriteLine($"DTS emulator is not running. Status code: {response.StatusCode}"); + return false; + } + catch (HttpRequestException ex) + { + this._outputHelper.WriteLine($"DTS emulator is not running: {ex.Message}"); + return false; + } + } + + private async Task StartDockerContainerAsync(string containerName, string image, string[] ports) + { + // Stop existing container if it exists + await this.RunCommandAsync("docker", ["stop", containerName]); + await this.RunCommandAsync("docker", ["rm", containerName]); + + // Start new container + List args = ["run", "-d", "--name", containerName]; + args.AddRange(ports); + args.Add(image); + + this._outputHelper.WriteLine( + $"Starting new container: {containerName} with image: {image} and ports: {string.Join(", ", ports)}"); + await this.RunCommandAsync("docker", args.ToArray()); + this._outputHelper.WriteLine($"Container started: {containerName}"); + } + + private async Task WaitForConditionAsync(Func> condition, string message, TimeSpan timeout) + { + this._outputHelper.WriteLine($"Waiting for '{message}'..."); + + using CancellationTokenSource cancellationTokenSource = new(timeout); + while (true) + { + if (await condition()) + { + return; + } + + try + { + await Task.Delay(TimeSpan.FromSeconds(1), cancellationTokenSource.Token); + } + catch (OperationCanceledException) when (cancellationTokenSource.IsCancellationRequested) + { + throw new TimeoutException($"Timeout waiting for '{message}'"); + } + } + } + + private async Task RunSampleTestAsync(string samplePath, Func, Task> testAction) + { + // Start the Azure Functions app + List logsContainer = []; + using Process funcProcess = this.StartFunctionApp(samplePath, logsContainer); + try + { + // Wait for the app to be ready + await this.WaitForAzureFunctionsAsync(); + + // Run the test + await testAction(logsContainer); + } + finally + { + await this.StopProcessAsync(funcProcess); + } + } + + private sealed record OutputLog(DateTime Timestamp, LogLevel Level, string Message); + + private Process StartFunctionApp(string samplePath, List logs) + { + ProcessStartInfo startInfo = new() + { + FileName = "dotnet", + Arguments = $"run -f {s_dotnetTargetFramework} --port {AzureFunctionsPort}", + WorkingDirectory = samplePath, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + + string openAiEndpoint = s_configuration["AZURE_OPENAI_ENDPOINT"] ?? + throw new InvalidOperationException("The required AZURE_OPENAI_ENDPOINT env variable is not set."); + string openAiDeployment = s_configuration["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"] ?? + throw new InvalidOperationException("The required AZURE_OPENAI_CHAT_DEPLOYMENT_NAME env variable is not set."); + + // Set required environment variables for the function app (see local.settings.json for required settings) + startInfo.EnvironmentVariables["AZURE_OPENAI_ENDPOINT"] = openAiEndpoint; + startInfo.EnvironmentVariables["AZURE_OPENAI_DEPLOYMENT"] = openAiDeployment; + startInfo.EnvironmentVariables["DURABLE_TASK_SCHEDULER_CONNECTION_STRING"] = + $"Endpoint=http://localhost:{DtsPort};TaskHub=default;Authentication=None"; + startInfo.EnvironmentVariables["AzureWebJobsStorage"] = "UseDevelopmentStorage=true"; + + Process process = new() { StartInfo = startInfo }; + + // Capture the output and error streams + process.ErrorDataReceived += (sender, e) => + { + if (e.Data != null) + { + this._outputHelper.WriteLine($"[{startInfo.FileName}(err)]: {e.Data}"); + lock (logs) + { + logs.Add(new OutputLog(DateTime.Now, LogLevel.Error, e.Data)); + } + } + }; + + process.OutputDataReceived += (sender, e) => + { + if (e.Data != null) + { + this._outputHelper.WriteLine($"[{startInfo.FileName}(out)]: {e.Data}"); + lock (logs) + { + logs.Add(new OutputLog(DateTime.Now, LogLevel.Information, e.Data)); + } + } + }; + + if (!process.Start()) + { + throw new InvalidOperationException("Failed to start the function app"); + } + + process.BeginErrorReadLine(); + process.BeginOutputReadLine(); + + return process; + } + + private async Task WaitForAzureFunctionsAsync() + { + this._outputHelper.WriteLine( + $"Waiting for Azure Functions Core Tools to be ready at http://localhost:{AzureFunctionsPort}/..."); + await this.WaitForConditionAsync( + condition: async () => + { + try + { + using HttpRequestMessage request = new(HttpMethod.Head, $"http://localhost:{AzureFunctionsPort}/"); + using HttpResponseMessage response = await s_sharedHttpClient.SendAsync(request); + this._outputHelper.WriteLine($"Azure Functions Core Tools response: {response.StatusCode}"); + return response.IsSuccessStatusCode; + } + catch (HttpRequestException) + { + // Expected when the app isn't yet ready + return false; + } + }, + message: "Azure Functions Core Tools is ready", + timeout: TimeSpan.FromSeconds(60)); + } + + private async Task WaitForOrchestrationCompletionAsync(Uri statusUri) + { + using CancellationTokenSource timeoutCts = new(s_orchestrationTimeout); + while (true) + { + try + { + using HttpResponseMessage response = await s_sharedHttpClient.GetAsync( + statusUri, + timeoutCts.Token); + if (response.IsSuccessStatusCode) + { + string responseText = await response.Content.ReadAsStringAsync(timeoutCts.Token); + JsonElement result = JsonSerializer.Deserialize(responseText); + + if (result.TryGetProperty("runtimeStatus", out JsonElement statusElement)) + { + string status = statusElement.GetString()!; + if (status == "Completed" || status == "Failed" || status == "Terminated") + { + return; + } + } + } + } + catch (Exception ex) when (!timeoutCts.Token.IsCancellationRequested) + { + // Ignore errors and retry + this._outputHelper.WriteLine($"Error waiting for orchestration completion: {ex}"); + } + + await Task.Delay(TimeSpan.FromSeconds(1), timeoutCts.Token); + } + } + + private async Task RunCommandAsync(string command, string[] args) + { + await this.RunCommandAsync(command, workingDirectory: null, args: args); + } + + private async Task RunCommandAsync(string command, string? workingDirectory, string[] args) + { + ProcessStartInfo startInfo = new() + { + FileName = command, + Arguments = string.Join(" ", args), + WorkingDirectory = workingDirectory, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + this._outputHelper.WriteLine($"Running command: {command} {string.Join(" ", args)}"); + + using Process process = new() { StartInfo = startInfo }; + process.ErrorDataReceived += (sender, e) => this._outputHelper.WriteLine($"[{command}(err)]: {e.Data}"); + process.OutputDataReceived += (sender, e) => this._outputHelper.WriteLine($"[{command}(out)]: {e.Data}"); + if (!process.Start()) + { + throw new InvalidOperationException("Failed to start the command"); + } + process.BeginErrorReadLine(); + process.BeginOutputReadLine(); + + using CancellationTokenSource cancellationTokenSource = new(TimeSpan.FromMinutes(1)); + await process.WaitForExitAsync(cancellationTokenSource.Token); + + this._outputHelper.WriteLine($"Command completed with exit code: {process.ExitCode}"); + } + + private async Task StopProcessAsync(Process process) + { + try + { + if (!process.HasExited) + { + this._outputHelper.WriteLine($"Killing process {process.ProcessName}#{process.Id}"); + process.Kill(entireProcessTree: true); + + using CancellationTokenSource timeoutCts = new(TimeSpan.FromSeconds(10)); + await process.WaitForExitAsync(timeoutCts.Token); + this._outputHelper.WriteLine($"Process exited: {process.Id}"); + } + } + catch (Exception ex) + { + this._outputHelper.WriteLine($"Failed to stop process: {ex.Message}"); + } + } + + private static string GetTargetFramework() + { + // Get the target framework by looking at the path of the current file. It should be something like /path/to/project/bin/Debug/net8.0/... + string filePath = new Uri(typeof(SamplesValidation).Assembly.Location).LocalPath; + string directory = Path.GetDirectoryName(filePath)!; + string tfm = Path.GetFileName(directory); + if (tfm.StartsWith("net", StringComparison.OrdinalIgnoreCase)) + { + return tfm; + } + + throw new InvalidOperationException($"Unable to find target framework in path: {filePath}"); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/DurableAgentFunctionMetadataTransformerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/DurableAgentFunctionMetadataTransformerTests.cs new file mode 100644 index 0000000000..c9a13d7298 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/DurableAgentFunctionMetadataTransformerTests.cs @@ -0,0 +1,186 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Azure.Functions.Worker.Core.FunctionMetadata; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests; + +public sealed class DurableAgentFunctionMetadataTransformerTests +{ + [Theory] + [InlineData(0, false, false, 1)] // entity only + [InlineData(0, true, false, 2)] // entity + http + [InlineData(0, false, true, 2)] // entity + mcp tool + [InlineData(0, true, true, 3)] // entity + http + mcp tool + [InlineData(3, true, true, 3)] // entity + http + mcp tool added to existing + public void Transform_AddsAgentAndHttpTriggers_ForEachAgent( + int initialMetadataEntryCount, + bool enableHttp, + bool enableMcp, + int expectedMetadataCount) + { + // Arrange + Dictionary> agents = new() + { + { "testAgent", _ => new TestAgent("testAgent", "Test agent description") } + }; + + FunctionsAgentOptions options = new(); + + options.HttpTrigger.IsEnabled = enableHttp; + options.McpToolTrigger.IsEnabled = enableMcp; + + IFunctionsAgentOptionsProvider agentOptionsProvider = new FakeOptionsProvider(new Dictionary + { + { "testAgent", options } + }); + + List metadataList = BuildFunctionMetadataList(initialMetadataEntryCount); + + DurableAgentFunctionMetadataTransformer transformer = new( + agents, + NullLogger.Instance, + new FakeServiceProvider(), + agentOptionsProvider); + + // Act + transformer.Transform(metadataList); + + // Assert + Assert.Equal(initialMetadataEntryCount + expectedMetadataCount, metadataList.Count); + + DefaultFunctionMetadata agentTrigger = Assert.IsType(metadataList[initialMetadataEntryCount]); + Assert.Equal("dafx-testAgent", agentTrigger.Name); + Assert.Contains("entityTrigger", agentTrigger.RawBindings![0]); + + if (enableHttp) + { + DefaultFunctionMetadata httpTrigger = Assert.IsType(metadataList[initialMetadataEntryCount + 1]); + Assert.Equal("http-testAgent", httpTrigger.Name); + Assert.Contains("httpTrigger", httpTrigger.RawBindings![0]); + } + + if (enableMcp) + { + int mcpIndex = initialMetadataEntryCount + (enableHttp ? 2 : 1); + DefaultFunctionMetadata mcpToolTrigger = Assert.IsType(metadataList[mcpIndex]); + Assert.Equal("mcptool-testAgent", mcpToolTrigger.Name); + Assert.Contains("mcpToolTrigger", mcpToolTrigger.RawBindings![0]); + } + } + + [Fact] + public void Transform_AddsTriggers_ForMultipleAgents() + { + // Arrange + Dictionary> agents = new() + { + { "agentA", _ => new TestAgent("testAgentA", "Test agent description") }, + { "agentB", _ => new TestAgent("testAgentB", "Test agent description") }, + { "agentC", _ => new TestAgent("testAgentC", "Test agent description") } + }; + + // Helper to create options with configurable triggers + static FunctionsAgentOptions CreateFunctionsAgentOptions(bool httpEnabled, bool mcpEnabled) + { + FunctionsAgentOptions options = new(); + options.HttpTrigger.IsEnabled = httpEnabled; + options.McpToolTrigger.IsEnabled = mcpEnabled; + return options; + } + + FunctionsAgentOptions agentOptionsA = CreateFunctionsAgentOptions(true, false); + FunctionsAgentOptions agentOptionsB = CreateFunctionsAgentOptions(true, true); + FunctionsAgentOptions agentOptionsC = CreateFunctionsAgentOptions(true, true); + + Dictionary functionsAgentOptions = new() + { + { "agentA", agentOptionsA }, + { "agentB", agentOptionsB }, + { "agentC", agentOptionsC } + }; + + IFunctionsAgentOptionsProvider agentOptionsProvider = new FakeOptionsProvider(functionsAgentOptions); + DurableAgentFunctionMetadataTransformer transformer = new( + agents, + NullLogger.Instance, + new FakeServiceProvider(), + agentOptionsProvider); + + const int InitialMetadataEntryCount = 2; + List metadataList = BuildFunctionMetadataList(InitialMetadataEntryCount); + + // Act + transformer.Transform(metadataList); + + // Assert + Assert.Equal(InitialMetadataEntryCount + (agents.Count * 2) + 2, metadataList.Count); + + foreach (string agentName in agents.Keys) + { + // The agent's entity trigger name is prefixed with "dafx-" + DefaultFunctionMetadata entityMeta = + Assert.IsType( + Assert.Single(metadataList, m => m.Name == $"dafx-{agentName}")); + Assert.NotNull(entityMeta.RawBindings); + Assert.Contains("entityTrigger", entityMeta.RawBindings[0]); + + DefaultFunctionMetadata httpMeta = + Assert.IsType( + Assert.Single(metadataList, m => m.Name == $"http-{agentName}")); + Assert.NotNull(httpMeta.RawBindings); + Assert.Contains("httpTrigger", httpMeta.RawBindings[0]); + Assert.Contains($"agents/{agentName}/run", httpMeta.RawBindings[0]); + + // We expect 2 mcp tool triggers only for agentB and agentC + if (agentName == "agentB" || agentName == "agentC") + { + DefaultFunctionMetadata? mcpToolMeta = + Assert.Single(metadataList, m => m.Name == $"mcptool-{agentName}") as DefaultFunctionMetadata; + Assert.NotNull(mcpToolMeta); + Assert.NotNull(mcpToolMeta.RawBindings); + Assert.Equal(4, mcpToolMeta.RawBindings.Count); + Assert.Contains("mcpToolTrigger", mcpToolMeta.RawBindings[0]); + Assert.Contains("mcpToolProperty", mcpToolMeta.RawBindings[1]); // We expect 2 tool property bindings + Assert.Contains("mcpToolProperty", mcpToolMeta.RawBindings[2]); + } + } + } + + private static List BuildFunctionMetadataList(int numberOfFunctions) + { + List list = []; + for (int i = 0; i < numberOfFunctions; i++) + { + list.Add(new DefaultFunctionMetadata + { + Language = "dotnet-isolated", + Name = $"SingleAgentOrchestration{i + 1}", + EntryPoint = "MyApp.Functions.SingleAgentOrchestration", + RawBindings = ["{\r\n \"name\": \"context\",\r\n \"direction\": \"In\",\r\n \"type\": \"orchestrationTrigger\",\r\n \"properties\": {}\r\n }"], + ScriptFile = "MyApp.dll" + }); + } + + return list; + } + + private sealed class FakeServiceProvider : IServiceProvider + { + public object? GetService(Type serviceType) => null; + } + + private sealed class FakeOptionsProvider : IFunctionsAgentOptionsProvider + { + private readonly Dictionary _map; + + public FakeOptionsProvider(Dictionary map) + { + this._map = map ?? throw new ArgumentNullException(nameof(map)); + } + + public bool TryGet(string agentName, [NotNullWhen(true)] out FunctionsAgentOptions? options) + => this._map.TryGetValue(agentName, out options); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests.csproj new file mode 100644 index 0000000000..d3842800b9 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests.csproj @@ -0,0 +1,13 @@ + + + + $(ProjectsCoreTargetFrameworks) + $(ProjectsDebugCoreTargetFrameworks) + enable + + + + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/TestAgent.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/TestAgent.cs new file mode 100644 index 0000000000..b0ad7ec0fe --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/TestAgent.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests; + +internal sealed class TestAgent(string name, string description) : AIAgent +{ + public override string? Name => name; + + public override string? Description => description; + + public override AgentThread GetNewThread() => new DummyAgentThread(); + + public override AgentThread DeserializeThread( + JsonElement serializedThread, + JsonSerializerOptions? jsonSerializerOptions = null) => new DummyAgentThread(); + + public override Task RunAsync( + IEnumerable messages, + AgentThread? thread = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) => Task.FromResult(new AgentRunResponse([.. messages])); + + public override IAsyncEnumerable RunStreamingAsync( + IEnumerable messages, + AgentThread? thread = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) => throw new NotSupportedException(); + + private sealed class DummyAgentThread : AgentThread; +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/Microsoft.Agents.AI.Purview.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/Microsoft.Agents.AI.Purview.UnitTests.csproj new file mode 100644 index 0000000000..bd07eca8ab --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/Microsoft.Agents.AI.Purview.UnitTests.csproj @@ -0,0 +1,12 @@ + + + + $(ProjectsTargetFrameworks) + + + + + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/PurviewClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/PurviewClientTests.cs new file mode 100644 index 0000000000..b2b0ac45e6 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/PurviewClientTests.cs @@ -0,0 +1,585 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Azure.Core; +using Microsoft.Agents.AI.Purview.Models.Common; +using Microsoft.Agents.AI.Purview.Models.Requests; +using Microsoft.Agents.AI.Purview.Models.Responses; +using Microsoft.Agents.AI.Purview.Serialization; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Microsoft.Agents.AI.Purview.UnitTests; + +/// +/// Unit tests for the class. +/// +public sealed class PurviewClientTests : IDisposable +{ + private readonly HttpClient _httpClient; + private readonly PurviewClientHttpMessageHandlerStub _handler; + private readonly PurviewClient _client; + private readonly PurviewSettings _settings; + + public PurviewClientTests() + { + this._handler = new PurviewClientHttpMessageHandlerStub(); + this._httpClient = new HttpClient(this._handler, false); + this._settings = new PurviewSettings("TestApp") + { + GraphBaseUri = new Uri("https://graph.microsoft.com/v1.0/") + }; + var tokenCredential = new MockTokenCredential(); + this._client = new PurviewClient(tokenCredential, this._settings, this._httpClient, NullLogger.Instance); + } + + #region ProcessContentAsync Tests + + [Fact] + public async Task ProcessContentAsync_WithValidRequest_ReturnsSuccessResponseAsync() + { + // Arrange + var request = CreateValidProcessContentRequest(); + var expectedResponse = new ProcessContentResponse + { + Id = "test-id-123", + ProtectionScopeState = ProtectionScopeState.NotModified, + PolicyActions = new List + { + new() { Action = DlpAction.NotifyUser } + } + }; + + this._handler.StatusCodeToReturn = HttpStatusCode.OK; + this._handler.ResponseToReturn = JsonSerializer.Serialize(expectedResponse, PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ProcessContentResponse))); + + // Act + var result = await this._client.ProcessContentAsync(request, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.Equal(expectedResponse.Id, result.Id); + Assert.Equal(ProtectionScopeState.NotModified, result.ProtectionScopeState); + Assert.Single(result.PolicyActions!); + Assert.Equal(DlpAction.NotifyUser, result.PolicyActions![0].Action); + + // Verify request + Assert.Equal("https://graph.microsoft.com/v1.0/users/test-user-id/dataSecurityAndGovernance/processContent", this._handler.RequestUri?.ToString()); + Assert.Equal(HttpMethod.Post, this._handler.RequestMethod); + Assert.Contains("Bearer ", this._handler.AuthorizationHeader); + } + + [Fact] + public async Task ProcessContentAsync_WithAcceptedStatus_ReturnsSuccessResponseAsync() + { + // Arrange + var request = CreateValidProcessContentRequest(); + var expectedResponse = new ProcessContentResponse + { + Id = "test-id-456", + ProtectionScopeState = ProtectionScopeState.Modified + }; + + this._handler.StatusCodeToReturn = HttpStatusCode.Accepted; + this._handler.ResponseToReturn = JsonSerializer.Serialize(expectedResponse, PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ProcessContentResponse))); + + // Act + var result = await this._client.ProcessContentAsync(request, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.Equal(expectedResponse.Id, result.Id); + Assert.Equal(ProtectionScopeState.Modified, result.ProtectionScopeState); + } + + [Fact] + public async Task ProcessContentAsync_WithScopeIdentifier_IncludesIfNoneMatchHeaderAsync() + { + // Arrange + var request = CreateValidProcessContentRequest(); + request.ScopeIdentifier = "\"test-scope-123\""; // ETags must be quoted + var expectedResponse = new ProcessContentResponse { Id = "test-id" }; + + this._handler.StatusCodeToReturn = HttpStatusCode.OK; + this._handler.ResponseToReturn = JsonSerializer.Serialize(expectedResponse, PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ProcessContentResponse))); + + // Act + await this._client.ProcessContentAsync(request, CancellationToken.None); + + // Assert + Assert.Equal("\"test-scope-123\"", this._handler.IfNoneMatchHeader); + } + + [Fact] + public async Task ProcessContentAsync_WithRateLimitError_ThrowsPurviewRateLimitExceptionAsync() + { + // Arrange + var request = CreateValidProcessContentRequest(); + this._handler.StatusCodeToReturn = (HttpStatusCode)429; + + // Act & Assert + await Assert.ThrowsAsync(() => + this._client.ProcessContentAsync(request, CancellationToken.None)); + } + + [Fact] + public async Task ProcessContentAsync_WithUnauthorizedError_ThrowsPurviewAuthenticationExceptionAsync() + { + // Arrange + var request = CreateValidProcessContentRequest(); + this._handler.StatusCodeToReturn = HttpStatusCode.Unauthorized; + + // Act & Assert + await Assert.ThrowsAsync(() => + this._client.ProcessContentAsync(request, CancellationToken.None)); + } + + [Fact] + public async Task ProcessContentAsync_WithForbiddenError_ThrowsPurviewAuthenticationExceptionAsync() + { + // Arrange + var request = CreateValidProcessContentRequest(); + this._handler.StatusCodeToReturn = HttpStatusCode.Forbidden; + + // Act & Assert + await Assert.ThrowsAsync(() => + this._client.ProcessContentAsync(request, CancellationToken.None)); + } + + [Fact] + public async Task ProcessContentAsync_WithPaymentRequiredError_ThrowsPurviewPaymentRequiredExceptionAsync() + { + // Arrange + var request = CreateValidProcessContentRequest(); + this._handler.StatusCodeToReturn = HttpStatusCode.PaymentRequired; + + // Act & Assert + await Assert.ThrowsAsync(() => + this._client.ProcessContentAsync(request, CancellationToken.None)); + } + + [Fact] + public async Task ProcessContentAsync_WithBadRequestError_ThrowsPurviewRequestExceptionAsync() + { + // Arrange + var request = CreateValidProcessContentRequest(); + this._handler.StatusCodeToReturn = HttpStatusCode.BadRequest; + + // Act & Assert + await Assert.ThrowsAsync(() => + this._client.ProcessContentAsync(request, CancellationToken.None)); + } + + [Fact] + public async Task ProcessContentAsync_WithInvalidJsonResponse_ThrowsPurviewExceptionAsync() + { + // Arrange + var request = CreateValidProcessContentRequest(); + this._handler.StatusCodeToReturn = HttpStatusCode.OK; + this._handler.ResponseToReturn = "invalid json"; + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + this._client.ProcessContentAsync(request, CancellationToken.None)); + + Assert.Contains("Failed to deserialize ProcessContent response", exception.Message); + Assert.NotNull(exception.InnerException); + Assert.IsType(exception.InnerException); + } + + [Fact] + public async Task ProcessContentAsync_WithHttpRequestException_ThrowsPurviewRequestExceptionAsync() + { + // Arrange + var request = CreateValidProcessContentRequest(); + this._handler.ShouldThrowHttpRequestException = true; + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + this._client.ProcessContentAsync(request, CancellationToken.None)); + + Assert.Equal("Http error occurred while processing content.", exception.Message); + Assert.NotNull(exception.InnerException); + Assert.IsType(exception.InnerException); + } + + #endregion + + #region GetProtectionScopesAsync Tests + + [Fact] + public async Task GetProtectionScopesAsync_WithValidRequest_ReturnsSuccessResponseAsync() + { + // Arrange + var request = new ProtectionScopesRequest("test-user-id", "test-tenant-id") + { + Activities = ProtectionScopeActivities.UploadText, + Locations = new List + { + new("microsoft.graph.policyLocationApplication", "app-123") + } + }; + + var expectedResponse = new ProtectionScopesResponse + { + Scopes = new List + { + new() + { + Activities = ProtectionScopeActivities.UploadText, + Locations = new List + { + new ("microsoft.graph.policyLocationApplication", "app-123") + } + } + } + }; + + this._handler.StatusCodeToReturn = HttpStatusCode.OK; + this._handler.ResponseToReturn = JsonSerializer.Serialize(expectedResponse, PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ProtectionScopesResponse))); + this._handler.ETagToReturn = "\"scope-etag-123\""; + + // Act + var result = await this._client.GetProtectionScopesAsync(request, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Scopes); + Assert.Single(result.Scopes); + Assert.Equal("\"scope-etag-123\"", result.ScopeIdentifier); // ETags are stored with quotes + + // Verify request + Assert.Equal("https://graph.microsoft.com/v1.0/users/test-user-id/dataSecurityAndGovernance/protectionScopes/compute", this._handler.RequestUri?.ToString()); + Assert.Equal(HttpMethod.Post, this._handler.RequestMethod); + } + + [Fact] + public async Task GetProtectionScopesAsync_SetsETagFromResponse_Async() + { + // Arrange + var request = new ProtectionScopesRequest("test-user-id", "test-tenant-id"); + var expectedResponse = new ProtectionScopesResponse { Scopes = new List() }; + + this._handler.StatusCodeToReturn = HttpStatusCode.OK; + this._handler.ResponseToReturn = JsonSerializer.Serialize(expectedResponse, PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ProtectionScopesResponse))); + this._handler.ETagToReturn = "\"custom-etag-456\""; + + // Act + var result = await this._client.GetProtectionScopesAsync(request, CancellationToken.None); + + // Assert + Assert.Equal("\"custom-etag-456\"", result.ScopeIdentifier); + } + + [Fact] + public async Task GetProtectionScopesAsync_WithRateLimitError_ThrowsPurviewRateLimitExceptionAsync() + { + // Arrange + var request = new ProtectionScopesRequest("test-user-id", "test-tenant-id"); + this._handler.StatusCodeToReturn = (HttpStatusCode)429; + + // Act & Assert + await Assert.ThrowsAsync(() => + this._client.GetProtectionScopesAsync(request, CancellationToken.None)); + } + + [Fact] + public async Task GetProtectionScopesAsync_WithUnauthorizedError_ThrowsPurviewAuthenticationExceptionAsync() + { + // Arrange + var request = new ProtectionScopesRequest("test-user-id", "test-tenant-id"); + this._handler.StatusCodeToReturn = HttpStatusCode.Unauthorized; + + // Act & Assert + await Assert.ThrowsAsync(() => + this._client.GetProtectionScopesAsync(request, CancellationToken.None)); + } + + [Fact] + public async Task GetProtectionScopesAsync_WithInvalidJsonResponse_ThrowsPurviewExceptionAsync() + { + // Arrange + var request = new ProtectionScopesRequest("test-user-id", "test-tenant-id"); + this._handler.StatusCodeToReturn = HttpStatusCode.OK; + this._handler.ResponseToReturn = "invalid json"; + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + this._client.GetProtectionScopesAsync(request, CancellationToken.None)); + + Assert.Contains("Failed to deserialize ProtectionScopes response", exception.Message); + Assert.NotNull(exception.InnerException); + Assert.IsType(exception.InnerException); + } + + [Fact] + public async Task GetProtectionScopesAsync_WithHttpRequestException_ThrowsPurviewRequestExceptionAsync() + { + // Arrange + var request = new ProtectionScopesRequest("test-user-id", "test-tenant-id"); + this._handler.ShouldThrowHttpRequestException = true; + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + this._client.GetProtectionScopesAsync(request, CancellationToken.None)); + + Assert.Equal("Http error occurred while retrieving protection scopes.", exception.Message); + Assert.NotNull(exception.InnerException); + Assert.IsType(exception.InnerException); + } + + #endregion + + #region SendContentActivitiesAsync Tests + + [Fact] + public async Task SendContentActivitiesAsync_WithValidRequest_ReturnsSuccessResponseAsync() + { + // Arrange + var contentToProcess = CreateValidContentToProcess(); + var request = new ContentActivitiesRequest("test-user-id", "test-tenant-id", contentToProcess); + var expectedResponse = new ContentActivitiesResponse + { + StatusCode = HttpStatusCode.Created + }; + + this._handler.StatusCodeToReturn = HttpStatusCode.Created; + this._handler.ResponseToReturn = JsonSerializer.Serialize(expectedResponse, PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ContentActivitiesResponse))); + + // Act + var result = await this._client.SendContentActivitiesAsync(request, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.Null(result.Error); + + // Verify request - note the endpoint is different from ProcessContent + Assert.Equal("https://graph.microsoft.com/v1.0/test-user-id/dataSecurityAndGovernance/activities/contentActivities", this._handler.RequestUri?.ToString()); + Assert.Equal(HttpMethod.Post, this._handler.RequestMethod); + } + + [Fact] + public async Task SendContentActivitiesAsync_WithError_ReturnsResponseWithErrorAsync() + { + // Arrange + var contentToProcess = CreateValidContentToProcess(); + var request = new ContentActivitiesRequest("test-user-id", "test-tenant-id", contentToProcess); + var expectedResponse = new ContentActivitiesResponse + { + Error = new ErrorDetails + { + Code = "InvalidRequest", + Message = "The request is invalid" + } + }; + + this._handler.StatusCodeToReturn = HttpStatusCode.Created; + this._handler.ResponseToReturn = JsonSerializer.Serialize(expectedResponse, PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ContentActivitiesResponse))); + + // Act + var result = await this._client.SendContentActivitiesAsync(request, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Error); + Assert.Equal("InvalidRequest", result.Error.Code); + Assert.Equal("The request is invalid", result.Error.Message); + } + + [Fact] + public async Task SendContentActivitiesAsync_WithRateLimitError_ThrowsPurviewRateLimitExceptionAsync() + { + // Arrange + var contentToProcess = CreateValidContentToProcess(); + var request = new ContentActivitiesRequest("test-user-id", "test-tenant-id", contentToProcess); + this._handler.StatusCodeToReturn = (HttpStatusCode)429; + + // Act & Assert + await Assert.ThrowsAsync(() => + this._client.SendContentActivitiesAsync(request, CancellationToken.None)); + } + + [Fact] + public async Task SendContentActivitiesAsync_WithUnauthorizedError_ThrowsPurviewAuthenticationExceptionAsync() + { + // Arrange + var contentToProcess = CreateValidContentToProcess(); + var request = new ContentActivitiesRequest("test-user-id", "test-tenant-id", contentToProcess); + this._handler.StatusCodeToReturn = HttpStatusCode.Unauthorized; + + // Act & Assert + await Assert.ThrowsAsync(() => + this._client.SendContentActivitiesAsync(request, CancellationToken.None)); + } + + [Fact] + public async Task SendContentActivitiesAsync_WithBadRequestError_ThrowsPurviewRequestExceptionAsync() + { + // Arrange + var contentToProcess = CreateValidContentToProcess(); + var request = new ContentActivitiesRequest("test-user-id", "test-tenant-id", contentToProcess); + this._handler.StatusCodeToReturn = HttpStatusCode.BadRequest; + + // Act & Assert + await Assert.ThrowsAsync(() => + this._client.SendContentActivitiesAsync(request, CancellationToken.None)); + } + + [Fact] + public async Task SendContentActivitiesAsync_WithInvalidJsonResponse_ThrowsPurviewExceptionAsync() + { + // Arrange + var contentToProcess = CreateValidContentToProcess(); + var request = new ContentActivitiesRequest("test-user-id", "test-tenant-id", contentToProcess); + this._handler.StatusCodeToReturn = HttpStatusCode.Created; + this._handler.ResponseToReturn = "invalid json"; + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + this._client.SendContentActivitiesAsync(request, CancellationToken.None)); + + Assert.Contains("Failed to deserialize ContentActivities response", exception.Message); + Assert.NotNull(exception.InnerException); + Assert.IsType(exception.InnerException); + } + + [Fact] + public async Task SendContentActivitiesAsync_WithHttpRequestException_ThrowsPurviewRequestExceptionAsync() + { + // Arrange + var contentToProcess = CreateValidContentToProcess(); + var request = new ContentActivitiesRequest("test-user-id", "test-tenant-id", contentToProcess); + this._handler.ShouldThrowHttpRequestException = true; + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + this._client.SendContentActivitiesAsync(request, CancellationToken.None)); + + Assert.Equal("Http error occurred while creating content activities.", exception.Message); + Assert.NotNull(exception.InnerException); + Assert.IsType(exception.InnerException); + } + + #endregion + + #region Helper Methods + + private static ProcessContentRequest CreateValidProcessContentRequest() + { + var contentToProcess = CreateValidContentToProcess(); + return new ProcessContentRequest(contentToProcess, "test-user-id", "test-tenant-id"); + } + + private static ContentToProcess CreateValidContentToProcess() + { + var content = new PurviewTextContent("Test content"); + var metadata = new ProcessConversationMetadata(content, "msg-123", false, "Test message"); + var activityMetadata = new ActivityMetadata(Activity.UploadText); + var deviceMetadata = new DeviceMetadata + { + OperatingSystemSpecifications = new OperatingSystemSpecifications + { + OperatingSystemPlatform = "Windows", + OperatingSystemVersion = "10" + } + }; + var integratedAppMetadata = new IntegratedAppMetadata + { + Name = "TestApp", + Version = "1.0" + }; + var policyLocation = new PolicyLocation("microsoft.graph.policyLocationApplication", "app-123"); + var protectedAppMetadata = new ProtectedAppMetadata(policyLocation) + { + Name = "TestApp", + Version = "1.0" + }; + + return new ContentToProcess( + new List { metadata }, + activityMetadata, + deviceMetadata, + integratedAppMetadata, + protectedAppMetadata + ); + } + + #endregion + + public void Dispose() + { + this._handler.Dispose(); + this._httpClient.Dispose(); + } + + /// + /// Mock HTTP message handler for testing + /// + internal sealed class PurviewClientHttpMessageHandlerStub : HttpMessageHandler + { + public HttpStatusCode StatusCodeToReturn { get; set; } = HttpStatusCode.OK; + public string? ResponseToReturn { get; set; } + public string? ETagToReturn { get; set; } + public bool ShouldThrowHttpRequestException { get; set; } + public Uri? RequestUri { get; private set; } + public HttpMethod? RequestMethod { get; private set; } + public string? AuthorizationHeader { get; private set; } + public string? IfNoneMatchHeader { get; private set; } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + // Capture request details + this.RequestUri = request.RequestUri; + this.RequestMethod = request.Method; + + if (request.Headers.Authorization != null) + { + this.AuthorizationHeader = request.Headers.Authorization.ToString(); + } + + if (request.Headers.TryGetValues("If-None-Match", out var ifNoneMatchValues)) + { + this.IfNoneMatchHeader = string.Join(", ", ifNoneMatchValues); + } + + // Throw HttpRequestException if configured + if (this.ShouldThrowHttpRequestException) + { + throw new HttpRequestException("Simulated network error"); + } + + var response = new HttpResponseMessage(this.StatusCodeToReturn); + + response.Content = new StringContent(this.ResponseToReturn ?? string.Empty, Encoding.UTF8, "application/json"); + + if (!string.IsNullOrEmpty(this.ETagToReturn)) + { + response.Headers.ETag = new System.Net.Http.Headers.EntityTagHeaderValue(this.ETagToReturn); + } + + return await Task.FromResult(response); + } + } + + /// + /// Mock token credential for testing + /// + internal sealed class MockTokenCredential : TokenCredential + { + public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) + { + return new AccessToken("mock-token", DateTimeOffset.UtcNow.AddHours(1)); + } + + public override ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) + { + return new ValueTask(new AccessToken("mock-token", DateTimeOffset.UtcNow.AddHours(1))); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/PurviewWrapperTests.cs b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/PurviewWrapperTests.cs new file mode 100644 index 0000000000..22b729dda4 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/PurviewWrapperTests.cs @@ -0,0 +1,571 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Purview.Models.Common; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace Microsoft.Agents.AI.Purview.UnitTests; + +/// +/// Unit tests for the class. +/// +public sealed class PurviewWrapperTests : IDisposable +{ + private readonly Mock _mockProcessor; + private readonly IChannelHandler _channelHandler; + private readonly PurviewSettings _settings; + private readonly PurviewWrapper _wrapper; + + public PurviewWrapperTests() + { + this._mockProcessor = new Mock(); + this._channelHandler = Mock.Of(); + this._settings = new PurviewSettings("TestApp") + { + TenantId = "tenant-123", + PurviewAppLocation = new PurviewAppLocation(PurviewLocationType.Application, "app-123"), + BlockedPromptMessage = "Prompt blocked by policy", + BlockedResponseMessage = "Response blocked by policy" + }; + this._wrapper = new PurviewWrapper(this._mockProcessor.Object, this._settings, NullLogger.Instance, this._channelHandler); + } + + #region ProcessChatContentAsync Tests + + [Fact] + public async Task ProcessChatContentAsync_WithBlockedPrompt_ReturnsBlockedMessageAsync() + { + // Arrange + var messages = new List + { + new(ChatRole.User, "Sensitive content that should be blocked") + }; + var mockChatClient = new Mock(); + + this._mockProcessor.Setup(x => x.ProcessMessagesAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync((true, "user-123")); + + // Act + var result = await this._wrapper.ProcessChatContentAsync(messages, null, mockChatClient.Object, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.Single(result.Messages); + Assert.Equal(ChatRole.System, result.Messages[0].Role); + Assert.Equal("Prompt blocked by policy", result.Messages[0].Text); + mockChatClient.Verify(x => x.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny()), Times.Never); + } + + [Fact] + public async Task ProcessChatContentAsync_WithAllowedPromptAndBlockedResponse_ReturnsBlockedMessageAsync() + { + // Arrange + var messages = new List + { + new(ChatRole.User, "Test message") + }; + var mockChatClient = new Mock(); + var innerResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Sensitive response")); + + mockChatClient.Setup(x => x.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(innerResponse); + + this._mockProcessor.SetupSequence(x => x.ProcessMessagesAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync((false, "user-123")) // Prompt allowed + .ReturnsAsync((true, "user-123")); // Response blocked + + // Act + var result = await this._wrapper.ProcessChatContentAsync(messages, null, mockChatClient.Object, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.Single(result.Messages); + Assert.Equal(ChatRole.System, result.Messages[0].Role); + Assert.Equal("Response blocked by policy", result.Messages[0].Text); + } + + [Fact] + public async Task ProcessChatContentAsync_WithAllowedPromptAndResponse_ReturnsInnerResponseAsync() + { + // Arrange + var messages = new List + { + new(ChatRole.User, "Test message") + }; + var mockChatClient = new Mock(); + var innerResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Safe response")); + + mockChatClient.Setup(x => x.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(innerResponse); + + this._mockProcessor.Setup(x => x.ProcessMessagesAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync((false, "user-123")); + + // Act + var result = await this._wrapper.ProcessChatContentAsync(messages, null, mockChatClient.Object, CancellationToken.None); + + // Assert + Assert.Same(innerResponse, result); + } + + [Fact] + public async Task ProcessChatContentAsync_WithIgnoreExceptions_ContinuesOnPromptErrorAsync() + { + // Arrange + var settingsWithIgnore = new PurviewSettings("TestApp") + { + TenantId = "tenant-123", + IgnoreExceptions = true, + PurviewAppLocation = new PurviewAppLocation(PurviewLocationType.Application, "app-123") + }; + var wrapper = new PurviewWrapper(this._mockProcessor.Object, settingsWithIgnore, NullLogger.Instance, this._channelHandler); + + var messages = new List + { + new(ChatRole.User, "Test message") + }; + + var expectedResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Response from inner client")); + var mockChatClient = new Mock(); + mockChatClient.Setup(x => x.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(expectedResponse); + + this._mockProcessor.SetupSequence(x => x.ProcessMessagesAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync(new PurviewRequestException("Prompt processing error")); // Response processing succeeds + + // Act + var result = await wrapper.ProcessChatContentAsync(messages, null, mockChatClient.Object, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.Same(expectedResponse, result); + } + + [Fact] + public async Task ProcessChatContentAsync_WithoutIgnoreExceptions_ThrowsOnPromptErrorAsync() + { + // Arrange + var messages = new List + { + new(ChatRole.User, "Test message") + }; + var mockChatClient = new Mock(); + + this._mockProcessor.Setup(x => x.ProcessMessagesAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync(new PurviewRequestException("Prompt processing error")); + + // Act & Assert + await Assert.ThrowsAsync(() => + this._wrapper.ProcessChatContentAsync(messages, null, mockChatClient.Object, CancellationToken.None)); + } + + [Fact] + public async Task ProcessChatContentAsync_UsesConversationIdFromOptions_Async() + { + // Arrange + var messages = new List + { + new(ChatRole.User, "Test message") + }; + var options = new ChatOptions { ConversationId = "conversation-123" }; + var mockChatClient = new Mock(); + var innerResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Response")); + + mockChatClient.Setup(x => x.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(innerResponse); + + this._mockProcessor.Setup(x => x.ProcessMessagesAsync( + It.IsAny>(), + "conversation-123", + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync((false, "user-123")); + + // Act + await this._wrapper.ProcessChatContentAsync(messages, options, mockChatClient.Object, CancellationToken.None); + + // Assert + this._mockProcessor.Verify(x => x.ProcessMessagesAsync( + It.IsAny>(), + "conversation-123", + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Exactly(2)); + } + + #endregion + + #region ProcessAgentContentAsync Tests + + [Fact] + public async Task ProcessAgentContentAsync_WithBlockedPrompt_ReturnsBlockedMessageAsync() + { + // Arrange + var messages = new List + { + new(ChatRole.User, "Sensitive content") + }; + var mockAgent = new Mock(); + + this._mockProcessor.Setup(x => x.ProcessMessagesAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync((true, "user-123")); + + // Act + var result = await this._wrapper.ProcessAgentContentAsync(messages, null, null, mockAgent.Object, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.Single(result.Messages); + Assert.Equal(ChatRole.System, result.Messages[0].Role); + Assert.Equal("Prompt blocked by policy", result.Messages[0].Text); + mockAgent.Verify(x => x.RunAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Never); + } + + [Fact] + public async Task ProcessAgentContentAsync_WithAllowedPromptAndBlockedResponse_ReturnsBlockedMessageAsync() + { + // Arrange + var messages = new List + { + new(ChatRole.User, "Test message") + }; + var mockAgent = new Mock(); + var innerResponse = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Sensitive response")); + + mockAgent.Setup(x => x.RunAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(innerResponse); + + this._mockProcessor.SetupSequence(x => x.ProcessMessagesAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync((false, "user-123")) // Prompt allowed + .ReturnsAsync((true, "user-123")); // Response blocked + + // Act + var result = await this._wrapper.ProcessAgentContentAsync(messages, null, null, mockAgent.Object, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.Single(result.Messages); + Assert.Equal(ChatRole.System, result.Messages[0].Role); + Assert.Equal("Response blocked by policy", result.Messages[0].Text); + } + + [Fact] + public async Task ProcessAgentContentAsync_WithAllowedPromptAndResponse_ReturnsInnerResponseAsync() + { + // Arrange + var messages = new List + { + new(ChatRole.User, "Test message") + }; + var mockAgent = new Mock(); + var innerResponse = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Safe response")); + + mockAgent.Setup(x => x.RunAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(innerResponse); + + this._mockProcessor.Setup(x => x.ProcessMessagesAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync((false, "user-123")); + + // Act + var result = await this._wrapper.ProcessAgentContentAsync(messages, null, null, mockAgent.Object, CancellationToken.None); + + // Assert + Assert.Same(innerResponse, result); + } + + [Fact] + public async Task ProcessAgentContentAsync_WithIgnoreExceptions_ContinuesOnErrorAsync() + { + // Arrange + var settingsWithIgnore = new PurviewSettings("TestApp") + { + TenantId = "tenant-123", + IgnoreExceptions = true, + PurviewAppLocation = new PurviewAppLocation(PurviewLocationType.Application, "app-123") + }; + var wrapper = new PurviewWrapper(this._mockProcessor.Object, settingsWithIgnore, NullLogger.Instance, this._channelHandler); + + var messages = new List + { + new(ChatRole.User, "Test message") + }; + + var expectedResponse = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Response from inner agent")); + var mockAgent = new Mock(); + mockAgent.Setup(x => x.RunAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(expectedResponse); + + this._mockProcessor.SetupSequence(x => x.ProcessMessagesAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync(new PurviewRequestException("Prompt processing error")) + .ReturnsAsync((false, "user-123")); // Response processing succeeds + + // Act + var result = await wrapper.ProcessAgentContentAsync(messages, null, null, mockAgent.Object, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.Same(expectedResponse, result); + } + + [Fact] + public async Task ProcessAgentContentAsync_WithoutIgnoreExceptions_ThrowsOnErrorAsync() + { + // Arrange + var messages = new List + { + new(ChatRole.User, "Test message") + }; + var mockAgent = new Mock(); + + this._mockProcessor.Setup(x => x.ProcessMessagesAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync(new PurviewRequestException("Processing error")); + + // Act & Assert + await Assert.ThrowsAsync(() => + this._wrapper.ProcessAgentContentAsync(messages, null, null, mockAgent.Object, CancellationToken.None)); + } + + [Fact] + public async Task ProcessAgentContentAsync_ExtractsThreadIdFromMessageAdditionalProperties_Async() + { + // Arrange + var messages = new List + { + new(ChatRole.User, "Test message") + { + AdditionalProperties = new AdditionalPropertiesDictionary + { + { "conversationId", "conversation-from-props" } + } + } + }; + + var expectedResponse = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Response")); + var mockAgent = new Mock(); + mockAgent.Setup(x => x.RunAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(expectedResponse); + + this._mockProcessor.Setup(x => x.ProcessMessagesAsync( + It.IsAny>(), + "conversation-from-props", + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync((false, "user-123")); + + // Act + var result = await this._wrapper.ProcessAgentContentAsync(messages, null, null, mockAgent.Object, CancellationToken.None); + + // Assert + Assert.NotNull(result); + this._mockProcessor.Verify(x => x.ProcessMessagesAsync( + It.IsAny>(), + "conversation-from-props", + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Exactly(2)); + } + + [Fact] + public async Task ProcessAgentContentAsync_GeneratesThreadId_WhenNotProvidedAsync() + { + // Arrange + var messages = new List + { + new(ChatRole.User, "Test message") + }; + + var expectedResponse = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Response")); + var mockAgent = new Mock(); + mockAgent.Setup(x => x.RunAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(expectedResponse); + + string? capturedThreadId = null; + this._mockProcessor.Setup(x => x.ProcessMessagesAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback, string, Activity, PurviewSettings, string, CancellationToken>( + (_, threadId, _, _, _, _) => capturedThreadId = threadId) + .ReturnsAsync((false, "user-123")); + + // Act + var result = await this._wrapper.ProcessAgentContentAsync(messages, null, null, mockAgent.Object, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.NotNull(capturedThreadId); + Assert.True(Guid.TryParse(capturedThreadId, out _), "Generated thread ID should be a valid GUID"); + } + + [Fact] + public async Task ProcessAgentContentAsync_PassesResolvedUserId_ToResponseProcessingAsync() + { + // Arrange + var messages = new List + { + new(ChatRole.User, "Test message") + }; + var mockAgent = new Mock(); + var innerResponse = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Response")); + + mockAgent.Setup(x => x.RunAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(innerResponse); + + var callCount = 0; + string? firstCallUserId = null; + string? secondCallUserId = null; + + this._mockProcessor.Setup(x => x.ProcessMessagesAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback, string, Activity, PurviewSettings, string, CancellationToken>( + (_, _, _, _, userId, _) => + { + if (callCount == 0) + { + firstCallUserId = userId; + } + else if (callCount == 1) + { + secondCallUserId = userId; + } + callCount++; + }) + .ReturnsAsync((false, "resolved-user-456")); + + // Act + await this._wrapper.ProcessAgentContentAsync(messages, null, null, mockAgent.Object, CancellationToken.None); + + // Assert + Assert.Null(firstCallUserId); // First call (prompt) should have null userId + Assert.Equal("resolved-user-456", secondCallUserId); // Second call (response) should have resolved userId from first call + } + + #endregion + + public void Dispose() + { + this._wrapper.Dispose(); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/ScopedContentProcessorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/ScopedContentProcessorTests.cs new file mode 100644 index 0000000000..f43f086de7 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/ScopedContentProcessorTests.cs @@ -0,0 +1,501 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Purview.Models.Common; +using Microsoft.Agents.AI.Purview.Models.Jobs; +using Microsoft.Agents.AI.Purview.Models.Requests; +using Microsoft.Agents.AI.Purview.Models.Responses; +using Microsoft.Extensions.AI; +using Moq; + +namespace Microsoft.Agents.AI.Purview.UnitTests; + +/// +/// Unit tests for the class. +/// +public sealed class ScopedContentProcessorTests +{ + private readonly Mock _mockPurviewClient; + private readonly Mock _mockCacheProvider; + private readonly Mock _mockChannelHandler; + private readonly ScopedContentProcessor _processor; + + public ScopedContentProcessorTests() + { + this._mockPurviewClient = new Mock(); + this._mockCacheProvider = new Mock(); + this._mockChannelHandler = new Mock(); + this._processor = new ScopedContentProcessor( + this._mockPurviewClient.Object, + this._mockCacheProvider.Object, + this._mockChannelHandler.Object); + } + + #region ProcessMessagesAsync Tests + + [Fact] + public async Task ProcessMessagesAsync_WithBlockAccessAction_ReturnsShouldBlockTrueAsync() + { + // Arrange + var messages = new List + { + new (ChatRole.User, "Test message") + }; + var settings = CreateValidPurviewSettings(); + var tokenInfo = new TokenInfo { TenantId = "tenant-123", UserId = "user-123", ClientId = "client-123" }; + + this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny(), null)) + .ReturnsAsync(tokenInfo); + + this._mockCacheProvider.Setup(x => x.GetAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync((ProtectionScopesResponse?)null); + + var psResponse = new ProtectionScopesResponse + { + Scopes = new List + { + new() + { + Activities = ProtectionScopeActivities.UploadText, + Locations = new List + { + new ("microsoft.graph.policyLocationApplication", "app-123") + }, + ExecutionMode = ExecutionMode.EvaluateInline + } + } + }; + + this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(psResponse); + + var pcResponse = new ProcessContentResponse + { + PolicyActions = new List + { + new() { Action = DlpAction.BlockAccess } + } + }; + + this._mockPurviewClient.Setup(x => x.ProcessContentAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(pcResponse); + + // Act + var result = await this._processor.ProcessMessagesAsync( + messages, "thread-123", Activity.UploadText, settings, "user-123", CancellationToken.None); + + // Assert + Assert.True(result.shouldBlock); + Assert.Equal("user-123", result.userId); + } + + [Fact] + public async Task ProcessMessagesAsync_WithRestrictionActionBlock_ReturnsShouldBlockTrueAsync() + { + // Arrange + var messages = new List + { + new (ChatRole.User, "Test message") + }; + var settings = CreateValidPurviewSettings(); + var tokenInfo = new TokenInfo { TenantId = "tenant-123", UserId = "user-123", ClientId = "client-123" }; + + this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny(), null)) + .ReturnsAsync(tokenInfo); + + this._mockCacheProvider.Setup(x => x.GetAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync((ProtectionScopesResponse?)null); + + var psResponse = new ProtectionScopesResponse + { + Scopes = new List + { + new() + { + Activities = ProtectionScopeActivities.UploadText, + Locations = new List + { + new ("microsoft.graph.policyLocationApplication", "app-123") + }, + ExecutionMode = ExecutionMode.EvaluateInline + } + } + }; + + this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(psResponse); + + var pcResponse = new ProcessContentResponse + { + PolicyActions = new List + { + new() { RestrictionAction = RestrictionAction.Block } + } + }; + + this._mockPurviewClient.Setup(x => x.ProcessContentAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(pcResponse); + + // Act + var result = await this._processor.ProcessMessagesAsync( + messages, "thread-123", Activity.UploadText, settings, "user-123", CancellationToken.None); + + // Assert + Assert.True(result.shouldBlock); + Assert.Equal("user-123", result.userId); + } + + [Fact] + public async Task ProcessMessagesAsync_WithNoBlockingActions_ReturnsShouldBlockFalseAsync() + { + // Arrange + var messages = new List + { + new (ChatRole.User, "Test message") + }; + var settings = CreateValidPurviewSettings(); + var tokenInfo = new TokenInfo { TenantId = "tenant-123", UserId = "user-123", ClientId = "client-123" }; + + this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny(), null)) + .ReturnsAsync(tokenInfo); + + this._mockCacheProvider.Setup(x => x.GetAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync((ProtectionScopesResponse?)null); + + var psResponse = new ProtectionScopesResponse + { + Scopes = new List + { + new() + { + Activities = ProtectionScopeActivities.UploadText, + Locations = new List + { + new("microsoft.graph.policyLocationApplication", "app-123") + }, + ExecutionMode = ExecutionMode.EvaluateInline + } + } + }; + + this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(psResponse); + + var pcResponse = new ProcessContentResponse + { + PolicyActions = new List + { + new() { Action = DlpAction.NotifyUser } + } + }; + + this._mockPurviewClient.Setup(x => x.ProcessContentAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(pcResponse); + + // Act + var result = await this._processor.ProcessMessagesAsync( + messages, "thread-123", Activity.UploadText, settings, "user-123", CancellationToken.None); + + // Assert + Assert.False(result.shouldBlock); + Assert.Equal("user-123", result.userId); + } + + [Fact] + public async Task ProcessMessagesAsync_UsesCachedProtectionScopes_WhenAvailableAsync() + { + // Arrange + var messages = new List + { + new (ChatRole.User, "Test message") + }; + var settings = CreateValidPurviewSettings(); + var tokenInfo = new TokenInfo { TenantId = "tenant-123", UserId = "user-123", ClientId = "client-123" }; + + this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny(), null)) + .ReturnsAsync(tokenInfo); + + var cachedPsResponse = new ProtectionScopesResponse + { + Scopes = new List + { + new() + { + Activities = ProtectionScopeActivities.UploadText, + Locations = new List + { + new ("microsoft.graph.policyLocationApplication", "app-123") + }, + ExecutionMode = ExecutionMode.EvaluateInline + } + } + }; + + this._mockCacheProvider.Setup(x => x.GetAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(cachedPsResponse); + + var pcResponse = new ProcessContentResponse + { + PolicyActions = new List() + }; + + this._mockPurviewClient.Setup(x => x.ProcessContentAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(pcResponse); + + // Act + await this._processor.ProcessMessagesAsync( + messages, "thread-123", Activity.UploadText, settings, "user-123", CancellationToken.None); + + // Assert + this._mockPurviewClient.Verify(x => x.GetProtectionScopesAsync( + It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task ProcessMessagesAsync_InvalidatesCache_WhenProtectionScopeModifiedAsync() + { + // Arrange + var messages = new List + { + new (ChatRole.User, "Test message") + }; + var settings = CreateValidPurviewSettings(); + var tokenInfo = new TokenInfo { TenantId = "tenant-123", UserId = "user-123", ClientId = "client-123" }; + + this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny(), null)) + .ReturnsAsync(tokenInfo); + + this._mockCacheProvider.Setup(x => x.GetAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync((ProtectionScopesResponse?)null); + + var psResponse = new ProtectionScopesResponse + { + Scopes = new List + { + new() + { + Activities = ProtectionScopeActivities.UploadText, + Locations = new List + { + new ("microsoft.graph.policyLocationApplication", "app-123") + }, + ExecutionMode = ExecutionMode.EvaluateInline + } + } + }; + + this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(psResponse); + + var pcResponse = new ProcessContentResponse + { + ProtectionScopeState = ProtectionScopeState.Modified, + PolicyActions = new List() + }; + + this._mockPurviewClient.Setup(x => x.ProcessContentAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(pcResponse); + + // Act + await this._processor.ProcessMessagesAsync( + messages, "thread-123", Activity.UploadText, settings, "user-123", CancellationToken.None); + + // Assert + this._mockCacheProvider.Verify(x => x.RemoveAsync( + It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task ProcessMessagesAsync_SendsContentActivities_WhenNoApplicableScopesAsync() + { + // Arrange + var messages = new List + { + new (ChatRole.User, "Test message") + }; + var settings = CreateValidPurviewSettings(); + var tokenInfo = new TokenInfo { TenantId = "tenant-123", UserId = "user-123", ClientId = "client-123" }; + + this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny(), null)) + .ReturnsAsync(tokenInfo); + + this._mockCacheProvider.Setup(x => x.GetAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync((ProtectionScopesResponse?)null); + + var psResponse = new ProtectionScopesResponse + { + Scopes = new List + { + new() + { + Activities = ProtectionScopeActivities.UploadText, + Locations = new List + { + new ("microsoft.graph.policyLocationApplication", "app-456") + } + } + } + }; + + this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(psResponse); + + // Act + await this._processor.ProcessMessagesAsync( + messages, "thread-123", Activity.UploadText, settings, "user-123", CancellationToken.None); + + // Assert + // Content activities are now queued as background jobs, not called directly + this._mockChannelHandler.Verify(x => x.QueueJob(It.IsAny()), Times.Once); + this._mockPurviewClient.Verify(x => x.ProcessContentAsync( + It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task ProcessMessagesAsync_WithNoTenantId_ThrowsPurviewExceptionAsync() + { + // Arrange + var messages = new List + { + new (ChatRole.User, "Test message") + }; + var settings = new PurviewSettings("TestApp"); // No TenantId + var tokenInfo = new TokenInfo { UserId = "user-123", ClientId = "client-123" }; // No TenantId + + this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny(), null)) + .ReturnsAsync(tokenInfo); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + this._processor.ProcessMessagesAsync(messages, "thread-123", Activity.UploadText, settings, "user-123", CancellationToken.None)); + + Assert.Contains("No tenant id provided or inferred", exception.Message); + } + + [Fact] + public async Task ProcessMessagesAsync_WithNoUserId_ThrowsPurviewExceptionAsync() + { + // Arrange + var messages = new List + { + new (ChatRole.User, "Test message") + }; + var settings = CreateValidPurviewSettings(); + var tokenInfo = new TokenInfo { TenantId = "tenant-123", ClientId = "client-123" }; // No UserId + + this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny(), null)) + .ReturnsAsync(tokenInfo); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + this._processor.ProcessMessagesAsync(messages, "thread-123", Activity.UploadText, settings, null, CancellationToken.None)); + + Assert.Contains("No user id provided or inferred", exception.Message); + } + + [Fact] + public async Task ProcessMessagesAsync_ExtractsUserIdFromMessageAdditionalProperties_Async() + { + // Arrange + var messages = new List + { + new (ChatRole.User, "Test message") + { + AdditionalProperties = new AdditionalPropertiesDictionary + { + { "userId", "user-from-props" } + } + } + }; + var settings = CreateValidPurviewSettings(); + var tokenInfo = new TokenInfo { TenantId = "tenant-123", ClientId = "client-123" }; + + this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny(), null)) + .ReturnsAsync(tokenInfo); + + this._mockCacheProvider.Setup(x => x.GetAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync((ProtectionScopesResponse?)null); + + var psResponse = new ProtectionScopesResponse { Scopes = new List() }; + this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(psResponse); + + // Act + var result = await this._processor.ProcessMessagesAsync( + messages, "thread-123", Activity.UploadText, settings, null, CancellationToken.None); + + // Assert + Assert.Equal("user-from-props", result.userId); + } + + [Fact] + public async Task ProcessMessagesAsync_ExtractsUserIdFromMessageAuthorName_WhenValidGuidAsync() + { + // Arrange + var userId = Guid.NewGuid().ToString(); + var messages = new List + { + new (ChatRole.User, "Test message") + { + AuthorName = userId + } + }; + var settings = CreateValidPurviewSettings(); + var tokenInfo = new TokenInfo { TenantId = "tenant-123", ClientId = "client-123" }; + + this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny(), null)) + .ReturnsAsync(tokenInfo); + + this._mockCacheProvider.Setup(x => x.GetAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync((ProtectionScopesResponse?)null); + + var psResponse = new ProtectionScopesResponse { Scopes = new List() }; + this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(psResponse); + + // Act + var result = await this._processor.ProcessMessagesAsync( + messages, "thread-123", Activity.UploadText, settings, null, CancellationToken.None); + + // Assert + Assert.Equal(userId, result.userId); + } + + #endregion + + #region Helper Methods + + private static PurviewSettings CreateValidPurviewSettings() + { + return new PurviewSettings("TestApp") + { + TenantId = "tenant-123", + PurviewAppLocation = new PurviewAppLocation(PurviewLocationType.Application, "app-123") + }; + } + + #endregion +} diff --git a/dotnet/tests/OpenAIResponse.IntegrationTests/OpenAIResponseFixture.cs b/dotnet/tests/OpenAIResponse.IntegrationTests/OpenAIResponseFixture.cs index d223a65e28..fbb087a153 100644 --- a/dotnet/tests/OpenAIResponse.IntegrationTests/OpenAIResponseFixture.cs +++ b/dotnet/tests/OpenAIResponse.IntegrationTests/OpenAIResponseFixture.cs @@ -68,7 +68,7 @@ public class OpenAIResponseFixture(bool store) : IChatClientAgentFixture string name = "HelpfulAssistant", string instructions = "You are a helpful assistant.", IList? aiTools = null) => - new ChatClientAgent( + new( this._openAIResponseClient.AsIChatClient(), options: new() { diff --git a/python/.pre-commit-config.yaml b/python/.pre-commit-config.yaml index a6274114af..6d5df0b32c 100644 --- a/python/.pre-commit-config.yaml +++ b/python/.pre-commit-config.yaml @@ -47,7 +47,6 @@ repos: entry: uv --directory ./python run poe pre-commit-check language: system files: ^python/ - pass_filenames: false - repo: https://github.com/astral-sh/uv-pre-commit # uv version. rev: 0.7.18 diff --git a/python/.vscode/launch.json b/python/.vscode/launch.json index 4c6c3c0b01..fac3004e95 100644 --- a/python/.vscode/launch.json +++ b/python/.vscode/launch.json @@ -16,7 +16,7 @@ "name": "AG-UI Examples Server", "type": "debugpy", "request": "launch", - "module": "examples", + "module": "agent_framework_ag_ui_examples", "cwd": "${workspaceFolder}/packages/ag-ui", "console": "integratedTerminal", "justMyCode": false diff --git a/python/CHANGELOG.md b/python/CHANGELOG.md index 500c0b45cd..3c914b9fcb 100644 --- a/python/CHANGELOG.md +++ b/python/CHANGELOG.md @@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.0.0b251112.post1] - 2025-11-12 + +### Added + +- **agent-framework-azurefunctions**: Merge Azure Functions feature branch (#1916) + +### Fixed + +- **agent-framework-ag-ui**: fix tool call id mismatch in ag-ui ([#2166](https://github.com/microsoft/agent-framework/pull/2166)) + +## [1.0.0b251112] - 2025-11-12 + +### Added + +- **agent-framework-azure-ai**: Azure AI client based on new `azure-ai-projects` package ([#1910](https://github.com/microsoft/agent-framework/pull/1910)) +- **agent-framework-anthropic**: Add convenience method on data content ([#2083](https://github.com/microsoft/agent-framework/pull/2083)) + +### Changed + +- **agent-framework-core**: Update OpenAI samples to use agents ([#2012](https://github.com/microsoft/agent-framework/pull/2012)) + +### Fixed + +- **agent-framework-anthropic**: Fixed image handling in Anthropic client ([#2083](https://github.com/microsoft/agent-framework/pull/2083)) + ## [1.0.0b251111] - 2025-11-11 ### Added @@ -204,7 +229,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 For more information, see the [announcement blog post](https://devblogs.microsoft.com/foundry/introducing-microsoft-agent-framework-the-open-source-engine-for-agentic-ai-apps/). -[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251111...HEAD +[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251112.post1...HEAD +[1.0.0b251112.post1]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251112...python-1.0.0b251112.post1 +[1.0.0b251112]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251111...python-1.0.0b251112 [1.0.0b251111]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251108...python-1.0.0b251111 [1.0.0b251108]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251106.post1...python-1.0.0b251108 [1.0.0b251106.post1]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251106...python-1.0.0b251106.post1 diff --git a/python/check_md_code_blocks.py b/python/check_md_code_blocks.py index 1015fafdb1..7377a73038 100644 --- a/python/check_md_code_blocks.py +++ b/python/check_md_code_blocks.py @@ -33,13 +33,20 @@ def with_color(text: str, color: Colors) -> str: return f"{color.value}{text}{Colors.CEND.value}" -def expand_file_patterns(patterns: list[str]) -> list[str]: +def expand_file_patterns(patterns: list[str], skip_glob: bool = False) -> list[str]: """Expand glob patterns to actual file paths.""" all_files: list[str] = [] for pattern in patterns: - # Handle both relative and absolute paths - matches = glob.glob(pattern, recursive=True) - all_files.extend(matches) + if skip_glob: + # When skip_glob is True, treat patterns as literal file paths + # Only include if it's a markdown file + if pattern.endswith('.md'): + matches = glob.glob(pattern, recursive=False) + all_files.extend(matches) + else: + # Handle both relative and absolute paths with glob expansion + matches = glob.glob(pattern, recursive=True) + all_files.extend(matches) return sorted(set(all_files)) # Remove duplicates and sort @@ -126,8 +133,9 @@ if __name__ == "__main__": # Argument is a list of markdown files containing glob patterns parser.add_argument("markdown_files", nargs="+", help="Markdown files to check (supports glob patterns).") parser.add_argument("--exclude", action="append", help="Exclude files containing this pattern.") + parser.add_argument("--no-glob", action="store_true", help="Treat file arguments as literal paths (no glob expansion).") args = parser.parse_args() - - # Expand glob patterns to actual file paths - expanded_files = expand_file_patterns(args.markdown_files) + + # Expand glob patterns to actual file paths (or skip if --no-glob) + expanded_files = expand_file_patterns(args.markdown_files, skip_glob=args.no_glob) check_code_blocks(expanded_files, args.exclude) diff --git a/python/packages/a2a/pyproject.toml b/python/packages/a2a/pyproject.toml index 2780bdd481..b7d1fb840f 100644 --- a/python/packages/a2a/pyproject.toml +++ b/python/packages/a2a/pyproject.toml @@ -4,7 +4,7 @@ description = "A2A integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b251111" +version = "1.0.0b251112.post1" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_events.py b/python/packages/ag-ui/agent_framework_ag_ui/_events.py index 4117fd50bb..e10f3e92c8 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_events.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_events.py @@ -85,6 +85,7 @@ class AgentFrameworkEventBridge: self.input_messages = input_messages or [] self.pending_tool_calls: list[dict[str, Any]] = [] # Track tool calls for assistant message self.tool_results: list[dict[str, Any]] = [] # Track tool results + self.tool_calls_ended: set[str] = set() # Track which tool calls have had ToolCallEndEvent emitted async def from_agent_run_update(self, update: AgentRunResponseUpdate) -> list[BaseEvent]: """ @@ -118,12 +119,14 @@ class AgentFrameworkEventBridge: message_id=self.current_message_id, role="assistant", ) + logger.debug(f"Emitting TextMessageStartEvent with message_id={self.current_message_id}") events.append(start_event) event = TextMessageContentEvent( message_id=self.current_message_id, delta=content.text, ) + logger.debug(f"Emitting TextMessageContentEvent with delta: {content.text}") events.append(event) elif isinstance(content, FunctionCallContent): @@ -378,6 +381,7 @@ class AgentFrameworkEventBridge: ) logger.info(f"Emitting ToolCallEndEvent for completed tool call '{content.call_id}'") events.append(end_event) + self.tool_calls_ended.add(content.call_id) # Track that we emitted end event # Log total StateDeltaEvent count for this tool call if self.state_delta_count > 0: @@ -617,6 +621,7 @@ class AgentFrameworkEventBridge: f"Emitting ToolCallEndEvent for approval-required tool '{content.function_call.call_id}'" ) events.append(end_event) + self.tool_calls_ended.add(content.function_call.call_id) # Track that we emitted end event # Emit custom event for approval request # Note: In AG-UI protocol, the frontend handles interrupts automatically diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_message_adapters.py b/python/packages/ag-ui/agent_framework_ag_ui/_message_adapters.py index da8cb197f2..a6c1001386 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_message_adapters.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_message_adapters.py @@ -38,22 +38,69 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha """ result: list[ChatMessage] = [] for msg in messages: - # Check for backend tool rendering results FIRST (may not have role field) - if "actionExecutionId" in msg or "actionName" in msg: - # Backend tool rendering - convert to FunctionResultContent - from agent_framework import FunctionResultContent + # Handle standard tool result messages early (role="tool") to preserve provider invariants + # This path maps AG‑UI tool messages to FunctionResultContent with the correct tool_call_id + role_str = msg.get("role", "user") + if role_str == "tool": + # Prefer explicit tool_call_id fields; fall back to backend fields only if necessary + tool_call_id = msg.get("tool_call_id") or msg.get("toolCallId") - tool_call_id = msg.get("actionExecutionId", "") + # If no explicit tool_call_id, treat as backend tool rendering payloads where + # AG‑UI may send actionExecutionId/actionName. This must still map to the + # assistant's tool call id to satisfy provider requirements. + if not tool_call_id: + tool_call_id = msg.get("actionExecutionId") or "" + + # Extract raw content text + result_content = msg.get("content") + if result_content is None: + result_content = msg.get("result", "") + + # Distinguish approval payloads from actual tool results + is_approval = False + if isinstance(result_content, str) and result_content: + import json as _json + + try: + parsed = _json.loads(result_content) + is_approval = isinstance(parsed, dict) and "accepted" in parsed + except Exception: + is_approval = False + + if is_approval: + # Approval responses should be treated as user messages to trigger human-in-the-loop flow + chat_msg = ChatMessage( + role=Role.USER, + contents=[TextContent(text=str(result_content))], + additional_properties={"is_tool_result": True, "tool_call_id": str(tool_call_id or "")}, + ) + if "id" in msg: + chat_msg.message_id = msg["id"] + result.append(chat_msg) + continue + + chat_msg = ChatMessage( + role=Role.TOOL, + contents=[FunctionResultContent(call_id=str(tool_call_id), result=result_content)], + ) + if "id" in msg: + chat_msg.message_id = msg["id"] + result.append(chat_msg) + continue + + # Backend tool rendering payloads without an explicit role + # Prefer standard tool mapping above; this block only covers legacy/minimal payloads + if "actionExecutionId" in msg or "actionName" in msg: + # Prefer toolCallId if present; otherwise fall back to actionExecutionId + tool_call_id = msg.get("toolCallId") or msg.get("tool_call_id") or msg.get("actionExecutionId", "") result_content = msg.get("result", msg.get("content", "")) chat_msg = ChatMessage( - role=Role.TOOL, # Tool results must be tool role - contents=[FunctionResultContent(call_id=tool_call_id, result=result_content)], + role=Role.TOOL, + contents=[FunctionResultContent(call_id=str(tool_call_id), result=result_content)], ) - if "id" in msg: chat_msg.message_id = msg["id"] - result.append(chat_msg) continue @@ -93,55 +140,7 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha result.append(chat_msg) continue - role_str = msg.get("role", "user") - - # Handle tool result messages (with role="tool") - if role_str == "tool": - # Check if this is a standard tool result (has tool_call_id or toolCallId) - tool_call_id = msg.get("tool_call_id") or msg.get("toolCallId") - result_content = msg.get("content", "") - - # Distinguish between backend tool results and approval responses - # Approval responses have {"accepted": ...} structure - is_approval = False - if result_content: - import json - - try: - parsed_content = json.loads(result_content) - is_approval = "accepted" in parsed_content - except (json.JSONDecodeError, TypeError): - is_approval = False - - # Backend tool results have non-empty content WITHOUT "accepted" field - if tool_call_id and result_content and not is_approval: - # Tool execution result - convert to FunctionResultContent with correct role - from agent_framework import FunctionResultContent - - chat_msg = ChatMessage( - role=Role.TOOL, - contents=[FunctionResultContent(call_id=tool_call_id, result=result_content)], - ) - - if "id" in msg: - chat_msg.message_id = msg["id"] - - result.append(chat_msg) - continue - else: - # Human-in-the-loop approval response - mark for special handling - content = msg.get("content", "") - chat_msg = ChatMessage( - role=Role.USER, # Approval responses are user messages - contents=[TextContent(text=content)], - additional_properties={"is_tool_result": True, "tool_call_id": msg.get("toolCallId", "")}, - ) - - if "id" in msg: - chat_msg.message_id = msg["id"] - - result.append(chat_msg) - continue + # No special handling required for assistant/plain messages here role = _AGUI_TO_FRAMEWORK_ROLE.get(role_str, Role.USER) diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_orchestrators.py b/python/packages/ag-ui/agent_framework_ag_ui/_orchestrators.py index b5da7998ca..1f6a43d8c1 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_orchestrators.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_orchestrators.py @@ -16,7 +16,15 @@ from ag_ui.core import ( TextMessageEndEvent, TextMessageStartEvent, ) -from agent_framework import AgentProtocol, AgentThread, ChatAgent, TextContent +from agent_framework import ( + AgentProtocol, + AgentThread, + ChatAgent, + ChatMessage, + FunctionCallContent, + FunctionResultContent, + TextContent, +) from ._utils import convert_agui_tools_to_agent_framework, generate_event_id @@ -276,6 +284,129 @@ class DefaultOrchestrator(Orchestrator): response_format = context.agent.chat_options.response_format skip_text_content = response_format is not None + # Sanitizer: ensure tool results only follow assistant tool calls + # Also inject synthetic tool results for confirm_changes + def sanitize_tool_history(messages: list[ChatMessage]) -> list[ChatMessage]: + sanitized: list[ChatMessage] = [] + pending_tool_call_ids: set[str] | None = None + pending_confirm_changes_id: str | None = None + + for msg in messages: + role_value = msg.role.value if hasattr(msg.role, "value") else str(msg.role) + + if role_value == "assistant": + tool_ids = { + str(content.call_id) + for content in msg.contents or [] + if isinstance(content, FunctionCallContent) and content.call_id + } + # Check for confirm_changes tool call + confirm_changes_call = None + for content in msg.contents or []: + if isinstance(content, FunctionCallContent) and content.name == "confirm_changes": + confirm_changes_call = content + break + + sanitized.append(msg) + pending_tool_call_ids = tool_ids if tool_ids else None + pending_confirm_changes_id = ( + str(confirm_changes_call.call_id) + if confirm_changes_call and confirm_changes_call.call_id + else None + ) + continue + + if role_value == "user": + # Check if this user message is a confirm_changes response (JSON with "accepted" field) + # This must be checked BEFORE injecting synthetic results for pending tool calls + if pending_confirm_changes_id: + user_text = "" + for content in msg.contents or []: + if isinstance(content, TextContent): + user_text = content.text + break + + try: + parsed = json.loads(user_text) + if "accepted" in parsed: + # This is a confirm_changes response - inject synthetic tool result + logger.info( + f"Injecting synthetic tool result for confirm_changes call_id={pending_confirm_changes_id}" + ) + synthetic_result = ChatMessage( + role="tool", + contents=[ + FunctionResultContent( + call_id=pending_confirm_changes_id, + result="Confirmed" if parsed.get("accepted") else "Rejected", + ) + ], + ) + sanitized.append(synthetic_result) + if pending_tool_call_ids: + pending_tool_call_ids.discard(pending_confirm_changes_id) + pending_confirm_changes_id = None + # Don't add the user message to sanitized - it's been converted to tool result + continue + except (json.JSONDecodeError, KeyError) as e: + # Failed to parse user message as confirm_changes response; continue normal processing + logger.debug(f"Could not parse user message as confirm_changes response: {e}") + + # Before processing user message, check if there are pending tool calls without results + # This happens when assistant made multiple tool calls but only some got results + # This is checked AFTER confirm_changes special handling above + if pending_tool_call_ids: + logger.info( + f"User message arrived with {len(pending_tool_call_ids)} pending tool calls - injecting synthetic results" + ) + for pending_call_id in pending_tool_call_ids: + logger.info(f"Injecting synthetic tool result for pending call_id={pending_call_id}") + synthetic_result = ChatMessage( + role="tool", + contents=[ + FunctionResultContent( + call_id=pending_call_id, + result="Tool execution skipped - user provided follow-up message", + ) + ], + ) + sanitized.append(synthetic_result) + pending_tool_call_ids = None + pending_confirm_changes_id = None + + # Normal user message processing + sanitized.append(msg) + pending_confirm_changes_id = None + continue + + if role_value == "tool": + if not pending_tool_call_ids: + continue + keep = False + for content in msg.contents or []: + if isinstance(content, FunctionResultContent): + call_id = str(content.call_id) + if call_id in pending_tool_call_ids: + keep = True + # Note: We do NOT remove call_id from pending here. + # This allows duplicate tool results to pass through sanitization + # so the deduplicator can choose the best one (prefer non-empty results). + # We only clear pending_tool_call_ids when a user message arrives. + if call_id == pending_confirm_changes_id: + # For confirm_changes specifically, we do want to clear it + # since we only expect one response + pending_confirm_changes_id = None + break + if keep: + sanitized.append(msg) + continue + + sanitized.append(msg) + pending_tool_call_ids = None + pending_confirm_changes_id = None + + return sanitized + # Create event bridge event_bridge = AgentFrameworkEventBridge( run_id=context.run_id, @@ -328,22 +459,151 @@ class DefaultOrchestrator(Orchestrator): if current_state: thread.metadata["current_state"] = current_state # type: ignore[attr-defined] - # Add incoming AG-UI messages to the thread history - if context.messages: - await thread.on_new_messages(context.messages) - - # Use the full incoming message batch to preserve tool-call adjacency - if not context.messages: + raw_messages = context.messages or [] + if not raw_messages: logger.warning("No messages provided in AG-UI input") yield event_bridge.create_run_finished_event() return + logger.info(f"Received {len(raw_messages)} raw messages from client") + for i, msg in enumerate(raw_messages): + role = msg.role.value if hasattr(msg.role, "value") else str(msg.role) + msg_id = getattr(msg, "message_id", None) + logger.info(f" Raw message {i}: role={role}, id={msg_id}") + if hasattr(msg, "contents") and msg.contents: + for j, content in enumerate(msg.contents): + content_type = type(content).__name__ + if isinstance(content, TextContent): + logger.debug(f" Content {j}: {content_type} - {content.text}") + elif isinstance(content, FunctionCallContent): + logger.debug(f" Content {j}: {content_type} - {content.name}({content.arguments})") + elif isinstance(content, FunctionResultContent): + logger.debug( + f" Content {j}: {content_type} - call_id={content.call_id}, result={content.result}" + ) + else: + logger.debug(f" Content {j}: {content_type} - {content}") + + # After getting sanitized_messages, deduplicate them + def deduplicate_messages(messages: list[ChatMessage]) -> list[ChatMessage]: + """Remove duplicate messages while preserving order. + + For tool results with the same call_id, prefer the one with actual data. + """ + seen_keys: dict[Any, int] = {} # key -> index in unique_messages (key can be various tuple types) + unique_messages: list[ChatMessage] = [] + + for idx, msg in enumerate(messages): + role_value = msg.role.value if hasattr(msg.role, "value") else str(msg.role) + + # For tool messages, use call_id as unique key + if role_value == "tool" and msg.contents and isinstance(msg.contents[0], FunctionResultContent): + call_id = str(msg.contents[0].call_id) + key: Any = (role_value, call_id) + + # Check if we already have this tool result + if key in seen_keys: + existing_idx = seen_keys[key] + existing_msg = unique_messages[existing_idx] + + # Compare results - prefer non-empty over empty + existing_result = None + if existing_msg.contents and isinstance(existing_msg.contents[0], FunctionResultContent): + existing_result = existing_msg.contents[0].result + new_result = msg.contents[0].result + + # Replace if existing is empty/None and new has data + if (not existing_result or existing_result == "") and new_result: + logger.info( + f"Replacing empty tool result at index {existing_idx} with data from index {idx}" + ) + unique_messages[existing_idx] = msg + else: + logger.info(f"Skipping duplicate tool result at index {idx}: call_id={call_id}") + continue + + seen_keys[key] = len(unique_messages) + unique_messages.append(msg) + + elif ( + role_value == "assistant" + and msg.contents + and any(isinstance(c, FunctionCallContent) for c in msg.contents) + ): + # For assistant messages with tool_calls, use the tool call IDs + tool_call_ids = tuple( + sorted(str(c.call_id) for c in msg.contents if isinstance(c, FunctionCallContent) and c.call_id) + ) + key = (role_value, tool_call_ids) + + if key in seen_keys: + logger.info(f"Skipping duplicate assistant tool call at index {idx}") + continue + + seen_keys[key] = len(unique_messages) + unique_messages.append(msg) + + else: + # For other messages (system, user, assistant without tools), hash the content + content_str = str([str(c) for c in msg.contents]) if msg.contents else "" + key = (role_value, hash(content_str)) + + if key in seen_keys: + logger.info(f"Skipping duplicate message at index {idx}: role={role_value}") + continue + + seen_keys[key] = len(unique_messages) + unique_messages.append(msg) + + return unique_messages + + # Then use it: + sanitized_messages = sanitize_tool_history(raw_messages) + provider_messages = deduplicate_messages(sanitized_messages) + + if not provider_messages: + logger.info("No provider-eligible messages after filtering; finishing run without invoking agent.") + yield event_bridge.create_run_finished_event() + return + + logger.info(f"Processing {len(provider_messages)} provider messages after sanitization/deduplication") + for i, msg in enumerate(provider_messages): + role = msg.role.value if hasattr(msg.role, "value") else str(msg.role) + logger.info(f" Message {i}: role={role}") + if hasattr(msg, "contents") and msg.contents: + for j, content in enumerate(msg.contents): + content_type = type(content).__name__ + if isinstance(content, TextContent): + logger.info(f" Content {j}: {content_type} - {content.text}") + elif isinstance(content, FunctionCallContent): + logger.info(f" Content {j}: {content_type} - {content.name}({content.arguments})") + elif isinstance(content, FunctionResultContent): + logger.info( + f" Content {j}: {content_type} - call_id={content.call_id}, result={content.result}" + ) + else: + logger.info(f" Content {j}: {content_type} - {content}") + + # NOTE: For AG-UI, the client sends the full conversation history on each request. + # We should NOT add to thread.on_new_messages() as that would cause duplication. + # Instead, we pass messages directly to the agent via messages_to_run. + # Inject current state as system message context if we have state messages_to_run: list[Any] = [] - if current_state and context.config.state_schema: - state_json = json.dumps(current_state, indent=2) - from agent_framework import ChatMessage + conversation_has_tool_calls = False + logger.debug(f"Checking {len(provider_messages)} provider messages for tool calls") + for i, msg in enumerate(provider_messages): + logger.debug( + f" Message {i}: role={msg.role.value}, contents={len(msg.contents) if hasattr(msg, 'contents') and msg.contents else 0}" + ) + for msg in provider_messages: + if msg.role.value == "assistant" and hasattr(msg, "contents") and msg.contents: + if any(isinstance(content, FunctionCallContent) for content in msg.contents): + conversation_has_tool_calls = True + break + if current_state and context.config.state_schema and not conversation_has_tool_calls: + state_json = json.dumps(current_state, indent=2) state_context_msg = ChatMessage( role="system", contents=[ @@ -359,9 +619,9 @@ Never replace existing data - always append or merge.""" ) messages_to_run.append(state_context_msg) - # Preserve order from client to satisfy provider constraints (assistant tool_calls must - # immediately precede tool result messages). Using the full batch avoids reordering. - messages_to_run.extend(context.messages) + # Add all provider messages to messages_to_run + # AG-UI sends full conversation history on each request, so we pass it directly to the agent + messages_to_run.extend(provider_messages) # Handle client tools for hybrid execution # Client sends tool metadata, server merges with its own tools. @@ -370,11 +630,23 @@ Never replace existing data - always append or merge.""" from agent_framework import BaseChatClient client_tools = convert_agui_tools_to_agent_framework(context.input_data.get("tools")) + logger.info(f"[TOOLS] Client sent {len(client_tools) if client_tools else 0} tools") + if client_tools: + for tool in client_tools: + tool_name = getattr(tool, "name", "unknown") + declaration_only = getattr(tool, "declaration_only", None) + logger.info(f"[TOOLS] - Client tool: {tool_name}, declaration_only={declaration_only}") # Extract server tools - use type narrowing when possible server_tools: list[Any] = [] if isinstance(context.agent, ChatAgent): - server_tools = context.agent.chat_options.tools or [] + tools_from_agent = context.agent.chat_options.tools + server_tools = list(tools_from_agent) if tools_from_agent else [] + logger.info(f"[TOOLS] Agent has {len(server_tools)} configured tools") + for tool in server_tools: + tool_name = getattr(tool, "name", "unknown") + approval_mode = getattr(tool, "approval_mode", None) + logger.info(f"[TOOLS] - {tool_name}: approval_mode={approval_mode}") else: # AgentProtocol allows duck-typed implementations - fallback to attribute access # This supports test mocks and custom agent implementations @@ -412,15 +684,37 @@ Never replace existing data - always append or merge.""" except AttributeError: pass - combined_tools: list[Any] = [] - if server_tools: - combined_tools.extend(server_tools) + # For tools parameter: only pass if we have client tools to add + # If we pass tools=, it overrides the agent's configured tools and loses metadata like approval_mode + # So only pass tools when we need to add client tools on top of server tools + # IMPORTANT: Don't include client tools that duplicate server tools (same name) + tools_param = None if client_tools: - combined_tools.extend(client_tools) + # Get server tool names + server_tool_names = {getattr(tool, "name", None) for tool in server_tools} + + # Filter out client tools that duplicate server tools + unique_client_tools = [ + tool for tool in client_tools if getattr(tool, "name", None) not in server_tool_names + ] + + if unique_client_tools: + combined_tools: list[Any] = [] + if server_tools: + combined_tools.extend(server_tools) + combined_tools.extend(unique_client_tools) + tools_param = combined_tools + logger.info( + f"[TOOLS] Passing tools= parameter with {len(combined_tools)} tools ({len(server_tools)} server + {len(unique_client_tools)} unique client)" + ) + else: + logger.info("[TOOLS] All client tools duplicate server tools - not passing tools= parameter") + else: + logger.info("[TOOLS] No client tools - not passing tools= parameter (using agent's configured tools)") # Collect all updates to get the final structured output all_updates: list[Any] = [] - async for update in context.agent.run_stream(messages_to_run, thread=thread, tools=combined_tools or None): + async for update in context.agent.run_stream(messages_to_run, thread=thread, tools=tools_param): all_updates.append(update) events = await event_bridge.from_agent_run_update(update) for event in events: @@ -432,6 +726,27 @@ Never replace existing data - always append or merge.""" yield event_bridge.create_run_finished_event() return + # Check if there are pending tool calls (declaration-only tools that weren't executed) + # These need ToolCallEndEvent to signal the client to execute them + # Only emit for tool calls that haven't already had ToolCallEndEvent emitted + # (approval-required tools already had their end event emitted) + if event_bridge.pending_tool_calls: + pending_without_end = [ + tc for tc in event_bridge.pending_tool_calls if tc.get("id") not in event_bridge.tool_calls_ended + ] + if pending_without_end: + logger.info( + f"Found {len(pending_without_end)} pending tool calls without end event - emitting ToolCallEndEvent" + ) + for tool_call in pending_without_end: + tool_call_id = tool_call.get("id") + if tool_call_id: + from ag_ui.core import ToolCallEndEvent + + end_event = ToolCallEndEvent(tool_call_id=tool_call_id) + logger.info(f"Emitting ToolCallEndEvent for declaration-only tool call '{tool_call_id}'") + yield end_event + # After streaming completes, check if agent has response_format and extract structured output if all_updates and response_format: from agent_framework import AgentRunResponse diff --git a/python/packages/ag-ui/agent_framework_ag_ui_examples/README.md b/python/packages/ag-ui/agent_framework_ag_ui_examples/README.md index cd9c3c71c7..aed0f39b42 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui_examples/README.md +++ b/python/packages/ag-ui/agent_framework_ag_ui_examples/README.md @@ -10,11 +10,37 @@ pip install agent-framework-ag-ui ## Quick Start +### Using Example Agents with Any Chat Client + +All example agents are factory functions that accept any `ChatClientProtocol`-compatible chat client: + +```python +from fastapi import FastAPI +from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.openai import OpenAIChatClient +from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint +from agent_framework_ag_ui_examples.agents import simple_agent, weather_agent + +app = FastAPI() + +# Option 1: Use Azure OpenAI +azure_client = AzureOpenAIChatClient(model_id="gpt-4") +add_agent_framework_fastapi_endpoint(app, simple_agent(azure_client), "/chat") + +# Option 2: Use OpenAI +openai_client = OpenAIChatClient(model_id="gpt-4o") +add_agent_framework_fastapi_endpoint(app, weather_agent(openai_client), "/weather") + +# Run with: uvicorn main:app --reload +``` + +### Creating Your Own Agent + ```python from fastapi import FastAPI from agent_framework import ChatAgent from agent_framework.azure import AzureOpenAIChatClient -from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint +from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint # Create your agent agent = ChatAgent( @@ -44,38 +70,97 @@ This integration supports all 7 AG-UI features: ## Examples -Complete examples for all features are in the `examples/` directory: +All example agents are implemented as **factory functions** that accept any chat client implementing `ChatClientProtocol`. This provides maximum flexibility to use Azure OpenAI, OpenAI, Anthropic, or any custom chat client implementation. -- `examples/agents/simple_agent.py` - Basic agentic chat -- `examples/agents/weather_agent.py` - Backend tool rendering -- `examples/agents/task_planner_agent.py` - Human in the loop with approvals -- `examples/agents/research_assistant_agent.py` - Agentic generative UI -- `examples/agents/ui_generator_agent.py` - Tool-based generative UI -- `examples/agents/recipe_agent.py` - Shared state management -- `examples/agents/document_writer_agent.py` - Predictive state updates -- `examples/server/main.py` - FastAPI server with all endpoints +### Available Example Agents -Run the example server: +Complete examples for all AG-UI features are available: -```bash -cd examples/server -uvicorn main:app --reload +- `simple_agent(chat_client)` - Basic agentic chat (Feature 1) +- `weather_agent(chat_client)` - Backend tool rendering (Feature 2) +- `human_in_the_loop_agent(chat_client)` - Human-in-the-loop with step customization (Feature 3) +- `task_steps_agent_wrapped(chat_client)` - Agentic generative UI with step execution (Feature 4) +- `ui_generator_agent(chat_client)` - Tool-based generative UI (Feature 5) +- `recipe_agent(chat_client)` - Shared state management (Feature 6) +- `document_writer_agent(chat_client)` - Predictive state updates (Feature 7) +- `research_assistant_agent(chat_client)` - Research with progress events +- `task_planner_agent(chat_client)` - Task planning with approvals + +### Using Example Agents + +```python +from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.openai import OpenAIChatClient +from agent_framework_ag_ui_examples.agents import ( + simple_agent, + weather_agent, + recipe_agent, +) + +# Create a chat client (use any ChatClientProtocol implementation) +azure_client = AzureOpenAIChatClient(model_id="gpt-4") +openai_client = OpenAIChatClient(model_id="gpt-4o") + +# Create agent instances by calling the factory functions +agent1 = simple_agent(azure_client) +agent2 = weather_agent(openai_client) +agent3 = recipe_agent(azure_client) ``` -To enable debug logging: +### Running the Example Server + +The example server demonstrates all 7 AG-UI features: ```bash -ENABLE_DEBUG_LOGGING=1 uvicorn main:app --reload +# Install the package +pip install agent-framework-ag-ui + +# Run the example server +python -m agent_framework_ag_ui_examples + +# Or with debug logging +ENABLE_DEBUG_LOGGING=1 python -m agent_framework_ag_ui_examples ``` The server exposes endpoints at: -- `/agentic_chat` -- `/backend_tool_rendering` -- `/human_in_the_loop` -- `/agentic_generative_ui` -- `/tool_based_generative_ui` -- `/shared_state` -- `/predictive_state_updates` +- `/agentic_chat` - Simple chat with `simple_agent` +- `/backend_tool_rendering` - Weather tools with `weather_agent` +- `/human_in_the_loop` - Step approval with `human_in_the_loop_agent` +- `/agentic_generative_ui` - Task steps with `task_steps_agent_wrapped` +- `/tool_based_generative_ui` - Custom UI components with `ui_generator_agent` +- `/shared_state` - Recipe management with `recipe_agent` +- `/predictive_state_updates` - Document writing with `document_writer_agent` + +### Complete FastAPI Example + +```python +from fastapi import FastAPI +from agent_framework.azure import AzureOpenAIChatClient +from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint +from agent_framework_ag_ui_examples.agents import ( + simple_agent, + weather_agent, + human_in_the_loop_agent, + task_steps_agent_wrapped, + ui_generator_agent, + recipe_agent, + document_writer_agent, +) + +app = FastAPI(title="AG-UI Examples") + +# Create a chat client (shared across all agents, or create individual ones) +chat_client = AzureOpenAIChatClient(model_id="gpt-4") + +# Add all example endpoints +add_agent_framework_fastapi_endpoint(app, simple_agent(chat_client), "/agentic_chat") +add_agent_framework_fastapi_endpoint(app, weather_agent(chat_client), "/backend_tool_rendering") +add_agent_framework_fastapi_endpoint(app, human_in_the_loop_agent(chat_client), "/human_in_the_loop") +add_agent_framework_fastapi_endpoint(app, task_steps_agent_wrapped(chat_client), "/agentic_generative_ui") # type: ignore[arg-type] +add_agent_framework_fastapi_endpoint(app, ui_generator_agent(chat_client), "/tool_based_generative_ui") +add_agent_framework_fastapi_endpoint(app, recipe_agent(chat_client), "/shared_state") +add_agent_framework_fastapi_endpoint(app, document_writer_agent(chat_client), "/predictive_state_updates") +``` ## Architecture @@ -97,6 +182,48 @@ The package uses a clean, orchestrator-based architecture: ## Advanced Usage +### Creating Custom Agent Factories + +You can create your own agent factories following the same pattern as the examples: + +```python +from agent_framework import ChatAgent, ai_function +from agent_framework._clients import ChatClientProtocol +from agent_framework_ag_ui import AgentFrameworkAgent + +@ai_function +def my_tool(param: str) -> str: + """My custom tool.""" + return f"Result: {param}" + +def my_custom_agent(chat_client: ChatClientProtocol) -> AgentFrameworkAgent: + """Create a custom agent with the specified chat client. + + Args: + chat_client: The chat client to use for the agent + + Returns: + A configured AgentFrameworkAgent instance + """ + agent = ChatAgent( + name="my_custom_agent", + instructions="Custom instructions here", + chat_client=chat_client, + tools=[my_tool], + ) + + return AgentFrameworkAgent( + agent=agent, + name="MyCustomAgent", + description="My custom agent description", + ) + +# Use it +from agent_framework.azure import AzureOpenAIChatClient +chat_client = AzureOpenAIChatClient() +agent = my_custom_agent(chat_client) +``` + ### Shared State State is injected as system messages and updated via predictive state updates: diff --git a/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/__init__.py b/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/__init__.py index 720a16c765..2c3dd6554b 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/__init__.py +++ b/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/__init__.py @@ -6,7 +6,7 @@ from .document_writer_agent import document_writer_agent from .human_in_the_loop_agent import human_in_the_loop_agent from .recipe_agent import recipe_agent from .research_assistant_agent import research_assistant_agent -from .simple_agent import agent as simple_agent +from .simple_agent import simple_agent from .task_planner_agent import task_planner_agent from .task_steps_agent import task_steps_agent_wrapped from .ui_generator_agent import ui_generator_agent diff --git a/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/document_writer_agent.py b/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/document_writer_agent.py index ca7233a5a3..72623379ed 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/document_writer_agent.py +++ b/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/document_writer_agent.py @@ -3,7 +3,7 @@ """Example agent demonstrating predictive state updates with document writing.""" from agent_framework import ChatAgent, ai_function -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework._clients import ChatClientProtocol from agent_framework_ag_ui import AgentFrameworkAgent, DocumentWriterConfirmationStrategy @@ -28,31 +28,43 @@ def write_document_local(document: str) -> str: return "Document written." -agent = ChatAgent( - name="document_writer", - instructions=( - "You are a helpful assistant for writing documents. " - "To write the document, you MUST use the write_document_local tool. " - "You MUST write the full document, even when changing only a few words. " - "When you wrote the document, DO NOT repeat it as a message. " - "Just briefly summarize the changes you made. 2 sentences max. " - "\n\n" - "The current state of the document will be provided to you. " - "When editing, make minimal changes - do not change every word unless requested." - ), - chat_client=AzureOpenAIChatClient(), - tools=[write_document_local], +_DOCUMENT_WRITER_INSTRUCTIONS = ( + "You are a helpful assistant for writing documents. " + "To write the document, you MUST use the write_document_local tool. " + "You MUST write the full document, even when changing only a few words. " + "When you wrote the document, DO NOT repeat it as a message. " + "Just briefly summarize the changes you made. 2 sentences max. " + "\n\n" + "The current state of the document will be provided to you. " + "When editing, make minimal changes - do not change every word unless requested." ) -document_writer_agent = AgentFrameworkAgent( - agent=agent, - name="DocumentWriter", - description="Writes and edits documents with predictive state updates", - state_schema={ - "document": {"type": "string", "description": "The current document content"}, - }, - predict_state_config={ - "document": {"tool": "write_document_local", "tool_argument": "document"}, - }, - confirmation_strategy=DocumentWriterConfirmationStrategy(), -) + +def document_writer_agent(chat_client: ChatClientProtocol) -> AgentFrameworkAgent: + """Create a document writer agent with predictive state updates. + + Args: + chat_client: The chat client to use for the agent + + Returns: + A configured AgentFrameworkAgent instance with document writing capabilities + """ + agent = ChatAgent( + name="document_writer", + instructions=_DOCUMENT_WRITER_INSTRUCTIONS, + chat_client=chat_client, + tools=[write_document_local], + ) + + return AgentFrameworkAgent( + agent=agent, + name="DocumentWriter", + description="Writes and edits documents with predictive state updates", + state_schema={ + "document": {"type": "string", "description": "The current document content"}, + }, + predict_state_config={ + "document": {"tool": "write_document_local", "tool_argument": "document"}, + }, + confirmation_strategy=DocumentWriterConfirmationStrategy(), + ) diff --git a/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/human_in_the_loop_agent.py b/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/human_in_the_loop_agent.py index dfa1b30c63..8b178476af 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/human_in_the_loop_agent.py +++ b/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/human_in_the_loop_agent.py @@ -5,7 +5,7 @@ from enum import Enum from agent_framework import ChatAgent, ai_function -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework._clients import ChatClientProtocol from pydantic import BaseModel, Field @@ -43,10 +43,18 @@ def generate_task_steps(steps: list[TaskStep]) -> str: return f"Generated {len(steps)} execution steps for the task." -# Create the human-in-the-loop agent using tool-based approach for predictive state -human_in_the_loop_agent = ChatAgent( - name="human_in_the_loop_agent", - instructions="""You are a helpful assistant that can perform any task by breaking it down into steps. +def human_in_the_loop_agent(chat_client: ChatClientProtocol) -> ChatAgent: + """Create a human-in-the-loop agent using tool-based approach for predictive state. + + Args: + chat_client: The chat client to use for the agent + + Returns: + A configured ChatAgent instance with human-in-the-loop capabilities + """ + return ChatAgent( + name="human_in_the_loop_agent", + instructions="""You are a helpful assistant that can perform any task by breaking it down into steps. When asked to perform a task, you MUST call the `generate_task_steps` function with the proper number of steps per the request. @@ -71,6 +79,6 @@ human_in_the_loop_agent = ChatAgent( After calling the function, provide a brief acknowledgment like: "I've created a plan with 10 steps. You can customize which steps to enable before I proceed." """, - chat_client=AzureOpenAIChatClient(), - tools=[generate_task_steps], -) + chat_client=chat_client, + tools=[generate_task_steps], + ) diff --git a/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/recipe_agent.py b/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/recipe_agent.py index 2a5b94e1cc..dfdd058bc7 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/recipe_agent.py +++ b/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/recipe_agent.py @@ -5,7 +5,7 @@ from enum import Enum from agent_framework import ChatAgent, ai_function -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework._clients import ChatClientProtocol from pydantic import BaseModel, Field from agent_framework_ag_ui import AgentFrameworkAgent, RecipeConfirmationStrategy @@ -67,10 +67,7 @@ def update_recipe(recipe: Recipe) -> str: return "Recipe updated." -# Create the recipe agent using tool-based approach for streaming -agent = ChatAgent( - name="recipe_agent", - instructions="""You are a helpful recipe assistant that creates and modifies recipes. +_RECIPE_INSTRUCTIONS = """You are a helpful recipe assistant that creates and modifies recipes. CRITICAL RULES: 1. You will receive the current recipe state in the system context @@ -103,20 +100,34 @@ agent = ChatAgent( - Add aromatics: garlic, shallots - Add finishing touches: lemon zest, fresh parsley - Make instructions more detailed and professional - """, - chat_client=AzureOpenAIChatClient(), - tools=[update_recipe], -) + """ -recipe_agent = AgentFrameworkAgent( - agent=agent, - name="RecipeAgent", - description="Creates and modifies recipes with streaming state updates", - state_schema={ - "recipe": {"type": "object", "description": "The current recipe"}, - }, - predict_state_config={ - "recipe": {"tool": "update_recipe", "tool_argument": "recipe"}, - }, - confirmation_strategy=RecipeConfirmationStrategy(), -) + +def recipe_agent(chat_client: ChatClientProtocol) -> AgentFrameworkAgent: + """Create a recipe agent with streaming state updates. + + Args: + chat_client: The chat client to use for the agent + + Returns: + A configured AgentFrameworkAgent instance with recipe management + """ + agent = ChatAgent( + name="recipe_agent", + instructions=_RECIPE_INSTRUCTIONS, + chat_client=chat_client, + tools=[update_recipe], + ) + + return AgentFrameworkAgent( + agent=agent, + name="RecipeAgent", + description="Creates and modifies recipes with streaming state updates", + state_schema={ + "recipe": {"type": "object", "description": "The current recipe"}, + }, + predict_state_config={ + "recipe": {"tool": "update_recipe", "tool_argument": "recipe"}, + }, + confirmation_strategy=RecipeConfirmationStrategy(), + ) diff --git a/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/research_assistant_agent.py b/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/research_assistant_agent.py index 60d142e2c2..767ed1d961 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/research_assistant_agent.py +++ b/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/research_assistant_agent.py @@ -5,7 +5,7 @@ import asyncio from agent_framework import ChatAgent, ai_function -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework._clients import ChatClientProtocol from agent_framework_ag_ui import AgentFrameworkAgent @@ -82,19 +82,31 @@ async def analyze_data(dataset: str) -> str: return f"Analysis of '{dataset}':\n" + "\n".join(insights) -agent = ChatAgent( - name="research_assistant", - instructions=( - "You are a research and analysis assistant. " - "You can research topics, create presentations, and analyze data. " - "Use the available tools to help users with their research needs." - ), - chat_client=AzureOpenAIChatClient(), - tools=[research_topic, create_presentation, analyze_data], +_RESEARCH_ASSISTANT_INSTRUCTIONS = ( + "You are a research and analysis assistant. " + "You can research topics, create presentations, and analyze data. " + "Use the available tools to help users with their research needs." ) -research_assistant_agent = AgentFrameworkAgent( - agent=agent, - name="ResearchAssistant", - description="Research assistant that emits progress events during task execution", -) + +def research_assistant_agent(chat_client: ChatClientProtocol) -> AgentFrameworkAgent: + """Create a research assistant agent with progress events. + + Args: + chat_client: The chat client to use for the agent + + Returns: + A configured AgentFrameworkAgent instance with research capabilities + """ + agent = ChatAgent( + name="research_assistant", + instructions=_RESEARCH_ASSISTANT_INSTRUCTIONS, + chat_client=chat_client, + tools=[research_topic, create_presentation, analyze_data], + ) + + return AgentFrameworkAgent( + agent=agent, + name="ResearchAssistant", + description="Research assistant that emits progress events during task execution", + ) diff --git a/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/simple_agent.py b/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/simple_agent.py index 4831f1442c..bb63170399 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/simple_agent.py +++ b/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/simple_agent.py @@ -3,11 +3,20 @@ """Simple agentic chat example (Feature 1: Agentic Chat).""" from agent_framework import ChatAgent -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework._clients import ChatClientProtocol -# Create a simple chat agent -agent = ChatAgent( - name="simple_chat_agent", - instructions="You are a helpful assistant. Be concise and friendly.", - chat_client=AzureOpenAIChatClient(), -) + +def simple_agent(chat_client: ChatClientProtocol) -> ChatAgent: + """Create a simple chat agent. + + Args: + chat_client: The chat client to use for the agent + + Returns: + A configured ChatAgent instance + """ + return ChatAgent( + name="simple_chat_agent", + instructions="You are a helpful assistant. Be concise and friendly.", + chat_client=chat_client, + ) diff --git a/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/task_planner_agent.py b/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/task_planner_agent.py index 58d8b8c556..f9eea2669b 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/task_planner_agent.py +++ b/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/task_planner_agent.py @@ -3,7 +3,7 @@ """Example agent demonstrating human-in-the-loop with function approvals.""" from agent_framework import ChatAgent, ai_function -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework._clients import ChatClientProtocol from agent_framework_ag_ui import AgentFrameworkAgent, TaskPlannerConfirmationStrategy @@ -54,20 +54,32 @@ def book_meeting_room(room_name: str, date: str, start_time: str, end_time: str) return f"Meeting room '{room_name}' booked for {date} from {start_time} to {end_time}" -agent = ChatAgent( - name="task_planner", - instructions=( - "You are a helpful assistant that plans and executes tasks. " - "You have access to calendar, email, and meeting room booking functions. " - "All of these actions require user approval before execution." - ), - chat_client=AzureOpenAIChatClient(), - tools=[create_calendar_event, send_email, book_meeting_room], +_TASK_PLANNER_INSTRUCTIONS = ( + "You are a helpful assistant that plans and executes tasks. " + "You have access to calendar, email, and meeting room booking functions. " + "All of these actions require user approval before execution." ) -task_planner_agent = AgentFrameworkAgent( - agent=agent, - name="TaskPlanner", - description="Plans and executes tasks with user approval", - confirmation_strategy=TaskPlannerConfirmationStrategy(), -) + +def task_planner_agent(chat_client: ChatClientProtocol) -> AgentFrameworkAgent: + """Create a task planner agent with user approval for actions. + + Args: + chat_client: The chat client to use for the agent + + Returns: + A configured AgentFrameworkAgent instance with task planning capabilities + """ + agent = ChatAgent( + name="task_planner", + instructions=_TASK_PLANNER_INSTRUCTIONS, + chat_client=chat_client, + tools=[create_calendar_event, send_email, book_meeting_room], + ) + + return AgentFrameworkAgent( + agent=agent, + name="TaskPlanner", + description="Plans and executes tasks with user approval", + confirmation_strategy=TaskPlannerConfirmationStrategy(), + ) diff --git a/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/task_steps_agent.py b/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/task_steps_agent.py index a2856dbf23..332e416215 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/task_steps_agent.py +++ b/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/task_steps_agent.py @@ -19,7 +19,7 @@ from ag_ui.core import ( ToolCallStartEvent, ) from agent_framework import ChatAgent, ai_function -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework._clients import ChatClientProtocol from pydantic import BaseModel, Field from agent_framework_ag_ui import AgentFrameworkAgent @@ -54,10 +54,18 @@ def generate_task_steps(steps: list[TaskStep]) -> str: return "Steps generated." -# Create the task steps agent using tool-based approach for streaming -agent = ChatAgent( - name="task_steps_agent", - instructions="""You are a helpful assistant that breaks down tasks into actionable steps. +def _create_task_steps_agent(chat_client: ChatClientProtocol) -> AgentFrameworkAgent: + """Create the task steps agent using tool-based approach for streaming. + + Args: + chat_client: The chat client to use for the agent + + Returns: + A configured AgentFrameworkAgent instance + """ + agent = ChatAgent( + name="task_steps_agent", + instructions="""You are a helpful assistant that breaks down tasks into actionable steps. When asked to perform a task, you MUST: 1. Use the generate_task_steps tool to create the steps @@ -75,25 +83,25 @@ agent = ChatAgent( - "Installing platform" - "Adding finishing touches" """, - chat_client=AzureOpenAIChatClient(), - tools=[generate_task_steps], -) + chat_client=chat_client, + tools=[generate_task_steps], + ) -task_steps_agent = AgentFrameworkAgent( - agent=agent, - name="TaskStepsAgent", - description="Generates task steps with streaming state updates", - state_schema={ - "steps": {"type": "array", "description": "The list of task steps"}, - }, - predict_state_config={ - "steps": { - "tool": "generate_task_steps", - "tool_argument": "steps", - } - }, - require_confirmation=False, # Agentic generative UI updates automatically without confirmation -) + return AgentFrameworkAgent( + agent=agent, + name="TaskStepsAgent", + description="Generates task steps with streaming state updates", + state_schema={ + "steps": {"type": "array", "description": "The list of task steps"}, + }, + predict_state_config={ + "steps": { + "tool": "generate_task_steps", + "tool_argument": "steps", + } + }, + require_confirmation=False, # Agentic generative UI updates automatically without confirmation + ) # Wrap the agent's run method to add step execution simulation @@ -131,7 +139,7 @@ class TaskStepsAgentWithExecution: logger.info("TaskStepsAgentWithExecution.run_agent() called - wrapper is active") # First, run the base agent to generate the plan - buffer text messages - final_state: dict[str, Any] | None = None + final_state: dict[str, Any] = {} run_finished_event: Any = None tool_call_id: str | None = None buffered_text_events: list[Any] = [] # Buffer text from first LLM call @@ -142,9 +150,20 @@ class TaskStepsAgentWithExecution: match event: case StateSnapshotEvent(snapshot=snapshot): - final_state = snapshot + final_state = snapshot.copy() if snapshot else {} logger.info(f"Captured STATE_SNAPSHOT event with state: {final_state}") yield event + case StateDeltaEvent(delta=delta): + # Apply state delta to final_state + if delta: + for patch in delta: + if patch.get("op") == "replace" and patch.get("path") == "/steps": + final_state["steps"] = patch.get("value", []) + logger.info( + f"Applied STATE_DELTA: updated steps to {len(final_state.get('steps', []))} items" + ) + logger.info(f"Yielding event immediately: {event_type_str}") + yield event case RunFinishedEvent(): run_finished_event = event logger.info("Captured RUN_FINISHED event - will send after step execution and summary") @@ -314,5 +333,14 @@ class TaskStepsAgentWithExecution: yield run_finished_event -# Export the wrapped agent -task_steps_agent_wrapped = TaskStepsAgentWithExecution(task_steps_agent) +def task_steps_agent_wrapped(chat_client: ChatClientProtocol) -> TaskStepsAgentWithExecution: + """Create a task steps agent with execution simulation. + + Args: + chat_client: The chat client to use for the agent + + Returns: + A wrapped agent instance with step execution simulation + """ + base_agent = _create_task_steps_agent(chat_client) + return TaskStepsAgentWithExecution(base_agent) diff --git a/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/ui_generator_agent.py b/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/ui_generator_agent.py index 2456ccb5e1..12f1a9341e 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/ui_generator_agent.py +++ b/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/ui_generator_agent.py @@ -4,23 +4,39 @@ from typing import Any -from agent_framework import ChatAgent, ai_function -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework import AIFunction, ChatAgent +from agent_framework._clients import ChatClientProtocol from agent_framework_ag_ui import AgentFrameworkAgent - -@ai_function -def generate_haiku(english: list[str], japanese: list[str], image_name: str | None, gradient: str) -> str: - """Generate a haiku with image and gradient background (FRONTEND_RENDER). +# Declaration-only tools (func=None) - actual rendering happens on the client side +generate_haiku = AIFunction[Any, str]( + name="generate_haiku", + description="""Generate a haiku with image and gradient background (FRONTEND_RENDER). This tool generates UI for displaying a haiku with an image and gradient background. - The frontend should render this as a custom haiku component. - - Args: - english: English haiku lines (exactly 3 lines) - japanese: Japanese haiku lines (exactly 3 lines) - image_name: Image filename for visual accompaniment. Must be one of: + The frontend should render this as a custom haiku component.""", + func=None, # Makes declaration_only=True so client renders the UI + input_model={ + "type": "object", + "properties": { + "english": { + "type": "array", + "items": {"type": "string"}, + "description": "English haiku lines (exactly 3 lines)", + "minItems": 3, + "maxItems": 3, + }, + "japanese": { + "type": "array", + "items": {"type": "string"}, + "description": "Japanese haiku lines (exactly 3 lines)", + "minItems": 3, + "maxItems": 3, + }, + "image_name": { + "type": "string", + "description": """Image filename for visual accompaniment. Must be one of: - "Osaka_Castle_Turret_Stone_Wall_Pine_Trees_Daytime.jpg" - "Tokyo_Skyline_Night_Tokyo_Tower_Mount_Fuji_View.jpg" - "Itsukushima_Shrine_Miyajima_Floating_Torii_Gate_Sunset_Long_Exposure.jpg" @@ -31,71 +47,100 @@ def generate_haiku(english: list[str], japanese: list[str], image_name: str | No - "Senso-ji_Temple_Asakusa_Cherry_Blossoms_Kimono_Umbrella.jpg" - "Cherry_Blossoms_Sakura_Night_View_City_Lights_Japan.jpg" - "Mount_Fuji_Lake_Reflection_Cherry_Blossoms_Sakura_Spring.jpg" - gradient: CSS gradient string for background (e.g., "linear-gradient(135deg, #667eea 0%, #764ba2 100%)") + """, + }, + "gradient": { + "type": "string", + "description": 'CSS gradient string for background (e.g., "linear-gradient(135deg, #667eea 0%, #764ba2 100%)")', + }, + }, + "required": ["english", "japanese", "image_name", "gradient"], + }, +) - Returns: - Haiku metadata for frontend rendering - """ - return f"Haiku generated with image: {image_name}" - - -@ai_function -def create_chart(chart_type: str, data_points: list[dict[str, Any]], title: str) -> str: - """Create an interactive chart (FRONTEND_RENDER). +create_chart = AIFunction[Any, str]( + name="create_chart", + description="""Create an interactive chart (FRONTEND_RENDER). This tool creates chart specifications for frontend rendering. - The frontend should render this as an interactive chart component. + The frontend should render this as an interactive chart component.""", + func=None, # Makes declaration_only=True so client renders the UI + input_model={ + "type": "object", + "properties": { + "chart_type": { + "type": "string", + "description": "Type of chart (bar, line, pie, scatter)", + }, + "data_points": { + "type": "array", + "items": {"type": "object"}, + "description": "Data points for the chart", + }, + "title": { + "type": "string", + "description": "Chart title", + }, + }, + "required": ["chart_type", "data_points", "title"], + }, +) - Args: - chart_type: Type of chart (bar, line, pie, scatter) - data_points: Data points for the chart - title: Chart title - - Returns: - Chart specification for frontend rendering - """ - return f"Chart '{title}' created with {len(data_points)} data points" - - -@ai_function -def display_timeline(events: list[dict[str, Any]], start_date: str, end_date: str) -> str: - """Display an interactive timeline (FRONTEND_RENDER). +display_timeline = AIFunction[Any, str]( + name="display_timeline", + description="""Display an interactive timeline (FRONTEND_RENDER). This tool creates timeline specifications for frontend rendering. - The frontend should render this as an interactive timeline component. + The frontend should render this as an interactive timeline component.""", + func=None, # Makes declaration_only=True so client renders the UI + input_model={ + "type": "object", + "properties": { + "events": { + "type": "array", + "items": {"type": "object"}, + "description": "Events to display on the timeline", + }, + "start_date": { + "type": "string", + "description": "Timeline start date", + }, + "end_date": { + "type": "string", + "description": "Timeline end date", + }, + }, + "required": ["events", "start_date", "end_date"], + }, +) - Args: - events: Events to display on the timeline - start_date: Timeline start date - end_date: Timeline end date - - Returns: - Timeline specification for frontend rendering - """ - return f"Timeline created with {len(events)} events from {start_date} to {end_date}" - - -@ai_function -def show_comparison_table(items: list[dict[str, Any]], columns: list[str]) -> str: - """Show a comparison table (FRONTEND_RENDER). +show_comparison_table = AIFunction[Any, str]( + name="show_comparison_table", + description="""Show a comparison table (FRONTEND_RENDER). This tool creates table specifications for frontend rendering. - The frontend should render this as an interactive comparison table. - - Args: - items: Items to compare - columns: Column names - - Returns: - Table specification for frontend rendering - """ - return f"Comparison table created with {len(items)} items and {len(columns)} columns" + The frontend should render this as an interactive comparison table.""", + func=None, # Makes declaration_only=True so client renders the UI + input_model={ + "type": "object", + "properties": { + "items": { + "type": "array", + "items": {"type": "object"}, + "description": "Items to compare", + }, + "columns": { + "type": "array", + "items": {"type": "string"}, + "description": "Column names", + }, + }, + "required": ["items", "columns"], + }, +) -# Create the UI generator agent using tool-based approach with forced tool usage -agent = ChatAgent( - name="ui_generator", - instructions="""You MUST use the provided tools to generate content. Never respond with plain text descriptions. +_UI_GENERATOR_INSTRUCTIONS = """You MUST use the provided tools to generate content. Never respond with plain text descriptions. For haiku requests: - Call generate_haiku tool with all 4 required parameters @@ -105,15 +150,29 @@ agent = ChatAgent( - gradient: CSS gradient string For other requests, use the appropriate tool (create_chart, display_timeline, show_comparison_table). - """, - chat_client=AzureOpenAIChatClient(), - tools=[generate_haiku, create_chart, display_timeline, show_comparison_table], - # Force tool usage - the LLM MUST call a tool, cannot respond with plain text - chat_options={"tool_choice": "required"}, -) + """ -ui_generator_agent = AgentFrameworkAgent( - agent=agent, - name="UIGenerator", - description="Generates custom UI components through tool calls", -) + +def ui_generator_agent(chat_client: ChatClientProtocol) -> AgentFrameworkAgent: + """Create a UI generator agent with frontend rendering tools. + + Args: + chat_client: The chat client to use for the agent + + Returns: + A configured AgentFrameworkAgent instance with UI generation tools + """ + agent = ChatAgent( + name="ui_generator", + instructions=_UI_GENERATOR_INSTRUCTIONS, + chat_client=chat_client, + tools=[generate_haiku, create_chart, display_timeline, show_comparison_table], + # Force tool usage - the LLM MUST call a tool, cannot respond with plain text + chat_options={"tool_choice": "required"}, + ) + + return AgentFrameworkAgent( + agent=agent, + name="UIGenerator", + description="Generates custom UI components through tool calls", + ) diff --git a/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/weather_agent.py b/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/weather_agent.py index a224bb7cd0..e48a9cab50 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/weather_agent.py +++ b/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/weather_agent.py @@ -5,7 +5,7 @@ from typing import Any from agent_framework import ChatAgent, ai_function -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework._clients import ChatClientProtocol @ai_function @@ -58,14 +58,22 @@ def get_forecast(location: str, days: int = 3) -> str: return f"{days}-day forecast for {location}:\n" + "\n".join(forecast) -# Create the weather agent -weather_agent = ChatAgent( - name="weather_agent", - instructions=( - "You are a helpful weather assistant. " - "Use the get_weather and get_forecast functions to help users with weather information. " - "Always provide friendly and informative responses." - ), - chat_client=AzureOpenAIChatClient(), - tools=[get_weather, get_forecast], -) +def weather_agent(chat_client: ChatClientProtocol) -> ChatAgent: + """Create a weather agent with get_weather and get_forecast tools. + + Args: + chat_client: The chat client to use for the agent + + Returns: + A configured ChatAgent instance with weather tools + """ + return ChatAgent( + name="weather_agent", + instructions=( + "You are a helpful weather assistant. " + "Use the get_weather and get_forecast functions to help users with weather information. " + "Always provide friendly and informative responses." + ), + chat_client=chat_client, + tools=[get_weather, get_forecast], + ) diff --git a/python/packages/ag-ui/agent_framework_ag_ui_examples/server/api/backend_tool_rendering.py b/python/packages/ag-ui/agent_framework_ag_ui_examples/server/api/backend_tool_rendering.py index fb8f88e6a4..0ff360efb2 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui_examples/server/api/backend_tool_rendering.py +++ b/python/packages/ag-ui/agent_framework_ag_ui_examples/server/api/backend_tool_rendering.py @@ -2,6 +2,7 @@ """Backend tool rendering endpoint.""" +from agent_framework.azure import AzureOpenAIChatClient from fastapi import FastAPI from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint @@ -15,8 +16,11 @@ def register_backend_tool_rendering(app: FastAPI) -> None: Args: app: The FastAPI application. """ + # Create a chat client and call the factory function + chat_client = AzureOpenAIChatClient() + add_agent_framework_fastapi_endpoint( app, - weather_agent, + weather_agent(chat_client), "/backend_tool_rendering", ) diff --git a/python/packages/ag-ui/agent_framework_ag_ui_examples/server/main.py b/python/packages/ag-ui/agent_framework_ag_ui_examples/server/main.py index 6841f3db20..e633268c50 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui_examples/server/main.py +++ b/python/packages/ag-ui/agent_framework_ag_ui_examples/server/main.py @@ -6,6 +6,7 @@ import logging import os import uvicorn +from agent_framework.azure import AzureOpenAIChatClient from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware @@ -14,8 +15,8 @@ from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint from ..agents.document_writer_agent import document_writer_agent from ..agents.human_in_the_loop_agent import human_in_the_loop_agent from ..agents.recipe_agent import recipe_agent -from ..agents.simple_agent import agent as simple_agent -from ..agents.task_steps_agent import task_steps_agent_wrapped as task_steps_agent # Custom wrapper +from ..agents.simple_agent import simple_agent +from ..agents.task_steps_agent import task_steps_agent_wrapped from ..agents.ui_generator_agent import ui_generator_agent from ..agents.weather_agent import weather_agent @@ -58,38 +59,42 @@ app.add_middleware( allow_headers=["*"], ) +# Create a shared chat client for all agents +# You can use different chat clients for different agents if needed +chat_client = AzureOpenAIChatClient() + # Agentic Chat - basic chat agent add_agent_framework_fastapi_endpoint( app=app, - agent=simple_agent, + agent=simple_agent(chat_client), path="/agentic_chat", ) # Backend Tool Rendering - agent with tools add_agent_framework_fastapi_endpoint( app=app, - agent=weather_agent, + agent=weather_agent(chat_client), path="/backend_tool_rendering", ) # Shared State - recipe agent with structured output add_agent_framework_fastapi_endpoint( app=app, - agent=recipe_agent, + agent=recipe_agent(chat_client), path="/shared_state", ) # Predictive State Updates - document writer with predictive state add_agent_framework_fastapi_endpoint( app=app, - agent=document_writer_agent, + agent=document_writer_agent(chat_client), path="/predictive_state_updates", ) # Human in the Loop - human-in-the-loop agent with step customization add_agent_framework_fastapi_endpoint( app=app, - agent=human_in_the_loop_agent, + agent=human_in_the_loop_agent(chat_client), path="/human_in_the_loop", state_schema={"steps": {"type": "array"}}, predict_state_config={"steps": {"tool": "generate_task_steps", "tool_argument": "steps"}}, @@ -98,23 +103,26 @@ add_agent_framework_fastapi_endpoint( # Agentic Generative UI - task steps agent with streaming state updates add_agent_framework_fastapi_endpoint( app=app, - agent=task_steps_agent, # type: ignore[arg-type] + agent=task_steps_agent_wrapped(chat_client), # type: ignore[arg-type] path="/agentic_generative_ui", ) # Tool-based Generative UI - UI generator with frontend-rendered tools add_agent_framework_fastapi_endpoint( app=app, - agent=ui_generator_agent, + agent=ui_generator_agent(chat_client), path="/tool_based_generative_ui", ) def main(): """Run the server.""" - port = int(os.getenv("PORT", "8888")) + port = int(os.getenv("PORT", "8887")) host = os.getenv("HOST", "127.0.0.1") + print(f"\nAG-UI Examples Server starting on http://{host}:{port}") + print("Set ENABLE_DEBUG_LOGGING=1 for detailed request logging\n") + # Use log_config=None to prevent uvicorn from reconfiguring logging # This preserves our file + console logging setup uvicorn.run( diff --git a/python/packages/ag-ui/pyproject.toml b/python/packages/ag-ui/pyproject.toml index 9216a17e24..95b0f46bc3 100644 --- a/python/packages/ag-ui/pyproject.toml +++ b/python/packages/ag-ui/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "agent-framework-ag-ui" -version = "1.0.0b251111" +version = "1.0.0b251112.post1" description = "AG-UI protocol integration for Agent Framework" readme = "README.md" license-files = ["LICENSE"] diff --git a/python/packages/ag-ui/tests/test_agent_wrapper_comprehensive.py b/python/packages/ag-ui/tests/test_agent_wrapper_comprehensive.py index 723e369c43..4b5b770509 100644 --- a/python/packages/ag-ui/tests/test_agent_wrapper_comprehensive.py +++ b/python/packages/ag-ui/tests/test_agent_wrapper_comprehensive.py @@ -505,17 +505,20 @@ async def test_error_handling_with_exception(): async def test_json_decode_error_in_tool_result(): - """Test handling of JSONDecodeError when parsing tool result.""" + """Test handling of orphaned tool result - should be sanitized out.""" from agent_framework_ag_ui import AgentFrameworkAgent class MockChatClient: async def get_streaming_response(self, messages, chat_options, **kwargs): - yield ChatResponseUpdate(contents=[TextContent(text="Fallback response")]) + # Should not be called since orphaned tool result is dropped + if False: + yield + raise AssertionError("ChatClient should not be called with orphaned tool result") agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient()) wrapper = AgentFrameworkAgent(agent=agent) - # Send invalid JSON as tool result + # Send invalid JSON as tool result without preceding tool call input_data = { "messages": [ { @@ -530,10 +533,12 @@ async def test_json_decode_error_in_tool_result(): async for event in wrapper.run_agent(input_data): events.append(event) - # Should fall through to normal agent processing + # Orphaned tool result should be sanitized out + # Only run lifecycle events should be emitted, no text/tool events text_events = [e for e in events if e.type == "TEXT_MESSAGE_CONTENT"] - assert len(text_events) > 0 - assert text_events[0].delta == "Fallback response" + tool_events = [e for e in events if e.type.startswith("TOOL_CALL")] + assert len(text_events) == 0 + assert len(tool_events) == 0 async def test_suppressed_summary_with_document_state(): diff --git a/python/packages/ag-ui/tests/test_orchestrators_coverage.py b/python/packages/ag-ui/tests/test_orchestrators_coverage.py new file mode 100644 index 0000000000..81e41dee5f --- /dev/null +++ b/python/packages/ag-ui/tests/test_orchestrators_coverage.py @@ -0,0 +1,811 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Comprehensive tests for orchestrator coverage.""" + +from collections.abc import AsyncGenerator +from types import SimpleNamespace +from typing import Any + +from agent_framework import ( + AgentRunResponseUpdate, + ChatMessage, + TextContent, + ai_function, +) +from pydantic import BaseModel + +from agent_framework_ag_ui._agent import AgentConfig +from agent_framework_ag_ui._orchestrators import ( + DefaultOrchestrator, + ExecutionContext, + HumanInTheLoopOrchestrator, +) + + +@ai_function(approval_mode="always_require") +def approval_tool(param: str) -> str: + """Tool requiring approval.""" + return f"executed: {param}" + + +class MockAgent: + """Mock agent for testing.""" + + def __init__(self, updates: list[AgentRunResponseUpdate] | None = None) -> None: + self.updates = updates or [AgentRunResponseUpdate(contents=[TextContent(text="response")], role="assistant")] + self.chat_options = SimpleNamespace(tools=[approval_tool], response_format=None) + self.chat_client = SimpleNamespace(function_invocation_configuration=None) + self.messages_received: list[Any] = [] + self.tools_received: list[Any] | None = None + + async def run_stream( + self, + messages: list[Any], + *, + thread: Any = None, + tools: list[Any] | None = None, + ) -> AsyncGenerator[AgentRunResponseUpdate, None]: + self.messages_received = messages + self.tools_received = tools + for update in self.updates: + yield update + + +async def test_human_in_the_loop_json_decode_error() -> None: + """Test HumanInTheLoopOrchestrator handles invalid JSON in tool result.""" + orchestrator = HumanInTheLoopOrchestrator() + + input_data = { + "messages": [ + { + "role": "tool", + "content": [{"type": "text", "text": "not valid json {"}], + } + ], + } + + messages = [ + ChatMessage( + role="tool", + contents=[TextContent(text="not valid json {")], + additional_properties={"is_tool_result": True}, + ) + ] + + context = ExecutionContext( + input_data=input_data, + agent=MockAgent(), + config=AgentConfig(), + ) + context._messages = messages + + assert orchestrator.can_handle(context) + + events = [] + async for event in orchestrator.run(context): + events.append(event) + + # Should emit RunErrorEvent for invalid JSON + error_events = [e for e in events if e.type == "RUN_ERROR"] + assert len(error_events) == 1 + assert "Invalid tool result format" in error_events[0].message + + +async def test_sanitize_tool_history_confirm_changes() -> None: + """Test sanitize_tool_history logic for confirm_changes synthetic result.""" + from agent_framework import ChatMessage, FunctionCallContent, TextContent + + # Create messages that will trigger confirm_changes synthetic result injection + messages = [ + ChatMessage( + role="assistant", + contents=[ + FunctionCallContent( + name="confirm_changes", + call_id="call_confirm_123", + arguments='{"changes": "test"}', + ) + ], + ), + ChatMessage( + role="user", + contents=[TextContent(text='{"accepted": true}')], + ), + ] + + # The sanitize_tool_history function is internal to DefaultOrchestrator.run + # We'll test it indirectly by checking the orchestrator processes it correctly + orchestrator = DefaultOrchestrator() + + # Use pre-constructed ChatMessage objects to bypass message adapter + input_data = {"messages": []} + + agent = MockAgent() + context = ExecutionContext( + input_data=input_data, + agent=agent, + config=AgentConfig(), + ) + # Override the messages property to use our pre-constructed messages + context._messages = messages + + events = [] + async for event in orchestrator.run(context): + events.append(event) + + # Agent should receive synthetic tool result + assert len(agent.messages_received) > 0 + tool_messages = [ + msg + for msg in agent.messages_received + if (msg.role.value if hasattr(msg.role, "value") else str(msg.role)) == "tool" + ] + assert len(tool_messages) == 1 + assert str(tool_messages[0].contents[0].call_id) == "call_confirm_123" + assert tool_messages[0].contents[0].result == "Confirmed" + + +async def test_sanitize_tool_history_orphaned_tool_result() -> None: + """Test sanitize_tool_history removes orphaned tool results.""" + from agent_framework import ChatMessage, FunctionResultContent, TextContent + + # Tool result without preceding assistant tool call + messages = [ + ChatMessage( + role="tool", + contents=[FunctionResultContent(call_id="orphan_123", result="orphaned data")], + ), + ChatMessage( + role="user", + contents=[TextContent(text="Hello")], + ), + ] + + orchestrator = DefaultOrchestrator() + input_data = {"messages": []} + agent = MockAgent() + context = ExecutionContext( + input_data=input_data, + agent=agent, + config=AgentConfig(), + ) + context._messages = messages + + events = [] + async for event in orchestrator.run(context): + events.append(event) + + # Orphaned tool result should be filtered out + tool_messages = [ + msg + for msg in agent.messages_received + if (msg.role.value if hasattr(msg.role, "value") else str(msg.role)) == "tool" + ] + assert len(tool_messages) == 0 + + +async def test_orphaned_tool_result_sanitization() -> None: + """Test that orphaned tool results are filtered out.""" + orchestrator = DefaultOrchestrator() + + input_data = { + "messages": [ + { + "role": "tool", + "content": [{"type": "tool_result", "tool_call_id": "orphan_123", "content": "result"}], + }, + { + "role": "user", + "content": [{"type": "text", "text": "Hello"}], + }, + ], + } + + agent = MockAgent() + context = ExecutionContext( + input_data=input_data, + agent=agent, + config=AgentConfig(), + ) + + events = [] + async for event in orchestrator.run(context): + events.append(event) + + # Orphaned tool result should be filtered, only user message remains + tool_messages = [ + msg + for msg in agent.messages_received + if (msg.role.value if hasattr(msg.role, "value") else str(msg.role)) == "tool" + ] + assert len(tool_messages) == 0 + + +async def test_deduplicate_messages_empty_tool_results() -> None: + """Test deduplicate_messages prefers non-empty tool results.""" + from agent_framework import ChatMessage, FunctionCallContent, FunctionResultContent + + messages = [ + ChatMessage( + role="assistant", + contents=[FunctionCallContent(name="test_tool", call_id="call_789", arguments="{}")], + ), + ChatMessage( + role="tool", + contents=[FunctionResultContent(call_id="call_789", result="")], + ), + ChatMessage( + role="tool", + contents=[FunctionResultContent(call_id="call_789", result="real data")], + ), + ] + + orchestrator = DefaultOrchestrator() + input_data = {"messages": []} + agent = MockAgent() + context = ExecutionContext( + input_data=input_data, + agent=agent, + config=AgentConfig(), + ) + context._messages = messages + + events = [] + async for event in orchestrator.run(context): + events.append(event) + + # Should have only one tool result with actual data + tool_messages = [ + msg + for msg in agent.messages_received + if (msg.role.value if hasattr(msg.role, "value") else str(msg.role)) == "tool" + ] + assert len(tool_messages) == 1 + assert tool_messages[0].contents[0].result == "real data" + + +async def test_deduplicate_messages_duplicate_assistant_tool_calls() -> None: + """Test deduplicate_messages removes duplicate assistant tool call messages.""" + from agent_framework import ChatMessage, FunctionCallContent, FunctionResultContent + + messages = [ + ChatMessage( + role="assistant", + contents=[FunctionCallContent(name="test_tool", call_id="call_abc", arguments="{}")], + ), + ChatMessage( + role="assistant", + contents=[FunctionCallContent(name="test_tool", call_id="call_abc", arguments="{}")], + ), + ChatMessage( + role="tool", + contents=[FunctionResultContent(call_id="call_abc", result="result")], + ), + ] + + orchestrator = DefaultOrchestrator() + input_data = {"messages": []} + agent = MockAgent() + context = ExecutionContext( + input_data=input_data, + agent=agent, + config=AgentConfig(), + ) + context._messages = messages + + events = [] + async for event in orchestrator.run(context): + events.append(event) + + # Should have only one assistant message + assistant_messages = [ + msg + for msg in agent.messages_received + if (msg.role.value if hasattr(msg.role, "value") else str(msg.role)) == "assistant" + ] + assert len(assistant_messages) == 1 + + +async def test_deduplicate_messages_duplicate_system_messages() -> None: + """Test that deduplication logic is invoked for system messages.""" + from agent_framework import ChatMessage, TextContent + + messages = [ + ChatMessage( + role="system", + contents=[TextContent(text="You are a helpful assistant.")], + ), + ChatMessage( + role="system", + contents=[TextContent(text="You are a helpful assistant.")], + ), + ChatMessage( + role="user", + contents=[TextContent(text="Hello")], + ), + ] + + orchestrator = DefaultOrchestrator() + input_data = {"messages": []} + agent = MockAgent() + context = ExecutionContext( + input_data=input_data, + agent=agent, + config=AgentConfig(), + ) + context._messages = messages + + events = [] + async for event in orchestrator.run(context): + events.append(event) + + # Deduplication uses hash() which may not deduplicate identical content + # This test verifies deduplication logic runs without errors + system_messages = [ + msg + for msg in agent.messages_received + if (msg.role.value if hasattr(msg.role, "value") else str(msg.role)) == "system" + ] + # At least one system message should be present + assert len(system_messages) >= 1 + + +async def test_state_context_injection() -> None: + """Test state context message injection for first request.""" + orchestrator = DefaultOrchestrator() + + input_data = { + "messages": [ + { + "role": "user", + "content": [{"type": "text", "text": "Hello"}], + } + ], + "state": {"items": ["apple", "banana"]}, + } + + agent = MockAgent() + context = ExecutionContext( + input_data=input_data, + agent=agent, + config=AgentConfig(state_schema={"items": {"type": "array"}}), + ) + + events = [] + async for event in orchestrator.run(context): + events.append(event) + + # Should inject system message with current state + system_messages = [ + msg + for msg in agent.messages_received + if (msg.role.value if hasattr(msg.role, "value") else str(msg.role)) == "system" + ] + assert len(system_messages) == 1 + assert "apple" in system_messages[0].contents[0].text + assert "banana" in system_messages[0].contents[0].text + + +async def test_no_state_context_injection_with_tool_calls() -> None: + """Test state context is NOT injected if conversation has tool calls.""" + from agent_framework import ChatMessage, FunctionCallContent, FunctionResultContent, TextContent + + messages = [ + ChatMessage( + role="assistant", + contents=[FunctionCallContent(name="get_weather", call_id="call_xyz", arguments="{}")], + ), + ChatMessage( + role="tool", + contents=[FunctionResultContent(call_id="call_xyz", result="sunny")], + ), + ChatMessage( + role="user", + contents=[TextContent(text="Thanks")], + ), + ] + + orchestrator = DefaultOrchestrator() + input_data = {"messages": [], "state": {"weather": "sunny"}} + agent = MockAgent() + context = ExecutionContext( + input_data=input_data, + agent=agent, + config=AgentConfig(state_schema={"weather": {"type": "string"}}), + ) + context._messages = messages + + events = [] + async for event in orchestrator.run(context): + events.append(event) + + # Should NOT inject state context system message since conversation has tool calls + system_messages = [ + msg + for msg in agent.messages_received + if (msg.role.value if hasattr(msg.role, "value") else str(msg.role)) == "system" + ] + assert len(system_messages) == 0 + + +async def test_structured_output_processing() -> None: + """Test structured output extraction and state update.""" + + class RecipeState(BaseModel): + ingredients: list[str] + message: str + + orchestrator = DefaultOrchestrator() + + input_data = { + "messages": [ + { + "role": "user", + "content": [{"type": "text", "text": "Add tomato"}], + } + ], + } + + # Agent with structured output + agent = MockAgent( + updates=[ + AgentRunResponseUpdate( + contents=[TextContent(text='{"ingredients": ["tomato"], "message": "Added tomato"}')], + role="assistant", + ) + ] + ) + agent.chat_options.response_format = RecipeState + + context = ExecutionContext( + input_data=input_data, + agent=agent, + config=AgentConfig(state_schema={"ingredients": {"type": "array"}}), + ) + + events = [] + async for event in orchestrator.run(context): + events.append(event) + + # Should emit StateSnapshotEvent with ingredients + state_events = [e for e in events if e.type == "STATE_SNAPSHOT"] + assert len(state_events) >= 1 + + # Should emit TextMessage with message field + text_content_events = [e for e in events if e.type == "TEXT_MESSAGE_CONTENT"] + assert len(text_content_events) >= 1 + assert any("Added tomato" in e.delta for e in text_content_events) + + +async def test_duplicate_client_tools_filtered() -> None: + """Test that client tools duplicating server tools are filtered out.""" + + @ai_function + def get_weather(location: str) -> str: + """Get weather for location.""" + return f"Weather in {location}" + + orchestrator = DefaultOrchestrator() + + input_data = { + "messages": [ + { + "role": "user", + "content": [{"type": "text", "text": "Hello"}], + } + ], + "tools": [ + { + "name": "get_weather", + "description": "Client weather tool.", + "parameters": { + "type": "object", + "properties": {"location": {"type": "string"}}, + "required": ["location"], + }, + } + ], + } + + agent = MockAgent() + agent.chat_options.tools = [get_weather] + + context = ExecutionContext( + input_data=input_data, + agent=agent, + config=AgentConfig(), + ) + + events = [] + async for event in orchestrator.run(context): + events.append(event) + + # tools parameter should not be passed since client tool duplicates server tool + assert agent.tools_received is None + + +async def test_unique_client_tools_merged() -> None: + """Test that unique client tools are merged with server tools.""" + + @ai_function + def server_tool() -> str: + """Server tool.""" + return "server" + + orchestrator = DefaultOrchestrator() + + input_data = { + "messages": [ + { + "role": "user", + "content": [{"type": "text", "text": "Hello"}], + } + ], + "tools": [ + { + "name": "client_tool", + "description": "Unique client tool.", + "parameters": { + "type": "object", + "properties": {"param": {"type": "string"}}, + "required": ["param"], + }, + } + ], + } + + agent = MockAgent() + agent.chat_options.tools = [server_tool] + + context = ExecutionContext( + input_data=input_data, + agent=agent, + config=AgentConfig(), + ) + + events = [] + async for event in orchestrator.run(context): + events.append(event) + + # tools parameter should be passed with both server and client tools + assert agent.tools_received is not None + tool_names = [getattr(tool, "name", None) for tool in agent.tools_received] + assert "server_tool" in tool_names + assert "client_tool" in tool_names + + +async def test_empty_messages_handling() -> None: + """Test orchestrator handles empty message list gracefully.""" + orchestrator = DefaultOrchestrator() + + input_data = {"messages": []} + + agent = MockAgent() + context = ExecutionContext( + input_data=input_data, + agent=agent, + config=AgentConfig(), + ) + + events = [] + async for event in orchestrator.run(context): + events.append(event) + + # Should emit run lifecycle events but not call agent + assert len(agent.messages_received) == 0 + run_started = [e for e in events if e.type == "RUN_STARTED"] + run_finished = [e for e in events if e.type == "RUN_FINISHED"] + assert len(run_started) == 1 + assert len(run_finished) == 1 + + +async def test_all_messages_filtered_handling() -> None: + """Test orchestrator handles case where all messages are filtered out.""" + orchestrator = DefaultOrchestrator() + + input_data = { + "messages": [ + { + "role": "tool", + "content": [{"type": "tool_result", "tool_call_id": "orphan", "content": "data"}], + } + ] + } + + agent = MockAgent() + context = ExecutionContext( + input_data=input_data, + agent=agent, + config=AgentConfig(), + ) + + events = [] + async for event in orchestrator.run(context): + events.append(event) + + # Should finish without calling agent + assert len(agent.messages_received) == 0 + run_finished = [e for e in events if e.type == "RUN_FINISHED"] + assert len(run_finished) == 1 + + +async def test_confirm_changes_with_invalid_json_fallback() -> None: + """Test confirm_changes with invalid JSON falls back to normal processing.""" + from agent_framework import ChatMessage, FunctionCallContent, TextContent + + messages = [ + ChatMessage( + role="assistant", + contents=[ + FunctionCallContent( + name="confirm_changes", + call_id="call_confirm_invalid", + arguments='{"changes": "test"}', + ) + ], + ), + ChatMessage( + role="user", + contents=[TextContent(text="invalid json {")], + ), + ] + + orchestrator = DefaultOrchestrator() + input_data = {"messages": []} + agent = MockAgent() + context = ExecutionContext( + input_data=input_data, + agent=agent, + config=AgentConfig(), + ) + context._messages = messages + + events = [] + async for event in orchestrator.run(context): + events.append(event) + + # Invalid JSON should fall back - user message should be included + user_messages = [ + msg + for msg in agent.messages_received + if (msg.role.value if hasattr(msg.role, "value") else str(msg.role)) == "user" + ] + assert len(user_messages) == 1 + + +async def test_tool_result_kept_when_call_id_matches() -> None: + """Test tool result is kept when call_id matches pending tool calls.""" + from agent_framework import ChatMessage, FunctionCallContent, FunctionResultContent + + messages = [ + ChatMessage( + role="assistant", + contents=[FunctionCallContent(name="get_data", call_id="call_match", arguments="{}")], + ), + ChatMessage( + role="tool", + contents=[FunctionResultContent(call_id="call_match", result="data")], + ), + ] + + orchestrator = DefaultOrchestrator() + input_data = {"messages": []} + agent = MockAgent() + context = ExecutionContext( + input_data=input_data, + agent=agent, + config=AgentConfig(), + ) + context._messages = messages + + events = [] + async for event in orchestrator.run(context): + events.append(event) + + # Tool result should be kept + tool_messages = [ + msg + for msg in agent.messages_received + if (msg.role.value if hasattr(msg.role, "value") else str(msg.role)) == "tool" + ] + assert len(tool_messages) == 1 + assert tool_messages[0].contents[0].result == "data" + + +async def test_agent_protocol_fallback_paths() -> None: + """Test fallback paths for non-ChatAgent implementations.""" + + class CustomAgent: + """Custom agent without ChatAgent type.""" + + def __init__(self) -> None: + self.chat_options = SimpleNamespace(tools=[], response_format=None) + self.chat_client = SimpleNamespace(function_invocation_configuration=SimpleNamespace()) + self.messages_received: list[Any] = [] + + async def run_stream( + self, + messages: list[Any], + *, + thread: Any = None, + tools: list[Any] | None = None, + ) -> AsyncGenerator[AgentRunResponseUpdate, None]: + self.messages_received = messages + yield AgentRunResponseUpdate(contents=[TextContent(text="response")], role="assistant") + + from agent_framework import ChatMessage, TextContent + + messages = [ChatMessage(role="user", contents=[TextContent(text="Hello")])] + + orchestrator = DefaultOrchestrator() + input_data = {"messages": []} + agent = CustomAgent() + context = ExecutionContext( + input_data=input_data, + agent=agent, # type: ignore + config=AgentConfig(), + ) + context._messages = messages + + events = [] + async for event in orchestrator.run(context): + events.append(event) + + # Should work with custom agent implementation + assert len(agent.messages_received) > 0 + + +async def test_initial_state_snapshot_with_array_schema() -> None: + """Test state initialization with array type schema.""" + from agent_framework import ChatMessage, TextContent + + messages = [ChatMessage(role="user", contents=[TextContent(text="Hello")])] + + orchestrator = DefaultOrchestrator() + input_data = {"messages": [], "state": {}} + agent = MockAgent() + context = ExecutionContext( + input_data=input_data, + agent=agent, + config=AgentConfig(state_schema={"items": {"type": "array"}}), + ) + context._messages = messages + + events = [] + async for event in orchestrator.run(context): + events.append(event) + + # Should emit state snapshot with empty array for items + state_events = [e for e in events if e.type == "STATE_SNAPSHOT"] + assert len(state_events) >= 1 + + +async def test_response_format_skip_text_content() -> None: + """Test that response_format causes skip_text_content to be set.""" + + class OutputModel(BaseModel): + result: str + + from agent_framework import ChatMessage, TextContent + + messages = [ChatMessage(role="user", contents=[TextContent(text="Hello")])] + + orchestrator = DefaultOrchestrator() + input_data = {"messages": []} + + agent = MockAgent() + agent.chat_options.response_format = OutputModel + + context = ExecutionContext( + input_data=input_data, + agent=agent, + config=AgentConfig(), + ) + context._messages = messages + + events = [] + async for event in orchestrator.run(context): + events.append(event) + + # Test passes if no errors occur - verifies response_format code path + assert len(events) > 0 diff --git a/python/packages/anthropic/pyproject.toml b/python/packages/anthropic/pyproject.toml index cacd760e76..d7d8f9bd35 100644 --- a/python/packages/anthropic/pyproject.toml +++ b/python/packages/anthropic/pyproject.toml @@ -4,7 +4,7 @@ description = "Anthropic integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b251111" +version = "1.0.0b251112.post1" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" diff --git a/python/packages/anthropic/tests/test_anthropic_client.py b/python/packages/anthropic/tests/test_anthropic_client.py index 677cc1e166..11498bfe59 100644 --- a/python/packages/anthropic/tests/test_anthropic_client.py +++ b/python/packages/anthropic/tests/test_anthropic_client.py @@ -92,7 +92,7 @@ def test_anthropic_settings_init_with_explicit_values() -> None: @pytest.mark.parametrize("exclude_list", [["ANTHROPIC_API_KEY"]], indirect=True) def test_anthropic_settings_missing_api_key(anthropic_unit_test_env: dict[str, str]) -> None: """Test AnthropicSettings when API key is missing.""" - settings = AnthropicSettings() + settings = AnthropicSettings(env_file_path="test.env") assert settings.api_key is None assert settings.chat_model_id == anthropic_unit_test_env["ANTHROPIC_CHAT_MODEL_ID"] diff --git a/python/packages/azure-ai/agent_framework_azure_ai/__init__.py b/python/packages/azure-ai/agent_framework_azure_ai/__init__.py index 6e6ac7a5e5..cf2423693d 100644 --- a/python/packages/azure-ai/agent_framework_azure_ai/__init__.py +++ b/python/packages/azure-ai/agent_framework_azure_ai/__init__.py @@ -3,6 +3,7 @@ import importlib.metadata from ._chat_client import AzureAIAgentClient +from ._client import AzureAIClient from ._shared import AzureAISettings try: @@ -12,6 +13,7 @@ except importlib.metadata.PackageNotFoundError: __all__ = [ "AzureAIAgentClient", + "AzureAIClient", "AzureAISettings", "__version__", ] diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_chat_client.py b/python/packages/azure-ai/agent_framework_azure_ai/_chat_client.py index f16560e517..d85dc95111 100644 --- a/python/packages/azure-ai/agent_framework_azure_ai/_chat_client.py +++ b/python/packages/azure-ai/agent_framework_azure_ai/_chat_client.py @@ -1,7 +1,9 @@ # Copyright (c) Microsoft. All rights reserved. +import ast import json import os +import re import sys from collections.abc import AsyncIterable, MutableMapping, MutableSequence, Sequence from typing import Any, ClassVar, TypeVar @@ -427,7 +429,9 @@ class AzureAIAgentClient(BaseChatClient): # and remove until here. return thread_id - def _extract_url_citations(self, message_delta_chunk: MessageDeltaChunk) -> list[CitationAnnotation]: + def _extract_url_citations( + self, message_delta_chunk: MessageDeltaChunk, azure_search_tool_calls: list[dict[str, Any]] + ) -> list[CitationAnnotation]: """Extract URL citations from MessageDeltaChunk.""" url_citations: list[CitationAnnotation] = [] @@ -446,10 +450,15 @@ class AzureAIAgentClient(BaseChatClient): ) ] - # Create CitationAnnotation from AzureAI annotation + # Extract real URL from Azure AI Search tool calls + real_url = self._get_real_url_from_citation_reference( + annotation.url_citation.url, azure_search_tool_calls + ) + + # Create CitationAnnotation with real URL citation = CitationAnnotation( title=getattr(annotation.url_citation, "title", None), - url=annotation.url_citation.url, + url=real_url, snippet=None, annotated_regions=annotated_regions, raw_representation=annotation, @@ -458,11 +467,54 @@ class AzureAIAgentClient(BaseChatClient): return url_citations + def _get_real_url_from_citation_reference( + self, citation_url: str, azure_search_tool_calls: list[dict[str, Any]] + ) -> str: + """Extract real URL from Azure AI Search tool calls based on citation reference. + + Args: + citation_url: Citation reference URL (e.g., "doc_0", "#doc_1", or full URL with doc_N) + azure_search_tool_calls: List of captured Azure AI Search tool calls + + Returns: + Real document URL if found, otherwise original citation_url + """ + # Extract document index from citation URL (e.g., "doc_0" -> 0) + match = re.search(r"doc_(\d+)", citation_url) + if not match: + return citation_url + + doc_index = int(match.group(1)) + + # Get Azure AI Search tool calls + if not azure_search_tool_calls: + return citation_url + + try: + # Extract URLs from the most recent Azure AI Search tool call + tool_call = azure_search_tool_calls[-1] # Most recent call + output_str = tool_call["azure_ai_search"]["output"] + + # Parse the tool call output to get URLs + output_data = ast.literal_eval(output_str) + all_urls = output_data["metadata"]["get_urls"] + + # Return the URL at the specified index, if it exists + if 0 <= doc_index < len(all_urls): + return str(all_urls[doc_index]) + + except (KeyError, IndexError, TypeError, ValueError, SyntaxError) as ex: + logger.debug(f"Failed to extract real URL for {citation_url}: {ex}") + + return citation_url + async def _process_stream( self, stream: AsyncAgentRunStream[AsyncAgentEventHandler[Any]] | AsyncAgentEventHandler[Any], thread_id: str ) -> AsyncIterable[ChatResponseUpdate]: """Process events from the stream iterator and yield ChatResponseUpdate objects.""" response_id: str | None = None + # Track Azure Search tool calls for this stream only + azure_search_tool_calls: list[dict[str, Any]] = [] response_stream = await stream.__aenter__() if isinstance(stream, AsyncAgentRunStream) else stream # type: ignore[no-untyped-call] try: async for event_type, event_data, _ in response_stream: # type: ignore @@ -472,7 +524,7 @@ class AzureAIAgentClient(BaseChatClient): role = Role.USER if event_data.delta.role == MessageRole.USER else Role.ASSISTANT # Extract URL citations from the delta chunk - url_citations = self._extract_url_citations(event_data) + url_citations = self._extract_url_citations(event_data, azure_search_tool_calls) # Create contents with citations if any exist citation_content: list[Contents] = [] @@ -545,6 +597,10 @@ class AzureAIAgentClient(BaseChatClient): case AgentStreamEvent.THREAD_RUN_STEP_CREATED: response_id = event_data.run_id case AgentStreamEvent.THREAD_RUN_COMPLETED | AgentStreamEvent.THREAD_RUN_STEP_COMPLETED: + # Capture Azure AI Search tool calls when steps complete + if event_type == AgentStreamEvent.THREAD_RUN_STEP_COMPLETED: + self._capture_azure_search_tool_calls(event_data, azure_search_tool_calls) + if event_data.usage: usage_content = UsageContent( UsageDetails( @@ -623,6 +679,29 @@ class AzureAIAgentClient(BaseChatClient): if isinstance(stream, AsyncAgentRunStream): await stream.__aexit__(None, None, None) # type: ignore[no-untyped-call] + def _capture_azure_search_tool_calls( + self, step_data: RunStep, azure_search_tool_calls: list[dict[str, Any]] + ) -> None: + """Capture Azure AI Search tool call data from completed steps.""" + try: + if ( + hasattr(step_data, "step_details") + and hasattr(step_data.step_details, "tool_calls") + and step_data.step_details.tool_calls + ): + for tool_call in step_data.step_details.tool_calls: + if hasattr(tool_call, "type") and tool_call.type == "azure_ai_search": + # Store the complete tool call as a dictionary + tool_call_dict = { + "id": getattr(tool_call, "id", None), + "type": tool_call.type, + "azure_ai_search": getattr(tool_call, "azure_ai_search", None), + } + azure_search_tool_calls.append(tool_call_dict) + logger.debug(f"Captured Azure AI Search tool call: {tool_call_dict['id']}") + except Exception as ex: + logger.debug(f"Failed to capture Azure AI Search tool call: {ex}") + def _create_function_call_contents(self, event_data: ThreadRun, response_id: str | None) -> list[Contents]: """Create function call contents from a tool action event.""" if isinstance(event_data, ThreadRun) and event_data.required_action is not None: diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_client.py b/python/packages/azure-ai/agent_framework_azure_ai/_client.py new file mode 100644 index 0000000000..2fa54ea565 --- /dev/null +++ b/python/packages/azure-ai/agent_framework_azure_ai/_client.py @@ -0,0 +1,358 @@ +# Copyright (c) Microsoft. All rights reserved. + +import sys +from collections.abc import MutableSequence +from typing import Any, ClassVar, TypeVar + +from agent_framework import ( + AGENT_FRAMEWORK_USER_AGENT, + ChatMessage, + ChatOptions, + HostedMCPTool, + TextContent, + get_logger, + use_chat_middleware, + use_function_invocation, +) +from agent_framework.exceptions import ServiceInitializationError +from agent_framework.observability import use_observability +from agent_framework.openai._responses_client import OpenAIBaseResponsesClient +from azure.ai.projects.aio import AIProjectClient +from azure.ai.projects.models import ( + MCPTool, + PromptAgentDefinition, + PromptAgentDefinitionText, + ResponseTextFormatConfigurationJsonSchema, +) +from azure.core.credentials_async import AsyncTokenCredential +from azure.core.exceptions import ResourceNotFoundError +from openai.types.responses.parsed_response import ( + ParsedResponse, +) +from openai.types.responses.response import Response as OpenAIResponse +from pydantic import BaseModel, ValidationError + +from ._shared import AzureAISettings + +if sys.version_info >= (3, 11): + from typing import Self # pragma: no cover +else: + from typing_extensions import Self # pragma: no cover + + +logger = get_logger("agent_framework.azure") + + +TAzureAIClient = TypeVar("TAzureAIClient", bound="AzureAIClient") + + +@use_function_invocation +@use_observability +@use_chat_middleware +class AzureAIClient(OpenAIBaseResponsesClient): + """Azure AI Agent client.""" + + OTEL_PROVIDER_NAME: ClassVar[str] = "azure.ai" # type: ignore[reportIncompatibleVariableOverride, misc] + + def __init__( + self, + *, + project_client: AIProjectClient | None = None, + agent_name: str | None = None, + agent_version: str | None = None, + conversation_id: str | None = None, + project_endpoint: str | None = None, + model_deployment_name: str | None = None, + async_credential: AsyncTokenCredential | None = None, + use_latest_version: bool | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + **kwargs: Any, + ) -> None: + """Initialize an Azure AI Agent client. + + Keyword Args: + project_client: An existing AIProjectClient to use. If not provided, one will be created. + agent_name: The name to use when creating new agents or using existing agents. + agent_version: The version of the agent to use. + conversation_id: Default conversation ID to use for conversations. Can be overridden by + conversation_id property when making a request. + project_endpoint: The Azure AI Project endpoint URL. + Can also be set via environment variable AZURE_AI_PROJECT_ENDPOINT. + Ignored when a project_client is passed. + model_deployment_name: The model deployment name to use for agent creation. + Can also be set via environment variable AZURE_AI_MODEL_DEPLOYMENT_NAME. + async_credential: Azure async credential to use for authentication. + use_latest_version: Boolean flag that indicates whether to use latest agent version + if it exists in the service. + env_file_path: Path to environment file for loading settings. + env_file_encoding: Encoding of the environment file. + kwargs: Additional keyword arguments passed to the parent class. + + Examples: + .. code-block:: python + + from agent_framework.azure import AzureAIClient + from azure.identity.aio import DefaultAzureCredential + + # Using environment variables + # Set AZURE_AI_PROJECT_ENDPOINT=https://your-project.cognitiveservices.azure.com + # Set AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4 + credential = DefaultAzureCredential() + client = AzureAIClient(async_credential=credential) + + # Or passing parameters directly + client = AzureAIClient( + project_endpoint="https://your-project.cognitiveservices.azure.com", + model_deployment_name="gpt-4", + async_credential=credential, + ) + + # Or loading from a .env file + client = AzureAIClient(async_credential=credential, env_file_path="path/to/.env") + """ + try: + azure_ai_settings = AzureAISettings( + project_endpoint=project_endpoint, + model_deployment_name=model_deployment_name, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) + except ValidationError as ex: + raise ServiceInitializationError("Failed to create Azure AI settings.", ex) from ex + + # If no project_client is provided, create one + should_close_client = False + if project_client is None: + if not azure_ai_settings.project_endpoint: + raise ServiceInitializationError( + "Azure AI project endpoint is required. Set via 'project_endpoint' parameter " + "or 'AZURE_AI_PROJECT_ENDPOINT' environment variable." + ) + + # Use provided credential + if not async_credential: + raise ServiceInitializationError("Azure credential is required when project_client is not provided.") + project_client = AIProjectClient( + endpoint=azure_ai_settings.project_endpoint, + credential=async_credential, + user_agent=AGENT_FRAMEWORK_USER_AGENT, + ) + should_close_client = True + + # Initialize parent + super().__init__( + **kwargs, + ) + + # Initialize instance variables + self.agent_name = agent_name + self.agent_version = agent_version + self.use_latest_version = use_latest_version + self.project_client = project_client + self.credential = async_credential + self.model_id = azure_ai_settings.model_deployment_name + self.conversation_id = conversation_id + self._should_close_client = should_close_client # Track whether we should close client connection + + async def setup_azure_ai_observability(self, enable_sensitive_data: bool | None = None) -> None: + """Use this method to setup tracing in your Azure AI Project. + + This will take the connection string from the project project_client. + It will override any connection string that is set in the environment variables. + It will disable any OTLP endpoint that might have been set. + """ + try: + conn_string = await self.project_client.telemetry.get_application_insights_connection_string() + except ResourceNotFoundError: + logger.warning( + "No Application Insights connection string found for the Azure AI Project, " + "please call setup_observability() manually." + ) + return + from agent_framework.observability import setup_observability + + setup_observability( + applicationinsights_connection_string=conn_string, enable_sensitive_data=enable_sensitive_data + ) + + async def __aenter__(self) -> "Self": + """Async context manager entry.""" + return self + + async def __aexit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: Any) -> None: + """Async context manager exit.""" + await self.close() + + async def close(self) -> None: + """Close the project_client.""" + await self._close_client_if_needed() + + async def _get_agent_reference_or_create( + self, run_options: dict[str, Any], messages_instructions: str | None + ) -> dict[str, str]: + """Determine which agent to use and create if needed. + + Returns: + dict[str, str]: The agent reference to use. + """ + # Agent name must be explicitly provided by the user. + if self.agent_name is None: + raise ServiceInitializationError( + "Agent name is required. Provide 'agent_name' when initializing AzureAIClient " + "or 'name' when initializing ChatAgent." + ) + + # If no agent_version is provided, either use latest version or create a new agent: + if self.agent_version is None: + # Try to use latest version if requested and agent exists + if self.use_latest_version: + try: + existing_agent = await self.project_client.agents.get(self.agent_name) + self.agent_version = existing_agent.versions.latest.version + return {"name": self.agent_name, "version": self.agent_version, "type": "agent_reference"} + except ResourceNotFoundError: + # Agent doesn't exist, fall through to creation logic + pass + + if "model" not in run_options or not run_options["model"]: + raise ServiceInitializationError( + "Model deployment name is required for agent creation, " + "can also be passed to the get_response methods." + ) + + args: dict[str, Any] = {"model": run_options["model"]} + + if "tools" in run_options: + args["tools"] = run_options["tools"] + + if "response_format" in run_options: + response_format = run_options["response_format"] + args["text"] = PromptAgentDefinitionText( + format=ResponseTextFormatConfigurationJsonSchema( + name=response_format.__name__, + schema=response_format.model_json_schema(), + ) + ) + + # Combine instructions from messages and options + combined_instructions = [ + instructions + for instructions in [messages_instructions, run_options.get("instructions")] + if instructions + ] + if combined_instructions: + args["instructions"] = "".join(combined_instructions) + + created_agent = await self.project_client.agents.create_version( + agent_name=self.agent_name, definition=PromptAgentDefinition(**args) + ) + + self.agent_version = created_agent.version + + return {"name": self.agent_name, "version": self.agent_version, "type": "agent_reference"} + + async def _close_client_if_needed(self) -> None: + """Close project_client session if we created it.""" + if self._should_close_client: + await self.project_client.close() + + def _prepare_input(self, messages: MutableSequence[ChatMessage]) -> tuple[list[ChatMessage], str | None]: + """Prepare input from messages and convert system/developer messages to instructions.""" + result: list[ChatMessage] = [] + instructions_list: list[str] = [] + instructions: str | None = None + + # System/developer messages are turned into instructions, since there is no such message roles in Azure AI. + for message in messages: + if message.role.value in ["system", "developer"]: + for text_content in [content for content in message.contents if isinstance(content, TextContent)]: + instructions_list.append(text_content.text) + else: + result.append(message) + + if len(instructions_list) > 0: + instructions = "".join(instructions_list) + + return result, instructions + + async def prepare_options( + self, messages: MutableSequence[ChatMessage], chat_options: ChatOptions + ) -> dict[str, Any]: + """Take ChatOptions and create the specific options for Azure AI.""" + chat_options.store = bool(chat_options.store or chat_options.store is None) + prepared_messages, instructions = self._prepare_input(messages) + run_options = await super().prepare_options(prepared_messages, chat_options) + agent_reference = await self._get_agent_reference_or_create(run_options, instructions) + + run_options["extra_body"] = {"agent": agent_reference} + + conversation_id = chat_options.conversation_id or self.conversation_id + + # Handle different conversation ID formats + if conversation_id: + if conversation_id.startswith("resp_"): + # For response IDs, set previous_response_id and remove conversation property + run_options.pop("conversation", None) + run_options["previous_response_id"] = conversation_id + elif conversation_id.startswith("conv_"): + # For conversation IDs, set conversation and remove previous_response_id property + run_options.pop("previous_response_id", None) + run_options["conversation"] = conversation_id + + # Remove properties that are not supported on request level + # but were configured on agent level + exclude = ["model", "tools", "response_format"] + + for property in exclude: + run_options.pop(property, None) + + return run_options + + async def initialize_client(self) -> None: + """Initialize OpenAI client asynchronously.""" + self.client = await self.project_client.get_openai_client() # type: ignore + + def _update_agent_name(self, agent_name: str | None) -> None: + """Update the agent name in the chat client. + + Args: + agent_name: The new name for the agent. + """ + # This is a no-op in the base class, but can be overridden by subclasses + # to update the agent name in the client. + if agent_name and not self.agent_name: + self.agent_name = agent_name + + def get_mcp_tool(self, tool: HostedMCPTool) -> Any: + """Get MCP tool from HostedMCPTool.""" + mcp = MCPTool(server_label=tool.name.replace(" ", "_"), server_url=str(tool.url)) + + if tool.allowed_tools: + mcp["allowed_tools"] = list(tool.allowed_tools) + + if tool.approval_mode: + match tool.approval_mode: + case str(): + mcp["require_approval"] = "always" if tool.approval_mode == "always_require" else "never" + case _: + if always_require_approvals := tool.approval_mode.get("always_require_approval"): + mcp["require_approval"] = {"always": {"tool_names": list(always_require_approvals)}} + if never_require_approvals := tool.approval_mode.get("never_require_approval"): + mcp["require_approval"] = {"never": {"tool_names": list(never_require_approvals)}} + + return mcp + + def get_conversation_id( + self, response: OpenAIResponse | ParsedResponse[BaseModel], store: bool | None + ) -> str | None: + """Get the conversation ID from the response if store is True.""" + if store: + # If conversation ID exists, it means that we operate with conversation + # so we use conversation ID as input and output. + if response.conversation and response.conversation.id: + return response.conversation.id + # If conversation ID doesn't exist, we operate with responses + # so we use response ID as input and output. + return response.id + return None diff --git a/python/packages/azure-ai/pyproject.toml b/python/packages/azure-ai/pyproject.toml index fa15e4c074..b2b8db8f8b 100644 --- a/python/packages/azure-ai/pyproject.toml +++ b/python/packages/azure-ai/pyproject.toml @@ -4,7 +4,7 @@ description = "Azure AI Foundry integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b251111" +version = "1.0.0b251112.post1" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -24,7 +24,7 @@ classifiers = [ ] dependencies = [ "agent-framework-core", - "azure-ai-projects >= 1.0.0b11", + "azure-ai-projects >= 2.0.0b1", "azure-ai-agents == 1.2.0b5", "aiohttp", ] diff --git a/python/packages/azure-ai/tests/test_azure_ai_agent_client.py b/python/packages/azure-ai/tests/test_azure_ai_agent_client.py index 555d27d560..d839eca376 100644 --- a/python/packages/azure-ai/tests/test_azure_ai_agent_client.py +++ b/python/packages/azure-ai/tests/test_azure_ai_agent_client.py @@ -3,7 +3,7 @@ import json import os from pathlib import Path -from typing import Annotated +from typing import Annotated, Any from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -92,6 +92,7 @@ def create_test_azure_ai_chat_client( client._agent_created = False client._should_close_client = False client._agent_definition = None + client._azure_search_tool_calls = [] # Add the new instance variable client.additional_properties = {} client.middleware = None @@ -1335,8 +1336,8 @@ def test_azure_ai_chat_client_extract_url_citations_with_citations(mock_agents_c mock_chunk = MagicMock(spec=MessageDeltaChunk) mock_chunk.delta = mock_delta - # Call the method - citations = chat_client._extract_url_citations(mock_chunk) # type: ignore + # Call the method with empty azure_search_tool_calls + citations = chat_client._extract_url_citations(mock_chunk, []) # type: ignore # Verify results assert len(citations) == 1 @@ -1804,3 +1805,166 @@ async def test_azure_ai_chat_client_no_cleanup_when_agent_not_created_by_client( # Verify agent was NOT deleted mock_agents_client.delete_agent.assert_not_called() assert chat_client.agent_id == "existing-agent-id" + + +def test_azure_ai_chat_client_capture_azure_search_tool_calls(mock_agents_client: MagicMock) -> None: + """Test _capture_azure_search_tool_calls method.""" + chat_client = create_test_azure_ai_chat_client(mock_agents_client) + + # Mock Azure AI Search tool call + mock_tool_call = MagicMock() + mock_tool_call.type = "azure_ai_search" + mock_tool_call.id = "call_123" + mock_tool_call.azure_ai_search = {"input": "test query", "output": "test output"} + + # Mock step data + mock_step_data = MagicMock() + mock_step_data.step_details.tool_calls = [mock_tool_call] + + # Call the method with a list to capture tool calls + azure_search_tool_calls: list[dict[str, Any]] = [] + chat_client._capture_azure_search_tool_calls(mock_step_data, azure_search_tool_calls) # type: ignore + + # Verify tool call was captured + assert len(azure_search_tool_calls) == 1 + captured_tool_call = azure_search_tool_calls[0] + assert captured_tool_call["type"] == "azure_ai_search" + assert captured_tool_call["id"] == "call_123" + assert captured_tool_call["azure_ai_search"] == {"input": "test query", "output": "test output"} + + +def test_azure_ai_chat_client_get_real_url_from_citation_reference_no_tool_calls( + mock_agents_client: MagicMock, +) -> None: + """Test _get_real_url_from_citation_reference with no tool calls.""" + chat_client = create_test_azure_ai_chat_client(mock_agents_client) + + # No tool calls - pass empty list + result = chat_client._get_real_url_from_citation_reference("doc_1", []) # type: ignore + assert result == "doc_1" + + +def test_azure_ai_chat_client_get_real_url_from_citation_reference_invalid_output( + mock_agents_client: MagicMock, +) -> None: + """Test _get_real_url_from_citation_reference with invalid output format.""" + chat_client = create_test_azure_ai_chat_client(mock_agents_client) + + # Tool call with invalid output format + azure_search_tool_calls = [ + {"id": "call_123", "type": "azure_ai_search", "azure_ai_search": {"output": "invalid_json_format"}} + ] + + result = chat_client._get_real_url_from_citation_reference("doc_1", azure_search_tool_calls) # type: ignore + assert result == "doc_1" + + +async def test_azure_ai_chat_client_context_manager(mock_agents_client: MagicMock) -> None: + """Test AzureAIAgentClient as async context manager.""" + chat_client = create_test_azure_ai_chat_client(mock_agents_client) + + # Mock close method to avoid actual cleanup + chat_client.close = AsyncMock() + + async with chat_client as client: + assert client is chat_client + + # Verify close was called on exit + chat_client.close.assert_called_once() + + +async def test_azure_ai_chat_client_close_method(mock_agents_client: MagicMock) -> None: + """Test AzureAIAgentClient close method.""" + chat_client = create_test_azure_ai_chat_client(mock_agents_client) + + # Mock cleanup methods + chat_client._cleanup_agent_if_needed = AsyncMock() + chat_client._close_client_if_needed = AsyncMock() + + await chat_client.close() + + # Verify cleanup methods were called + chat_client._cleanup_agent_if_needed.assert_called_once() + chat_client._close_client_if_needed.assert_called_once() + + +def test_azure_ai_chat_client_extract_url_citations_with_azure_search_enhanced_url( + mock_agents_client: MagicMock, +) -> None: + """Test _extract_url_citations with Azure AI Search URL enhancement.""" + chat_client = create_test_azure_ai_chat_client(mock_agents_client) + + # Add Azure Search tool calls for URL enhancement + azure_search_tool_calls = [ + { + "id": "call_123", + "type": "azure_ai_search", + "azure_ai_search": { + "output": str({ + "metadata": {"get_urls": ["https://real-example.com/doc1", "https://real-example.com/doc2"]} + }) + }, + } + ] + + # Create mock URL citation with doc reference + mock_url_citation = MagicMock() + mock_url_citation.url = "doc_1" + mock_url_citation.title = "Test Title" + + mock_annotation = MagicMock(spec=MessageDeltaTextUrlCitationAnnotation) + mock_annotation.url_citation = mock_url_citation + mock_annotation.start_index = 10 + mock_annotation.end_index = 20 + + mock_text = MagicMock() + mock_text.annotations = [mock_annotation] + + mock_text_content = MagicMock(spec=MessageDeltaTextContent) + mock_text_content.text = mock_text + + mock_delta = MagicMock() + mock_delta.content = [mock_text_content] + + mock_chunk = MagicMock(spec=MessageDeltaChunk) + mock_chunk.delta = mock_delta + + citations = chat_client._extract_url_citations(mock_chunk, azure_search_tool_calls) # type: ignore + + # Verify real URL was used + assert len(citations) == 1 + citation = citations[0] + assert citation.url == "https://real-example.com/doc2" # doc_1 maps to index 1 + + +def test_azure_ai_chat_client_init_with_auto_created_agents_client( + azure_ai_unit_test_env: dict[str, str], mock_azure_credential: MagicMock +) -> None: + """Test AzureAIAgentClient initialization when it creates its own AgentsClient.""" + + # Mock the AgentsClient constructor + with patch("agent_framework_azure_ai._chat_client.AgentsClient") as mock_agents_client_class: + mock_agents_client_instance = MagicMock() + mock_agents_client_class.return_value = mock_agents_client_instance + + # Create client without providing agents_client - should create its own + client = AzureAIAgentClient( + agents_client=None, # This will trigger creation of AgentsClient + agent_id="test-agent", + project_endpoint=azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"], + model_deployment_name=azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + async_credential=mock_azure_credential, + ) + + # Verify AgentsClient was created with correct parameters + mock_agents_client_class.assert_called_once_with( + endpoint=azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"], + credential=mock_azure_credential, + user_agent="agent-framework-python/0.0.0", + ) + + # Verify client properties are set correctly + assert client.agents_client is mock_agents_client_instance + assert client.agent_id == "test-agent" + assert client.credential is mock_azure_credential + assert client._should_close_client is True # Should close since we created it # type: ignore[attr-defined] diff --git a/python/packages/azure-ai/tests/test_azure_ai_client.py b/python/packages/azure-ai/tests/test_azure_ai_client.py new file mode 100644 index 0000000000..ead970785d --- /dev/null +++ b/python/packages/azure-ai/tests/test_azure_ai_client.py @@ -0,0 +1,753 @@ +# Copyright (c) Microsoft. All rights reserved. + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from agent_framework import ( + ChatClientProtocol, + ChatMessage, + ChatOptions, + Role, + TextContent, +) +from agent_framework.exceptions import ServiceInitializationError +from azure.ai.projects.models import ( + ResponseTextFormatConfigurationJsonSchema, +) +from openai.types.responses.parsed_response import ParsedResponse +from openai.types.responses.response import Response as OpenAIResponse +from pydantic import BaseModel, ConfigDict, ValidationError + +from agent_framework_azure_ai import AzureAIClient, AzureAISettings + + +def create_test_azure_ai_client( + mock_project_client: MagicMock, + agent_name: str | None = None, + agent_version: str | None = None, + conversation_id: str | None = None, + azure_ai_settings: AzureAISettings | None = None, + should_close_client: bool = False, + use_latest_version: bool | None = None, +) -> AzureAIClient: + """Helper function to create AzureAIClient instances for testing, bypassing normal validation.""" + if azure_ai_settings is None: + azure_ai_settings = AzureAISettings(env_file_path="test.env") + + # Create client instance directly + client = object.__new__(AzureAIClient) + + # Set attributes directly + client.project_client = mock_project_client + client.credential = None + client.agent_name = agent_name + client.agent_version = agent_version + client.use_latest_version = use_latest_version + client.model_id = azure_ai_settings.model_deployment_name + client.conversation_id = conversation_id + client._should_close_client = should_close_client # type: ignore + client.additional_properties = {} + client.middleware = None + + # Mock the OpenAI client attribute + mock_openai_client = MagicMock() + mock_openai_client.conversations = MagicMock() + mock_openai_client.conversations.create = AsyncMock() + client.client = mock_openai_client + + return client + + +def test_azure_ai_settings_init(azure_ai_unit_test_env: dict[str, str]) -> None: + """Test AzureAISettings initialization.""" + settings = AzureAISettings() + + assert settings.project_endpoint == azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"] + assert settings.model_deployment_name == azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"] + + +def test_azure_ai_settings_init_with_explicit_values() -> None: + """Test AzureAISettings initialization with explicit values.""" + settings = AzureAISettings( + project_endpoint="https://custom-endpoint.com/", + model_deployment_name="custom-model", + ) + + assert settings.project_endpoint == "https://custom-endpoint.com/" + assert settings.model_deployment_name == "custom-model" + + +def test_azure_ai_client_init_with_project_client(mock_project_client: MagicMock) -> None: + """Test AzureAIClient initialization with existing project_client.""" + with patch("agent_framework_azure_ai._client.AzureAISettings") as mock_settings: + mock_settings.return_value.project_endpoint = None + mock_settings.return_value.model_deployment_name = "test-model" + + client = AzureAIClient( + project_client=mock_project_client, + agent_name="test-agent", + agent_version="1.0", + ) + + assert client.project_client is mock_project_client + assert client.agent_name == "test-agent" + assert client.agent_version == "1.0" + assert not client._should_close_client # type: ignore + assert isinstance(client, ChatClientProtocol) + + +def test_azure_ai_client_init_auto_create_client( + azure_ai_unit_test_env: dict[str, str], + mock_azure_credential: MagicMock, +) -> None: + """Test AzureAIClient initialization with auto-created project_client.""" + with patch("agent_framework_azure_ai._client.AIProjectClient") as mock_ai_project_client: + mock_project_client = MagicMock() + mock_ai_project_client.return_value = mock_project_client + + client = AzureAIClient( + project_endpoint=azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"], + model_deployment_name=azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + async_credential=mock_azure_credential, + agent_name="test-agent", + ) + + assert client.project_client is mock_project_client + assert client.agent_name == "test-agent" + assert client._should_close_client # type: ignore + + # Verify AIProjectClient was called with correct parameters + mock_ai_project_client.assert_called_once() + + +def test_azure_ai_client_init_missing_project_endpoint() -> None: + """Test AzureAIClient initialization when project_endpoint is missing and no project_client provided.""" + with patch("agent_framework_azure_ai._client.AzureAISettings") as mock_settings: + mock_settings.return_value.project_endpoint = None + mock_settings.return_value.model_deployment_name = "test-model" + + with pytest.raises(ServiceInitializationError, match="Azure AI project endpoint is required"): + AzureAIClient(async_credential=MagicMock()) + + +def test_azure_ai_client_init_missing_credential(azure_ai_unit_test_env: dict[str, str]) -> None: + """Test AzureAIClient.__init__ when async_credential is missing and no project_client provided.""" + with pytest.raises( + ServiceInitializationError, match="Azure credential is required when project_client is not provided" + ): + AzureAIClient( + project_endpoint=azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"], + model_deployment_name=azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + ) + + +def test_azure_ai_client_init_validation_error(mock_azure_credential: MagicMock) -> None: + """Test that ValidationError in AzureAISettings is properly handled.""" + with patch("agent_framework_azure_ai._client.AzureAISettings") as mock_settings: + mock_settings.side_effect = ValidationError.from_exception_data("test", []) + + with pytest.raises(ServiceInitializationError, match="Failed to create Azure AI settings"): + AzureAIClient(async_credential=mock_azure_credential) + + +async def test_azure_ai_client_get_agent_reference_or_create_existing_version( + mock_project_client: MagicMock, +) -> None: + """Test _get_agent_reference_or_create when agent_version is already provided.""" + client = create_test_azure_ai_client(mock_project_client, agent_name="existing-agent", agent_version="1.0") + + agent_ref = await client._get_agent_reference_or_create({}, None) # type: ignore + + assert agent_ref == {"name": "existing-agent", "version": "1.0", "type": "agent_reference"} + + +async def test_azure_ai_client_get_agent_reference_or_create_missing_agent_name( + mock_project_client: MagicMock, +) -> None: + """Test _get_agent_reference_or_create raises when agent_name is missing.""" + client = create_test_azure_ai_client(mock_project_client, agent_name=None) + + with pytest.raises(ServiceInitializationError, match="Agent name is required"): + await client._get_agent_reference_or_create({}, None) # type: ignore + + +async def test_azure_ai_client_get_agent_reference_or_create_new_agent( + mock_project_client: MagicMock, + azure_ai_unit_test_env: dict[str, str], +) -> None: + """Test _get_agent_reference_or_create when creating a new agent.""" + azure_ai_settings = AzureAISettings(model_deployment_name=azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"]) + client = create_test_azure_ai_client( + mock_project_client, agent_name="new-agent", azure_ai_settings=azure_ai_settings + ) + + # Mock agent creation response + mock_agent = MagicMock() + mock_agent.name = "new-agent" + mock_agent.version = "1.0" + mock_project_client.agents.create_version = AsyncMock(return_value=mock_agent) + + run_options = {"model": azure_ai_settings.model_deployment_name} + agent_ref = await client._get_agent_reference_or_create(run_options, None) # type: ignore + + assert agent_ref == {"name": "new-agent", "version": "1.0", "type": "agent_reference"} + assert client.agent_name == "new-agent" + assert client.agent_version == "1.0" + + +async def test_azure_ai_client_get_agent_reference_missing_model( + mock_project_client: MagicMock, +) -> None: + """Test _get_agent_reference_or_create when model is missing for agent creation.""" + client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent") + + with pytest.raises(ServiceInitializationError, match="Model deployment name is required for agent creation"): + await client._get_agent_reference_or_create({}, None) # type: ignore + + +async def test_azure_ai_client_prepare_input_with_system_messages( + mock_project_client: MagicMock, +) -> None: + """Test _prepare_input converts system/developer messages to instructions.""" + client = create_test_azure_ai_client(mock_project_client) + + messages = [ + ChatMessage(role=Role.SYSTEM, contents=[TextContent(text="You are a helpful assistant.")]), + ChatMessage(role=Role.USER, contents=[TextContent(text="Hello")]), + ChatMessage(role=Role.ASSISTANT, contents=[TextContent(text="System response")]), + ] + + result_messages, instructions = client._prepare_input(messages) # type: ignore + + assert len(result_messages) == 2 + assert result_messages[0].role == Role.USER + assert result_messages[1].role == Role.ASSISTANT + assert instructions == "You are a helpful assistant." + + +async def test_azure_ai_client_prepare_input_no_system_messages( + mock_project_client: MagicMock, +) -> None: + """Test _prepare_input with no system/developer messages.""" + client = create_test_azure_ai_client(mock_project_client) + + messages = [ + ChatMessage(role=Role.USER, contents=[TextContent(text="Hello")]), + ChatMessage(role=Role.ASSISTANT, contents=[TextContent(text="Hi there!")]), + ] + + result_messages, instructions = client._prepare_input(messages) # type: ignore + + assert len(result_messages) == 2 + assert instructions is None + + +async def test_azure_ai_client_prepare_options_basic(mock_project_client: MagicMock) -> None: + """Test prepare_options basic functionality.""" + client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent", agent_version="1.0") + + messages = [ChatMessage(role=Role.USER, contents=[TextContent(text="Hello")])] + chat_options = ChatOptions() + + with ( + patch.object(client.__class__.__bases__[0], "prepare_options", return_value={"model": "test-model"}), + patch.object( + client, + "_get_agent_reference_or_create", + return_value={"name": "test-agent", "version": "1.0", "type": "agent_reference"}, + ), + ): + run_options = await client.prepare_options(messages, chat_options) + + assert "extra_body" in run_options + assert run_options["extra_body"]["agent"]["name"] == "test-agent" + + +async def test_azure_ai_client_initialize_client(mock_project_client: MagicMock) -> None: + """Test initialize_client method.""" + client = create_test_azure_ai_client(mock_project_client) + + mock_openai_client = MagicMock() + mock_project_client.get_openai_client = AsyncMock(return_value=mock_openai_client) + + await client.initialize_client() + + assert client.client is mock_openai_client + mock_project_client.get_openai_client.assert_called_once() + + +def test_azure_ai_client_update_agent_name(mock_project_client: MagicMock) -> None: + """Test _update_agent_name method.""" + client = create_test_azure_ai_client(mock_project_client) + + # Test updating agent name when current is None + with patch.object(client, "_update_agent_name") as mock_update: + mock_update.return_value = None + client._update_agent_name("new-agent") # type: ignore + mock_update.assert_called_once_with("new-agent") + + # Test behavior when agent name is updated + assert client.agent_name is None # Should remain None since we didn't actually update + client.agent_name = "test-agent" # Manually set for the test + + # Test with None input + with patch.object(client, "_update_agent_name") as mock_update: + mock_update.return_value = None + client._update_agent_name(None) # type: ignore + mock_update.assert_called_once_with(None) + + +async def test_azure_ai_client_async_context_manager(mock_project_client: MagicMock) -> None: + """Test async context manager functionality.""" + client = create_test_azure_ai_client(mock_project_client, should_close_client=True) + + mock_project_client.close = AsyncMock() + + async with client as ctx_client: + assert ctx_client is client + + # Should call close after exiting context + mock_project_client.close.assert_called_once() + + +async def test_azure_ai_client_close_method(mock_project_client: MagicMock) -> None: + """Test close method.""" + client = create_test_azure_ai_client(mock_project_client, should_close_client=True) + + mock_project_client.close = AsyncMock() + + await client.close() + + mock_project_client.close.assert_called_once() + + +async def test_azure_ai_client_close_client_when_should_close_false(mock_project_client: MagicMock) -> None: + """Test _close_client_if_needed when should_close_client is False.""" + client = create_test_azure_ai_client(mock_project_client, should_close_client=False) + + mock_project_client.close = AsyncMock() + + await client._close_client_if_needed() # type: ignore + + # Should not call close when should_close_client is False + mock_project_client.close.assert_not_called() + + +async def test_azure_ai_client_agent_creation_with_instructions( + mock_project_client: MagicMock, +) -> None: + """Test agent creation with combined instructions.""" + client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent") + + # Mock agent creation response + mock_agent = MagicMock() + mock_agent.name = "test-agent" + mock_agent.version = "1.0" + mock_project_client.agents.create_version = AsyncMock(return_value=mock_agent) + + run_options = {"model": "test-model", "instructions": "Option instructions. "} + messages_instructions = "Message instructions. " + + await client._get_agent_reference_or_create(run_options, messages_instructions) # type: ignore + + # Verify agent was created with combined instructions + call_args = mock_project_client.agents.create_version.call_args + assert call_args[1]["definition"].instructions == "Message instructions. Option instructions. " + + +async def test_azure_ai_client_agent_creation_with_tools( + mock_project_client: MagicMock, +) -> None: + """Test agent creation with tools.""" + client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent") + + # Mock agent creation response + mock_agent = MagicMock() + mock_agent.name = "test-agent" + mock_agent.version = "1.0" + mock_project_client.agents.create_version = AsyncMock(return_value=mock_agent) + + test_tools = [{"type": "function", "function": {"name": "test_tool"}}] + run_options = {"model": "test-model", "tools": test_tools} + + await client._get_agent_reference_or_create(run_options, None) # type: ignore + + # Verify agent was created with tools + call_args = mock_project_client.agents.create_version.call_args + assert call_args[1]["definition"].tools == test_tools + + +async def test_azure_ai_client_use_latest_version_existing_agent( + mock_project_client: MagicMock, +) -> None: + """Test _get_agent_reference_or_create when use_latest_version=True and agent exists.""" + client = create_test_azure_ai_client(mock_project_client, agent_name="existing-agent", use_latest_version=True) + + # Mock existing agent response + mock_existing_agent = MagicMock() + mock_existing_agent.name = "existing-agent" + mock_existing_agent.versions.latest.version = "2.5" + mock_project_client.agents.get = AsyncMock(return_value=mock_existing_agent) + + run_options = {"model": "test-model"} + agent_ref = await client._get_agent_reference_or_create(run_options, None) # type: ignore + + # Verify existing agent was retrieved and used + mock_project_client.agents.get.assert_called_once_with("existing-agent") + mock_project_client.agents.create_version.assert_not_called() + + assert agent_ref == {"name": "existing-agent", "version": "2.5", "type": "agent_reference"} + assert client.agent_name == "existing-agent" + assert client.agent_version == "2.5" + + +async def test_azure_ai_client_use_latest_version_agent_not_found( + mock_project_client: MagicMock, +) -> None: + """Test _get_agent_reference_or_create when use_latest_version=True but agent doesn't exist.""" + from azure.core.exceptions import ResourceNotFoundError + + client = create_test_azure_ai_client(mock_project_client, agent_name="non-existing-agent", use_latest_version=True) + + # Mock ResourceNotFoundError when trying to retrieve agent + mock_project_client.agents.get = AsyncMock(side_effect=ResourceNotFoundError("Agent not found")) + + # Mock agent creation response for fallback + mock_created_agent = MagicMock() + mock_created_agent.name = "non-existing-agent" + mock_created_agent.version = "1.0" + mock_project_client.agents.create_version = AsyncMock(return_value=mock_created_agent) + + run_options = {"model": "test-model"} + agent_ref = await client._get_agent_reference_or_create(run_options, None) # type: ignore + + # Verify retrieval was attempted and creation was used as fallback + mock_project_client.agents.get.assert_called_once_with("non-existing-agent") + mock_project_client.agents.create_version.assert_called_once() + + assert agent_ref == {"name": "non-existing-agent", "version": "1.0", "type": "agent_reference"} + assert client.agent_name == "non-existing-agent" + assert client.agent_version == "1.0" + + +async def test_azure_ai_client_use_latest_version_false( + mock_project_client: MagicMock, +) -> None: + """Test _get_agent_reference_or_create when use_latest_version=False (default behavior).""" + client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent", use_latest_version=False) + + # Mock agent creation response + mock_created_agent = MagicMock() + mock_created_agent.name = "test-agent" + mock_created_agent.version = "1.0" + mock_project_client.agents.create_version = AsyncMock(return_value=mock_created_agent) + + run_options = {"model": "test-model"} + agent_ref = await client._get_agent_reference_or_create(run_options, None) # type: ignore + + # Verify retrieval was not attempted and creation was used directly + mock_project_client.agents.get.assert_not_called() + mock_project_client.agents.create_version.assert_called_once() + + assert agent_ref == {"name": "test-agent", "version": "1.0", "type": "agent_reference"} + + +async def test_azure_ai_client_use_latest_version_with_existing_agent_version( + mock_project_client: MagicMock, +) -> None: + """Test that use_latest_version is ignored when agent_version is already provided.""" + client = create_test_azure_ai_client( + mock_project_client, agent_name="test-agent", agent_version="3.0", use_latest_version=True + ) + + agent_ref = await client._get_agent_reference_or_create({}, None) # type: ignore + + # Verify neither retrieval nor creation was attempted since version is already set + mock_project_client.agents.get.assert_not_called() + mock_project_client.agents.create_version.assert_not_called() + + assert agent_ref == {"name": "test-agent", "version": "3.0", "type": "agent_reference"} + + +class ResponseFormatModel(BaseModel): + """Test Pydantic model for response format testing.""" + + name: str + value: int + description: str + model_config = ConfigDict(extra="forbid") + + +async def test_azure_ai_client_agent_creation_with_response_format( + mock_project_client: MagicMock, +) -> None: + """Test agent creation with response_format configuration.""" + client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent") + + # Mock agent creation response + mock_agent = MagicMock() + mock_agent.name = "test-agent" + mock_agent.version = "1.0" + mock_project_client.agents.create_version = AsyncMock(return_value=mock_agent) + + run_options = {"model": "test-model", "response_format": ResponseFormatModel} + + await client._get_agent_reference_or_create(run_options, None) # type: ignore + + # Verify agent was created with response format configuration + call_args = mock_project_client.agents.create_version.call_args + created_definition = call_args[1]["definition"] + + # Check that text format configuration was set + assert hasattr(created_definition, "text") + assert created_definition.text is not None + + # Check that the format is a ResponseTextFormatConfigurationJsonSchema + assert hasattr(created_definition.text, "format") + format_config = created_definition.text.format + assert isinstance(format_config, ResponseTextFormatConfigurationJsonSchema) + + # Check the schema name matches the model class name + assert format_config.name == "ResponseFormatModel" + + # Check that schema was generated correctly + assert format_config.schema is not None + schema = format_config.schema + assert "properties" in schema + assert "name" in schema["properties"] + assert "value" in schema["properties"] + assert "description" in schema["properties"] + + +async def test_azure_ai_client_prepare_options_excludes_response_format( + mock_project_client: MagicMock, +) -> None: + """Test that prepare_options excludes response_format from final run options.""" + client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent", agent_version="1.0") + + messages = [ChatMessage(role=Role.USER, contents=[TextContent(text="Hello")])] + chat_options = ChatOptions() + + with ( + patch.object( + client.__class__.__bases__[0], + "prepare_options", + return_value={"model": "test-model", "response_format": ResponseFormatModel}, + ), + patch.object( + client, + "_get_agent_reference_or_create", + return_value={"name": "test-agent", "version": "1.0", "type": "agent_reference"}, + ), + ): + run_options = await client.prepare_options(messages, chat_options) + + # response_format should be excluded from final run options + assert "response_format" not in run_options + # But extra_body should contain agent reference + assert "extra_body" in run_options + assert run_options["extra_body"]["agent"]["name"] == "test-agent" + + +async def test_azure_ai_client_prepare_options_with_resp_conversation_id( + mock_project_client: MagicMock, +) -> None: + """Test prepare_options with conversation ID starting with 'resp_'.""" + client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent", agent_version="1.0") + + messages = [ChatMessage(role=Role.USER, contents=[TextContent(text="Hello")])] + chat_options = ChatOptions(conversation_id="resp_12345") + + with ( + patch.object( + client.__class__.__bases__[0], + "prepare_options", + return_value={"model": "test-model", "previous_response_id": "old_value", "conversation": "old_conv"}, + ), + patch.object( + client, + "_get_agent_reference_or_create", + return_value={"name": "test-agent", "version": "1.0", "type": "agent_reference"}, + ), + ): + run_options = await client.prepare_options(messages, chat_options) + + # Should set previous_response_id and remove conversation property + assert run_options["previous_response_id"] == "resp_12345" + assert "conversation" not in run_options + + +async def test_azure_ai_client_prepare_options_with_conv_conversation_id( + mock_project_client: MagicMock, +) -> None: + """Test prepare_options with conversation ID starting with 'conv_'.""" + client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent", agent_version="1.0") + + messages = [ChatMessage(role=Role.USER, contents=[TextContent(text="Hello")])] + chat_options = ChatOptions(conversation_id="conv_67890") + + with ( + patch.object( + client.__class__.__bases__[0], + "prepare_options", + return_value={"model": "test-model", "previous_response_id": "old_value", "conversation": "old_conv"}, + ), + patch.object( + client, + "_get_agent_reference_or_create", + return_value={"name": "test-agent", "version": "1.0", "type": "agent_reference"}, + ), + ): + run_options = await client.prepare_options(messages, chat_options) + + # Should set conversation and remove previous_response_id property + assert run_options["conversation"] == "conv_67890" + assert "previous_response_id" not in run_options + + +async def test_azure_ai_client_prepare_options_with_client_conversation_id( + mock_project_client: MagicMock, +) -> None: + """Test prepare_options using client's default conversation ID when chat options don't have one.""" + client = create_test_azure_ai_client( + mock_project_client, agent_name="test-agent", agent_version="1.0", conversation_id="resp_client_default" + ) + + messages = [ChatMessage(role=Role.USER, contents=[TextContent(text="Hello")])] + chat_options = ChatOptions() # No conversation_id specified + + with ( + patch.object( + client.__class__.__bases__[0], + "prepare_options", + return_value={"model": "test-model", "previous_response_id": "old_value", "conversation": "old_conv"}, + ), + patch.object( + client, + "_get_agent_reference_or_create", + return_value={"name": "test-agent", "version": "1.0", "type": "agent_reference"}, + ), + ): + run_options = await client.prepare_options(messages, chat_options) + + # Should use client's default conversation_id and set previous_response_id + assert run_options["previous_response_id"] == "resp_client_default" + assert "conversation" not in run_options + + +def test_get_conversation_id_with_store_true_and_conversation_id() -> None: + """Test get_conversation_id returns conversation ID when store is True and conversation exists.""" + client = create_test_azure_ai_client(MagicMock()) + + # Mock OpenAI response with conversation + mock_response = MagicMock(spec=OpenAIResponse) + mock_response.id = "resp_12345" + mock_conversation = MagicMock() + mock_conversation.id = "conv_67890" + mock_response.conversation = mock_conversation + + result = client.get_conversation_id(mock_response, store=True) + + assert result == "conv_67890" + + +def test_get_conversation_id_with_store_true_and_no_conversation() -> None: + """Test get_conversation_id returns response ID when store is True and no conversation exists.""" + client = create_test_azure_ai_client(MagicMock()) + + # Mock OpenAI response without conversation + mock_response = MagicMock(spec=OpenAIResponse) + mock_response.id = "resp_12345" + mock_response.conversation = None + + result = client.get_conversation_id(mock_response, store=True) + + assert result == "resp_12345" + + +def test_get_conversation_id_with_store_true_and_empty_conversation_id() -> None: + """Test get_conversation_id returns response ID when store is True and conversation ID is empty.""" + client = create_test_azure_ai_client(MagicMock()) + + # Mock OpenAI response with conversation but empty ID + mock_response = MagicMock(spec=OpenAIResponse) + mock_response.id = "resp_12345" + mock_conversation = MagicMock() + mock_conversation.id = "" + mock_response.conversation = mock_conversation + + result = client.get_conversation_id(mock_response, store=True) + + assert result == "resp_12345" + + +def test_get_conversation_id_with_store_false() -> None: + """Test get_conversation_id returns None when store is False.""" + client = create_test_azure_ai_client(MagicMock()) + + # Mock OpenAI response with conversation + mock_response = MagicMock(spec=OpenAIResponse) + mock_response.id = "resp_12345" + mock_conversation = MagicMock() + mock_conversation.id = "conv_67890" + mock_response.conversation = mock_conversation + + result = client.get_conversation_id(mock_response, store=False) + + assert result is None + + +def test_get_conversation_id_with_parsed_response_and_store_true() -> None: + """Test get_conversation_id works with ParsedResponse when store is True.""" + client = create_test_azure_ai_client(MagicMock()) + + # Mock ParsedResponse with conversation + mock_response = MagicMock(spec=ParsedResponse[BaseModel]) + mock_response.id = "resp_parsed_12345" + mock_conversation = MagicMock() + mock_conversation.id = "conv_parsed_67890" + mock_response.conversation = mock_conversation + + result = client.get_conversation_id(mock_response, store=True) + + assert result == "conv_parsed_67890" + + +def test_get_conversation_id_with_parsed_response_no_conversation() -> None: + """Test get_conversation_id returns response ID with ParsedResponse when no conversation exists.""" + client = create_test_azure_ai_client(MagicMock()) + + # Mock ParsedResponse without conversation + mock_response = MagicMock(spec=ParsedResponse[BaseModel]) + mock_response.id = "resp_parsed_12345" + mock_response.conversation = None + + result = client.get_conversation_id(mock_response, store=True) + + assert result == "resp_parsed_12345" + + +@pytest.fixture +def mock_project_client() -> MagicMock: + """Fixture that provides a mock AIProjectClient.""" + mock_client = MagicMock() + + # Mock agents property + mock_client.agents = MagicMock() + mock_client.agents.create_version = AsyncMock() + + # Mock conversations property + mock_client.conversations = MagicMock() + mock_client.conversations.create = AsyncMock() + + # Mock telemetry property + mock_client.telemetry = MagicMock() + mock_client.telemetry.get_application_insights_connection_string = AsyncMock() + + # Mock get_openai_client method + mock_client.get_openai_client = AsyncMock() + + # Mock close method + mock_client.close = AsyncMock() + + return mock_client diff --git a/python/packages/azurefunctions/LICENSE b/python/packages/azurefunctions/LICENSE new file mode 100644 index 0000000000..9e841e7a26 --- /dev/null +++ b/python/packages/azurefunctions/LICENSE @@ -0,0 +1,21 @@ + MIT License + + Copyright (c) Microsoft Corporation. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE diff --git a/python/packages/azurefunctions/README.md b/python/packages/azurefunctions/README.md new file mode 100644 index 0000000000..5e49671da0 --- /dev/null +++ b/python/packages/azurefunctions/README.md @@ -0,0 +1,28 @@ +# Get Started with Microsoft Agent Framework Durable Functions + +[![PyPI](https://img.shields.io/pypi/v/agent-framework-azurefunctions)](https://pypi.org/project/agent-framework-azurefunctions/) + +Please install this package via pip: + +```bash +pip install agent-framework-azurefunctions --pre +``` + +## Durable Agent Extension + +The durable agent extension lets you host Microsoft Agent Framework agents on Azure Durable Functions so they can persist state, replay conversation history, and recover from failures automatically. + +### Basic Usage Example + +See the durable functions integration sample in the repository to learn how to: + +```python +from agent_framework.azure import AgentFunctionApp + +_app = AgentFunctionApp() +``` + +- Register agents with `AgentFunctionApp` +- Post messages using the generated `/api/agents/{agent_name}/run` endpoint + +For more details, review the Python [README](https://github.com/microsoft/agent-framework/tree/main/python/README.md) and the samples directory. diff --git a/python/packages/azurefunctions/agent_framework_azurefunctions/__init__.py b/python/packages/azurefunctions/agent_framework_azurefunctions/__init__.py new file mode 100644 index 0000000000..5684d1ec1b --- /dev/null +++ b/python/packages/azurefunctions/agent_framework_azurefunctions/__init__.py @@ -0,0 +1,20 @@ +# Copyright (c) Microsoft. All rights reserved. + +import importlib.metadata + +from ._app import AgentFunctionApp +from ._callbacks import AgentCallbackContext, AgentResponseCallbackProtocol +from ._orchestration import DurableAIAgent + +try: + __version__ = importlib.metadata.version(__name__) +except importlib.metadata.PackageNotFoundError: + __version__ = "0.0.0" # Fallback for development mode + +__all__ = [ + "AgentCallbackContext", + "AgentFunctionApp", + "AgentResponseCallbackProtocol", + "DurableAIAgent", + "__version__", +] diff --git a/python/packages/azurefunctions/agent_framework_azurefunctions/_app.py b/python/packages/azurefunctions/agent_framework_azurefunctions/_app.py new file mode 100644 index 0000000000..bb7c4398fc --- /dev/null +++ b/python/packages/azurefunctions/agent_framework_azurefunctions/_app.py @@ -0,0 +1,802 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""AgentFunctionApp - Main application class. + +This module provides the AgentFunctionApp class that integrates Microsoft Agent Framework +with Azure Durable Entities, enabling stateful and durable AI agent execution. +""" + +import json +import re +from collections.abc import Callable, Mapping +from typing import TYPE_CHECKING, Any, TypeVar, cast + +import azure.durable_functions as df +import azure.functions as func +from agent_framework import AgentProtocol, get_logger + +from ._callbacks import AgentResponseCallbackProtocol +from ._entities import create_agent_entity +from ._errors import IncomingRequestError +from ._models import AgentSessionId, RunRequest +from ._orchestration import AgentOrchestrationContextType, DurableAIAgent +from ._state import AgentState + +logger = get_logger("agent_framework.azurefunctions") + +THREAD_ID_FIELD: str = "thread_id" +RESPONSE_FORMAT_JSON: str = "json" +RESPONSE_FORMAT_TEXT: str = "text" +WAIT_FOR_RESPONSE_FIELD: str = "wait_for_response" +WAIT_FOR_RESPONSE_HEADER: str = "x-ms-wait-for-response" + + +EntityHandler = Callable[[df.DurableEntityContext], None] +HandlerT = TypeVar("HandlerT", bound=Callable[..., Any]) + +DEFAULT_MAX_POLL_RETRIES: int = 30 +DEFAULT_POLL_INTERVAL_SECONDS: float = 1.0 + +if TYPE_CHECKING: + + class DFAppBase: + def __init__(self, http_auth_level: func.AuthLevel = func.AuthLevel.FUNCTION) -> None: ... + + def function_name(self, name: str) -> Callable[[HandlerT], HandlerT]: ... + + def route(self, route: str, methods: list[str]) -> Callable[[HandlerT], HandlerT]: ... + + def durable_client_input(self, client_name: str) -> Callable[[HandlerT], HandlerT]: ... + + def entity_trigger(self, context_name: str, entity_name: str) -> Callable[[EntityHandler], EntityHandler]: ... + + def orchestration_trigger(self, context_name: str) -> Callable[[HandlerT], HandlerT]: ... + + def activity_trigger(self, input_name: str) -> Callable[[HandlerT], HandlerT]: ... + +else: + DFAppBase = df.DFApp # type: ignore[assignment] + + +class AgentFunctionApp(DFAppBase): + """Main application class for creating durable agent function apps using Durable Entities. + + This class uses Durable Entities pattern for agent execution, providing: + + - Stateful agent conversations + - Conversation history management + - Signal-based operation invocation + - Better state management than orchestrations + + Example: + ------- + + .. code-block:: python + + from agent_framework.azure import AgentFunctionApp, AzureOpenAIChatClient + + # Create agents with unique names + weather_agent = AzureOpenAIChatClient(...).create_agent( + name="WeatherAgent", + instructions="You are a helpful weather agent.", + tools=[get_weather], + ) + + math_agent = AzureOpenAIChatClient(...).create_agent( + name="MathAgent", + instructions="You are a helpful math assistant.", + tools=[calculate], + ) + + # Option 1: Pass list of agents during initialization + app = AgentFunctionApp(agents=[weather_agent, math_agent]) + + # Option 2: Add agents after initialization + app = AgentFunctionApp() + app.add_agent(weather_agent) + app.add_agent(math_agent) + + + @app.orchestration_trigger(context_name="context") + def my_orchestration(context): + writer = app.get_agent(context, "WeatherAgent") + thread = writer.get_new_thread() + forecast_task = writer.run("What's the forecast?", thread=thread) + forecast = yield forecast_task + return forecast + + This creates: + + - HTTP trigger endpoint for each agent's requests (if enabled) + - Durable entity for each agent's state management and execution + - Full access to all Azure Functions capabilities + + Attributes: + agents: Dictionary of agent name to AgentProtocol instance + enable_health_check: Whether health check endpoint is enabled + enable_http_endpoints: Whether HTTP endpoints are created for agents + max_poll_retries: Maximum polling attempts when waiting for responses + poll_interval_seconds: Delay (seconds) between polling attempts + """ + + agents: dict[str, AgentProtocol] + enable_health_check: bool + enable_http_endpoints: bool + agent_http_endpoint_flags: dict[str, bool] + + def __init__( + self, + agents: list[AgentProtocol] | None = None, + http_auth_level: func.AuthLevel = func.AuthLevel.FUNCTION, + enable_health_check: bool = True, + enable_http_endpoints: bool = True, + max_poll_retries: int = DEFAULT_MAX_POLL_RETRIES, + poll_interval_seconds: float = DEFAULT_POLL_INTERVAL_SECONDS, + default_callback: AgentResponseCallbackProtocol | None = None, + ): + """Initialize the AgentFunctionApp. + + :param agents: List of agent instances to register. + :param http_auth_level: HTTP authentication level (default: ``func.AuthLevel.FUNCTION``). + :param enable_health_check: Enable the built-in health check endpoint (default: ``True``). + :param enable_http_endpoints: Enable HTTP endpoints for agents (default: ``True``). + :param max_poll_retries: Maximum polling attempts when waiting for a response. + Defaults to ``DEFAULT_MAX_POLL_RETRIES``. + :param poll_interval_seconds: Delay in seconds between polling attempts. + Defaults to ``DEFAULT_POLL_INTERVAL_SECONDS``. + :param default_callback: Optional callback invoked for agents without specific callbacks. + + :note: If no agents are provided, they can be added later using :meth:`add_agent`. + """ + logger.debug("[AgentFunctionApp] Initializing with Durable Entities...") + + # Initialize parent DFApp + super().__init__(http_auth_level=http_auth_level) + + # Initialize agents dictionary + self.agents = {} + self.agent_http_endpoint_flags = {} + self.enable_health_check = enable_health_check + self.enable_http_endpoints = enable_http_endpoints + self.default_callback = default_callback + + try: + retries = int(max_poll_retries) + except (TypeError, ValueError): + retries = DEFAULT_MAX_POLL_RETRIES + self.max_poll_retries = max(1, retries) + + try: + interval = float(poll_interval_seconds) + except (TypeError, ValueError): + interval = DEFAULT_POLL_INTERVAL_SECONDS + self.poll_interval_seconds = interval if interval > 0 else DEFAULT_POLL_INTERVAL_SECONDS + + if agents: + # Register all provided agents + logger.debug(f"[AgentFunctionApp] Registering {len(agents)} agent(s)") + for agent_instance in agents: + self.add_agent(agent_instance) + + # Setup health check if enabled + if self.enable_health_check: + self._setup_health_route() + + logger.debug("[AgentFunctionApp] Initialization complete") + + def add_agent( + self, + agent: AgentProtocol, + callback: AgentResponseCallbackProtocol | None = None, + enable_http_endpoint: bool | None = None, + ) -> None: + """Add an agent to the function app after initialization. + + Args: + agent: The Microsoft Agent Framework agent instance (must implement AgentProtocol) + The agent must have a 'name' attribute. + callback: Optional callback invoked during agent execution + enable_http_endpoint: Optional flag that overrides the app-level + HTTP endpoint setting for this agent + + Raises: + ValueError: If the agent doesn't have a 'name' attribute or if an agent + with the same name is already registered + """ + # Get agent name from the agent's name attribute + name = getattr(agent, "name", None) + if name is None: + raise ValueError("Agent does not have a 'name' attribute. All agents must have a 'name' attribute.") + + if name in self.agents: + raise ValueError(f"Agent with name '{name}' is already registered. Each agent must have a unique name.") + + effective_enable_http_endpoint = ( + self.enable_http_endpoints if enable_http_endpoint is None else self._coerce_to_bool(enable_http_endpoint) + ) + + logger.debug(f"[AgentFunctionApp] Adding agent: {name}") + logger.debug(f"[AgentFunctionApp] Route: /api/agents/{name}") + logger.debug( + "[AgentFunctionApp] HTTP endpoint %s for agent '%s'", + "enabled" if effective_enable_http_endpoint else "disabled", + name, + ) + + self.agents[name] = agent + self.agent_http_endpoint_flags[name] = effective_enable_http_endpoint + + effective_callback = callback or self.default_callback + + self._setup_agent_functions( + agent, + name, + effective_callback, + effective_enable_http_endpoint, + ) + + logger.debug(f"[AgentFunctionApp] Agent '{name}' added successfully") + + def get_agent( + self, + context: AgentOrchestrationContextType, + agent_name: str, + ) -> DurableAIAgent: + """Return a DurableAIAgent proxy for a registered agent. + + Args: + context: Durable Functions orchestration context invoking the agent. + agent_name: Name of the agent registered on this app. + + Raises: + ValueError: If the requested agent has not been registered. + + Returns: + DurableAIAgent wrapper bound to the orchestration context. + """ + normalized_name = str(agent_name) + + if normalized_name not in self.agents: + raise ValueError(f"Agent '{normalized_name}' is not registered with this app.") + + return DurableAIAgent(context, normalized_name) + + def _setup_agent_functions( + self, + agent: AgentProtocol, + agent_name: str, + callback: AgentResponseCallbackProtocol | None, + enable_http_endpoint: bool, + ) -> None: + """Set up the HTTP trigger and entity for a specific agent. + + Args: + agent: The agent instance + agent_name: The name to use for routing and entity registration + callback: Optional callback to receive response updates + enable_http_endpoint: Whether the HTTP run route is enabled for + this agent + """ + logger.debug(f"[AgentFunctionApp] Setting up functions for agent '{agent_name}'...") + + if enable_http_endpoint: + self._setup_http_run_route(agent_name) + else: + logger.debug( + "[AgentFunctionApp] HTTP run route disabled for agent '%s'", + agent_name, + ) + self._setup_agent_entity(agent, agent_name, callback) + + def _setup_http_run_route(self, agent_name: str) -> None: + """Register the POST route that triggers agent execution. + + Args: + agent_name: The agent name (used for both routing and entity identification) + """ + run_function_name = self._build_function_name(agent_name, "http") + + function_name_decorator = self.function_name(run_function_name) + route_decorator = self.route(route=f"agents/{agent_name}/run", methods=["POST"]) + durable_client_decorator = self.durable_client_input(client_name="client") + + @function_name_decorator + @route_decorator + @durable_client_decorator + async def http_start(req: func.HttpRequest, client: df.DurableOrchestrationClient) -> func.HttpResponse: + """HTTP trigger that calls a durable entity to execute the agent and returns the result. + + Expected request body (RunRequest format): + { + "message": "user message to agent", + "thread_id": "optional conversation identifier", + "role": "user|system" (optional, default: "user"), + "response_format": {...} (optional JSON schema for structured responses), + "enable_tool_calls": true|false (optional, default: true) + } + """ + logger.debug(f"[HTTP Trigger] Received request on route: /api/agents/{agent_name}/run") + + response_format: str = RESPONSE_FORMAT_JSON + thread_id: str | None = None + + try: + req_body, message, response_format = self._parse_incoming_request(req) + thread_id = self._resolve_thread_id(req=req, req_body=req_body) + wait_for_response = self._should_wait_for_response(req=req, req_body=req_body) + + logger.debug(f"[HTTP Trigger] Message: {message}") + logger.debug(f"[HTTP Trigger] Thread ID: {thread_id}") + logger.debug(f"[HTTP Trigger] wait_for_response: {wait_for_response}") + + if not message: + logger.warning("[HTTP Trigger] Request rejected: Missing message") + return self._create_http_response( + payload={"error": "Message is required"}, + status_code=400, + response_format=response_format, + thread_id=thread_id, + ) + + session_id = self._create_session_id(agent_name, thread_id) + correlation_id = self._generate_unique_id() + + logger.debug(f"[HTTP Trigger] Using session ID: {session_id}") + logger.debug(f"[HTTP Trigger] Generated correlation ID: {correlation_id}") + logger.debug("[HTTP Trigger] Calling entity to run agent...") + + entity_instance_id = session_id.to_entity_id() + run_request = self._build_request_data( + req_body, + message, + thread_id, + correlation_id, + ) + logger.debug("Signalling entity %s with request: %s", entity_instance_id, run_request) + await client.signal_entity(entity_instance_id, "run_agent", run_request) + + logger.debug(f"[HTTP Trigger] Signal sent to entity {session_id}") + + if wait_for_response: + result = await self._get_response_from_entity( + client=client, + entity_instance_id=entity_instance_id, + correlation_id=correlation_id, + message=message, + thread_id=thread_id, + ) + + logger.debug(f"[HTTP Trigger] Result status: {result.get('status', 'unknown')}") + return self._create_http_response( + payload=result, + status_code=200 if result.get("status") == "success" else 500, + response_format=response_format, + thread_id=thread_id, + ) + + logger.debug("[HTTP Trigger] wait_for_response disabled; returning correlation ID") + + accepted_response = self._build_accepted_response( + message=message, thread_id=thread_id, correlation_id=correlation_id + ) + + return self._create_http_response( + payload=accepted_response, + status_code=202, + response_format=response_format, + thread_id=thread_id, + ) + + except IncomingRequestError as exc: + logger.warning(f"[HTTP Trigger] Request rejected: {exc!s}") + return self._create_http_response( + payload={"error": str(exc)}, + status_code=exc.status_code, + response_format=response_format, + thread_id=thread_id, + ) + except ValueError as exc: + logger.error(f"[HTTP Trigger] Invalid JSON: {exc!s}") + return self._create_http_response( + payload={"error": "Invalid JSON"}, + status_code=400, + response_format=response_format, + thread_id=thread_id, + ) + except Exception as exc: + logger.error(f"[HTTP Trigger] Error: {exc!s}", exc_info=True) + return self._create_http_response( + payload={"error": str(exc)}, + status_code=500, + response_format=response_format, + thread_id=thread_id, + ) + + _ = http_start + + def _setup_agent_entity( + self, + agent: AgentProtocol, + agent_name: str, + callback: AgentResponseCallbackProtocol | None, + ) -> None: + """Register the durable entity responsible for agent state. + + Args: + agent: The agent instance + agent_name: The agent name (used for both entity identification and function naming) + callback: Optional callback for response updates + """ + # Use the prefixed entity name for both registration and function naming + entity_name_with_prefix = AgentSessionId.to_entity_name(agent_name) + + def entity_function(context: df.DurableEntityContext) -> None: + """Durable entity that manages agent execution and conversation state. + + Operations: + - run_agent: Execute the agent with a message + - reset: Clear conversation history + """ + entity_handler = create_agent_entity(agent, callback) + entity_handler(context) + + # Set function name for Azure Functions (used in function.json generation) + # Use the prefixed entity name as the function name too. + entity_function.__name__ = entity_name_with_prefix + self.entity_trigger(context_name="context", entity_name=entity_name_with_prefix)(entity_function) + + def _setup_health_route(self) -> None: + """Register the optional health check route.""" + health_route = self.route(route="health", methods=["GET"]) + + @health_route + def health_check(req: func.HttpRequest) -> func.HttpResponse: + """Built-in health check endpoint.""" + agent_info = [ + { + "name": name, + "type": type(agent).__name__, + "http_endpoint_enabled": self.agent_http_endpoint_flags.get( + name, + self.enable_http_endpoints, + ), + } + for name, agent in self.agents.items() + ] + return func.HttpResponse( + json.dumps({"status": "healthy", "agents": agent_info, "agent_count": len(self.agents)}), + status_code=200, + mimetype="application/json", + ) + + _ = health_check + + @staticmethod + def _build_function_name(agent_name: str, prefix: str) -> str: + """Generate the sanitized function name in the form "{prefix}-{sanitized_agent_name}". + + Example: agent_name="Weather Agent" and prefix="http" becomes "http-Weather_Agent". + """ + sanitized_agent = re.sub(r"[^0-9a-zA-Z_]", "_", agent_name or "agent").strip("_") + + if not sanitized_agent: + sanitized_agent = "agent" + + if sanitized_agent[0].isdigit(): + sanitized_agent = f"agent_{sanitized_agent}" + + return f"{prefix}-{sanitized_agent}" + + async def _read_cached_state( + self, + client: df.DurableOrchestrationClient, + entity_instance_id: df.EntityId, + ) -> AgentState | None: + state_response = await client.read_entity_state(entity_instance_id) + if not state_response or not state_response.entity_exists: + return None + + state_payload = state_response.entity_state + if not isinstance(state_payload, dict): + return None + + typed_state_payload = cast(dict[str, Any], state_payload) + + agent_state = AgentState() + agent_state.restore_state(typed_state_payload) + return agent_state + + async def _get_response_from_entity( + self, + client: df.DurableOrchestrationClient, + entity_instance_id: df.EntityId, + correlation_id: str, + message: str, + thread_id: str, + ) -> dict[str, Any]: + """Poll the entity state until a response is available or timeout occurs.""" + import asyncio + + max_retries = self.max_poll_retries + interval = self.poll_interval_seconds + retry_count = 0 + result: dict[str, Any] | None = None + + logger.debug(f"[HTTP Trigger] Waiting for response with correlation ID: {correlation_id}") + + while retry_count < max_retries: + await asyncio.sleep(interval) + + result = await self._poll_entity_for_response( + client=client, + entity_instance_id=entity_instance_id, + correlation_id=correlation_id, + message=message, + thread_id=thread_id, + ) + if result is not None: + break + + logger.debug(f"[HTTP Trigger] Response not available yet (retry {retry_count})") + retry_count += 1 + + if result is not None: + return result + + logger.warning( + f"[HTTP Trigger] Response with correlation ID {correlation_id} " + f"not found in time (waited {max_retries * interval} seconds)" + ) + return await self._build_timeout_result(message=message, thread_id=thread_id, correlation_id=correlation_id) + + async def _poll_entity_for_response( + self, + client: df.DurableOrchestrationClient, + entity_instance_id: df.EntityId, + correlation_id: str, + message: str, + thread_id: str, + ) -> dict[str, Any] | None: + result: dict[str, Any] | None = None + try: + state = await self._read_cached_state(client, entity_instance_id) + + if state is None: + return None + + agent_response = state.try_get_agent_response(correlation_id) + if agent_response: + result = self._build_success_result( + response_data=agent_response, + message=message, + thread_id=thread_id, + correlation_id=correlation_id, + state=state, + ) + logger.debug(f"[HTTP Trigger] Found response for correlation ID: {correlation_id}") + + except Exception as exc: + logger.warning(f"[HTTP Trigger] Error reading entity state: {exc}") + + return result + + async def _build_timeout_result(self, message: str, thread_id: str, correlation_id: str) -> dict[str, Any]: + """Create the timeout response.""" + return { + "response": "Agent is still processing or timed out...", + "message": message, + THREAD_ID_FIELD: thread_id, + "status": "timeout", + "correlation_id": correlation_id, + } + + def _build_success_result( + self, response_data: dict[str, Any], message: str, thread_id: str, correlation_id: str, state: AgentState + ) -> dict[str, Any]: + """Build the success result returned to the HTTP caller.""" + return { + "response": response_data.get("content"), + "message": message, + THREAD_ID_FIELD: thread_id, + "status": "success", + "message_count": response_data.get("message_count", state.message_count), + "correlation_id": correlation_id, + } + + def _build_request_data( + self, req_body: dict[str, Any], message: str, thread_id: str, correlation_id: str + ) -> dict[str, Any]: + """Create the durable entity request payload.""" + enable_tool_calls_value = req_body.get("enable_tool_calls") + enable_tool_calls = True if enable_tool_calls_value is None else self._coerce_to_bool(enable_tool_calls_value) + + return RunRequest( + message=message, + role=req_body.get("role"), + response_format=req_body.get("response_format"), + enable_tool_calls=enable_tool_calls, + thread_id=thread_id, + correlation_id=correlation_id, + ).to_dict() + + def _build_accepted_response(self, message: str, thread_id: str, correlation_id: str) -> dict[str, Any]: + """Build the response returned when not waiting for completion.""" + return { + "response": "Agent request accepted", + "message": message, + THREAD_ID_FIELD: thread_id, + "status": "accepted", + "correlation_id": correlation_id, + } + + def _create_http_response( + self, + payload: dict[str, Any] | str, + status_code: int, + response_format: str, + thread_id: str | None, + ) -> func.HttpResponse: + """Create the HTTP response using helper serializers for clarity.""" + if response_format == RESPONSE_FORMAT_TEXT: + return self._build_plain_text_response(payload=payload, status_code=status_code, thread_id=thread_id) + + return self._build_json_response(payload=payload, status_code=status_code) + + def _build_plain_text_response( + self, + payload: dict[str, Any] | str, + status_code: int, + thread_id: str | None, + ) -> func.HttpResponse: + """Return a plain-text response with optional thread identifier header.""" + body_text = payload if isinstance(payload, str) else self._convert_payload_to_text(payload) + headers = {"x-ms-thread-id": thread_id} if thread_id is not None else None + return func.HttpResponse(body_text, status_code=status_code, mimetype="text/plain", headers=headers) + + def _build_json_response(self, payload: dict[str, Any] | str, status_code: int) -> func.HttpResponse: + """Return the JSON response, serializing dictionaries as needed.""" + body_json = payload if isinstance(payload, str) else json.dumps(payload) + return func.HttpResponse(body_json, status_code=status_code, mimetype="application/json") + + def _convert_payload_to_text(self, payload: dict[str, Any]) -> str: + """Convert a structured payload into a human-readable text response.""" + for key in ("response", "error", "message"): + value = payload.get(key) + if isinstance(value, str) and value: + return value + return json.dumps(payload) + + def _generate_unique_id(self) -> str: + """Generate a new unique identifier.""" + import uuid + + return uuid.uuid4().hex + + def _create_session_id(self, func_name: str, thread_id: str | None) -> AgentSessionId: + """Create a session identifier using the provided thread id or a random value.""" + if thread_id: + return AgentSessionId(name=func_name, key=thread_id) + return AgentSessionId.with_random_key(name=func_name) + + def _resolve_thread_id(self, req: func.HttpRequest, req_body: dict[str, Any]) -> str: + """Retrieve the thread identifier from request body or query parameters.""" + params = req.params or {} + + if THREAD_ID_FIELD in req_body: + value = req_body.get(THREAD_ID_FIELD) + if value is not None: + return str(value) + + if THREAD_ID_FIELD in params: + value = params.get(THREAD_ID_FIELD) + if value is not None: + return str(value) + + logger.debug("[HTTP Trigger] No thread identifier provided; using random thread id") + return self._generate_unique_id() + + def _parse_incoming_request(self, req: func.HttpRequest) -> tuple[dict[str, Any], str, str]: + """Parse the incoming run request supporting JSON and plain text bodies.""" + headers = self._extract_normalized_headers(req) + + normalized_content_type = self._extract_content_type(headers) + body_parser, body_format = self._select_body_parser(normalized_content_type) + prefers_json = self._accepts_json_response(headers) + response_format = self._select_response_format(body_format=body_format, prefers_json=prefers_json) + + req_body, message = body_parser(req) + return req_body, message, response_format + + def _extract_normalized_headers(self, req: func.HttpRequest) -> dict[str, str]: + """Create a lowercase header mapping from the incoming request.""" + headers: dict[str, str] = {} + raw_headers = req.headers + if isinstance(raw_headers, Mapping): + header_mapping: Mapping[str, Any] = cast(Mapping[str, Any], raw_headers) + for key, value in header_mapping.items(): + if value is not None: + headers[str(key).lower()] = str(value) + return headers + + @staticmethod + def _extract_content_type(headers: dict[str, str]) -> str: + """Return the normalized content-type value (without parameters).""" + content_type_header = headers.get("content-type", "") + return content_type_header.split(";")[0].strip().lower() if content_type_header else "" + + def _select_body_parser( + self, + normalized_content_type: str, + ) -> tuple[Callable[[func.HttpRequest], tuple[dict[str, Any], str]], str]: + """Choose the body parser and declared body format.""" + if normalized_content_type in {"application/json"} or normalized_content_type.endswith("+json"): + return self._parse_json_body, RESPONSE_FORMAT_JSON + return self._parse_text_body, RESPONSE_FORMAT_TEXT + + @staticmethod + def _accepts_json_response(headers: dict[str, str]) -> bool: + """Check whether the caller explicitly requests a JSON response.""" + accept_header = headers.get("accept") + if not accept_header: + return False + + for value in accept_header.split(","): + media_type = value.split(";")[0].strip().lower() + if media_type == "application/json": + return True + return False + + @staticmethod + def _select_response_format(body_format: str, prefers_json: bool) -> str: + """Combine body format and accept preference to determine response format.""" + if body_format == RESPONSE_FORMAT_JSON or prefers_json: + return RESPONSE_FORMAT_JSON + return RESPONSE_FORMAT_TEXT + + @staticmethod + def _parse_json_body(req: func.HttpRequest) -> tuple[dict[str, Any], str]: + req_body = req.get_json() + if not isinstance(req_body, dict): + raise IncomingRequestError("Invalid JSON payload. Expected an object.") + + typed_req_body = cast(dict[str, Any], req_body) + message_value = typed_req_body.get("message", "") + message = message_value if isinstance(message_value, str) else str(message_value) + return typed_req_body, message + + @staticmethod + def _parse_text_body(req: func.HttpRequest) -> tuple[dict[str, Any], str]: + body_bytes = req.get_body() + text_body = body_bytes.decode("utf-8", errors="replace") if body_bytes else "" + message = text_body.strip() + + return {}, message + + def _should_wait_for_response(self, req: func.HttpRequest, req_body: dict[str, Any]) -> bool: + """Determine whether the caller requested to wait for the response.""" + headers: dict[str, str] = self._extract_normalized_headers(req) + header_value: str | None = headers.get(WAIT_FOR_RESPONSE_HEADER) + + if header_value is not None: + return self._coerce_to_bool(header_value) + + params = req.params or {} + if WAIT_FOR_RESPONSE_FIELD in params: + return self._coerce_to_bool(params.get(WAIT_FOR_RESPONSE_FIELD)) + + if WAIT_FOR_RESPONSE_FIELD in req_body: + return self._coerce_to_bool(req_body.get(WAIT_FOR_RESPONSE_FIELD)) + + return True + + def _coerce_to_bool(self, value: Any) -> bool: + """Convert various representations into a boolean flag.""" + if isinstance(value, bool): + return value + if value is None: + return False + if isinstance(value, (int, float)): + return bool(value) + if isinstance(value, str): + return value.strip().lower() in {"true", "1", "yes", "y", "on"} + return False diff --git a/python/packages/azurefunctions/agent_framework_azurefunctions/_callbacks.py b/python/packages/azurefunctions/agent_framework_azurefunctions/_callbacks.py new file mode 100644 index 0000000000..31b31111ac --- /dev/null +++ b/python/packages/azurefunctions/agent_framework_azurefunctions/_callbacks.py @@ -0,0 +1,42 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Callback interfaces for Durable Agent executions. + +This module enables callers of AgentFunctionApp to supply streaming and final-response callbacks that are +invoked during durable entity execution. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol + +from agent_framework import AgentRunResponse, AgentRunResponseUpdate + + +@dataclass(frozen=True) +class AgentCallbackContext: + """Context supplied to callback invocations.""" + + agent_name: str + correlation_id: str + thread_id: str | None = None + request_message: str | None = None + + +class AgentResponseCallbackProtocol(Protocol): + """Protocol describing the callbacks invoked during agent execution.""" + + async def on_streaming_response_update( + self, + update: AgentRunResponseUpdate, + context: AgentCallbackContext, + ) -> None: + """Handle a streaming response update emitted by the agent.""" + + async def on_agent_response( + self, + response: AgentRunResponse, + context: AgentCallbackContext, + ) -> None: + """Handle the final agent response.""" diff --git a/python/packages/azurefunctions/agent_framework_azurefunctions/_entities.py b/python/packages/azurefunctions/agent_framework_azurefunctions/_entities.py new file mode 100644 index 0000000000..8df8e3f335 --- /dev/null +++ b/python/packages/azurefunctions/agent_framework_azurefunctions/_entities.py @@ -0,0 +1,427 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Durable Entity for Agent Execution. + +This module defines a durable entity that manages agent state and execution. +Using entities instead of orchestrations provides better state management and +allows for long-running agent conversations. +""" + +import asyncio +import inspect +import json +from collections.abc import AsyncIterable, Callable +from typing import Any, cast + +import azure.durable_functions as df +from agent_framework import AgentProtocol, AgentRunResponse, AgentRunResponseUpdate, Role, get_logger + +from ._callbacks import AgentCallbackContext, AgentResponseCallbackProtocol +from ._models import AgentResponse, RunRequest +from ._state import AgentState + +logger = get_logger("agent_framework.azurefunctions.entities") + + +class AgentEntity: + """Durable entity that manages agent execution and conversation state. + + This entity: + - Maintains conversation history + - Executes agent with messages + - Stores agent responses + - Handles tool execution + + Operations: + - run_agent: Execute the agent with a message + - reset: Clear conversation history + + Attributes: + agent: The AgentProtocol instance + state: The AgentState managing conversation history + """ + + agent: AgentProtocol + state: AgentState + + def __init__( + self, + agent: AgentProtocol, + callback: AgentResponseCallbackProtocol | None = None, + ): + """Initialize the agent entity. + + Args: + agent: The Microsoft Agent Framework agent instance (must implement AgentProtocol) + callback: Optional callback invoked during streaming updates and final responses + """ + self.agent = agent + self.state = AgentState() + self.callback = callback + + logger.debug(f"[AgentEntity] Initialized with agent type: {type(agent).__name__}") + + async def run_agent( + self, + context: df.DurableEntityContext, + request: RunRequest | dict[str, Any] | str, + ) -> dict[str, Any]: + """Execute the agent with a message directly in the entity. + + Args: + context: Entity context + request: RunRequest object, dict, or string message (for backward compatibility) + + Returns: + Dict with status information and response (serialized AgentResponse) + + Note: + The agent returns an AgentRunResponse object which is stored in state. + This method extracts the text/structured response and returns an AgentResponse dict. + """ + # Convert string or dict to RunRequest + if isinstance(request, str): + run_request = RunRequest(message=request, role=Role.USER) + elif isinstance(request, dict): + run_request = RunRequest.from_dict(request) + else: + run_request = request + + message = run_request.message + thread_id = run_request.thread_id + correlation_id = run_request.correlation_id + if not thread_id: + raise ValueError("RunRequest must include a thread_id") + if not correlation_id: + raise ValueError("RunRequest must include a correlation_id") + role = run_request.role or Role.USER + response_format = run_request.response_format + enable_tool_calls = run_request.enable_tool_calls + + logger.debug(f"[AgentEntity.run_agent] Received message: {message}") + logger.debug(f"[AgentEntity.run_agent] Thread ID: {thread_id}") + logger.debug(f"[AgentEntity.run_agent] Correlation ID: {correlation_id}") + logger.debug(f"[AgentEntity.run_agent] Role: {role.value}") + logger.debug(f"[AgentEntity.run_agent] Enable tool calls: {enable_tool_calls}") + logger.debug(f"[AgentEntity.run_agent] Response format: {'provided' if response_format else 'none'}") + + # Store message in history with role + self.state.add_user_message(message, role=role, correlation_id=correlation_id) + + logger.debug("[AgentEntity.run_agent] Executing agent...") + + try: + logger.debug("[AgentEntity.run_agent] Starting agent invocation") + + run_kwargs: dict[str, Any] = {"messages": self.state.get_chat_messages()} + if not enable_tool_calls: + run_kwargs["tools"] = None + if response_format: + run_kwargs["response_format"] = response_format + + agent_run_response: AgentRunResponse = await self._invoke_agent( + run_kwargs=run_kwargs, + correlation_id=correlation_id, + thread_id=thread_id, + request_message=message, + ) + + logger.debug( + "[AgentEntity.run_agent] Agent invocation completed - response type: %s", + type(agent_run_response).__name__, + ) + + response_text = None + structured_response = None + + response_str: str | None = None + try: + if response_format: + try: + response_str = agent_run_response.text + structured_response = json.loads(response_str) + logger.debug("Parsed structured JSON response") + except json.JSONDecodeError as decode_error: + logger.warning(f"Failed to parse JSON response: {decode_error}") + response_text = response_str + else: + raw_text = agent_run_response.text + response_text = raw_text if raw_text else "No response" + preview = response_text + logger.debug(f"Response: {preview[:100]}..." if len(preview) > 100 else f"Response: {preview}") + except Exception as extraction_error: + logger.error( + f"Error extracting response: {extraction_error}", + exc_info=True, + ) + response_text = "Error extracting response" + + agent_response = AgentResponse( + response=response_text, + message=str(message), + thread_id=str(thread_id), + status="success", + message_count=self.state.message_count, + structured_response=structured_response, + ) + result = agent_response.to_dict() + + content = json.dumps(structured_response) if structured_response else (response_text or "") + self.state.add_assistant_message(content, agent_run_response, correlation_id) + logger.debug("[AgentEntity.run_agent] AgentRunResponse stored in conversation history") + + return result + + except Exception as exc: + import traceback + + error_traceback = traceback.format_exc() + logger.error("[AgentEntity.run_agent] Agent execution failed") + logger.error(f"Error: {exc!s}") + logger.error(f"Error type: {type(exc).__name__}") + logger.error(f"Full traceback:\n{error_traceback}") + + error_response = AgentResponse( + response=f"Error: {exc!s}", + message=str(message), + thread_id=str(thread_id), + status="error", + message_count=self.state.message_count, + error=str(exc), + error_type=type(exc).__name__, + ) + return error_response.to_dict() + + async def _invoke_agent( + self, + run_kwargs: dict[str, Any], + correlation_id: str, + thread_id: str, + request_message: str, + ) -> AgentRunResponse: + """Execute the agent, preferring streaming when available.""" + callback_context: AgentCallbackContext | None = None + if self.callback is not None: + callback_context = self._build_callback_context( + correlation_id=correlation_id, + thread_id=thread_id, + request_message=request_message, + ) + + run_stream_callable = getattr(self.agent, "run_stream", None) + if callable(run_stream_callable): + try: + stream_candidate = run_stream_callable(**run_kwargs) + if inspect.isawaitable(stream_candidate): + stream_candidate = await stream_candidate + + return await self._consume_stream( + stream=cast(AsyncIterable[AgentRunResponseUpdate], stream_candidate), + callback_context=callback_context, + ) + except TypeError as type_error: + if "__aiter__" not in str(type_error): + raise + logger.debug( + "run_stream returned a non-async result; falling back to run(): %s", + type_error, + ) + except Exception as stream_error: + logger.warning( + "run_stream failed; falling back to run(): %s", + stream_error, + exc_info=True, + ) + else: + logger.debug("Agent does not expose run_stream; falling back to run().") + + agent_run_response = await self._invoke_non_stream(run_kwargs) + await self._notify_final_response(agent_run_response, callback_context) + return agent_run_response + + async def _consume_stream( + self, + stream: AsyncIterable[AgentRunResponseUpdate], + callback_context: AgentCallbackContext | None = None, + ) -> AgentRunResponse: + """Consume streaming responses and build the final AgentRunResponse.""" + updates: list[AgentRunResponseUpdate] = [] + + async for update in stream: + updates.append(update) + await self._notify_stream_update(update, callback_context) + + if updates: + response = AgentRunResponse.from_agent_run_response_updates(updates) + else: + logger.debug("[AgentEntity] No streaming updates received; creating empty response") + response = AgentRunResponse(messages=[]) + + await self._notify_final_response(response, callback_context) + return response + + async def _invoke_non_stream(self, run_kwargs: dict[str, Any]) -> AgentRunResponse: + """Invoke the agent without streaming support.""" + run_callable = getattr(self.agent, "run", None) + if run_callable is None or not callable(run_callable): + raise AttributeError("Agent does not implement run() method") + + result = run_callable(**run_kwargs) + if inspect.isawaitable(result): + result = await result + + if not isinstance(result, AgentRunResponse): + raise TypeError(f"Agent run() must return an AgentRunResponse instance; received {type(result).__name__}") + + return result + + async def _notify_stream_update( + self, + update: AgentRunResponseUpdate, + context: AgentCallbackContext | None, + ) -> None: + """Invoke the streaming callback if one is registered.""" + if self.callback is None or context is None: + return + + try: + callback_result = self.callback.on_streaming_response_update(update, context) + if inspect.isawaitable(callback_result): + await callback_result + except Exception as exc: + logger.warning( + "[AgentEntity] Streaming callback raised an exception: %s", + exc, + exc_info=True, + ) + + async def _notify_final_response( + self, + response: AgentRunResponse, + context: AgentCallbackContext | None, + ) -> None: + """Invoke the final response callback if one is registered.""" + if self.callback is None or context is None: + return + + try: + callback_result = self.callback.on_agent_response(response, context) + if inspect.isawaitable(callback_result): + await callback_result + except Exception as exc: + logger.warning( + "[AgentEntity] Response callback raised an exception: %s", + exc, + exc_info=True, + ) + + def _build_callback_context( + self, + correlation_id: str, + thread_id: str, + request_message: str, + ) -> AgentCallbackContext: + """Create the callback context provided to consumers.""" + agent_name = getattr(self.agent, "name", None) or type(self.agent).__name__ + return AgentCallbackContext( + agent_name=agent_name, + correlation_id=correlation_id, + thread_id=thread_id, + request_message=request_message, + ) + + def reset(self, context: df.DurableEntityContext) -> None: + """Reset the entity state (clear conversation history).""" + logger.debug("[AgentEntity.reset] Resetting entity state") + self.state.reset() + logger.debug("[AgentEntity.reset] State reset complete") + + +def create_agent_entity( + agent: AgentProtocol, + callback: AgentResponseCallbackProtocol | None = None, +) -> Callable[[df.DurableEntityContext], None]: + """Factory function to create an agent entity class. + + Args: + agent: The Microsoft Agent Framework agent instance (must implement AgentProtocol) + callback: Optional callback invoked during streaming and final responses + + Returns: + Entity function configured with the agent + """ + + async def _entity_coroutine(context: df.DurableEntityContext) -> None: + """Async handler that executes the entity operations.""" + try: + logger.debug("[entity_function] Entity triggered") + logger.debug(f"[entity_function] Operation: {context.operation_name}") + + current_state = context.get_state(lambda: None) + logger.debug("Retrieved state: %s", str(current_state)[:100]) + entity = AgentEntity(agent, callback) + + if current_state is not None: + entity.state.restore_state(current_state) + logger.debug( + "[entity_function] Restored entity from state (message_count: %s)", entity.state.message_count + ) + else: + logger.debug("[entity_function] Created new entity instance") + + operation = context.operation_name + + if operation == "run_agent": + input_data: Any = context.get_input() + + request: str | dict[str, Any] + if isinstance(input_data, dict) and "message" in input_data: + request = cast(dict[str, Any], input_data) + else: + # Fall back to treating input as message string + request = "" if input_data is None else str(cast(object, input_data)) + + result = await entity.run_agent(context, request) + context.set_result(result) + + elif operation == "reset": + entity.reset(context) + context.set_result({"status": "reset"}) + + else: + logger.error("[entity_function] Unknown operation: %s", operation) + context.set_result({"error": f"Unknown operation: {operation}"}) + + context.set_state(entity.state.to_dict()) + logger.debug(f"[entity_function] Operation {operation} completed successfully") + + except Exception as exc: + import traceback + + logger.error("[entity_function] Error in entity: %s", exc) + logger.error(f"[entity_function] Traceback:\n{traceback.format_exc()}") + context.set_result({"error": str(exc), "status": "error"}) + + def entity_function(context: df.DurableEntityContext) -> None: + """Synchronous wrapper invoked by the Durable Functions runtime.""" + try: + try: + loop = asyncio.get_event_loop() + except RuntimeError: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + if loop.is_running(): + temp_loop = asyncio.new_event_loop() + try: + temp_loop.run_until_complete(_entity_coroutine(context)) + finally: + temp_loop.close() + else: + loop.run_until_complete(_entity_coroutine(context)) + + except Exception as exc: # pragma: no cover - defensive logging + logger.error("[entity_function] Unexpected error executing entity: %s", exc, exc_info=True) + context.set_result({"error": str(exc), "status": "error"}) + + return entity_function diff --git a/python/packages/azurefunctions/agent_framework_azurefunctions/_errors.py b/python/packages/azurefunctions/agent_framework_azurefunctions/_errors.py new file mode 100644 index 0000000000..b2f96c7ebb --- /dev/null +++ b/python/packages/azurefunctions/agent_framework_azurefunctions/_errors.py @@ -0,0 +1,13 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Custom exception types for the durable agent framework.""" + +from __future__ import annotations + + +class IncomingRequestError(ValueError): + """Raised when an incoming HTTP request cannot be parsed or validated.""" + + def __init__(self, message: str, status_code: int = 400) -> None: + super().__init__(message) + self.status_code = status_code diff --git a/python/packages/azurefunctions/agent_framework_azurefunctions/_models.py b/python/packages/azurefunctions/agent_framework_azurefunctions/_models.py new file mode 100644 index 0000000000..015ca40754 --- /dev/null +++ b/python/packages/azurefunctions/agent_framework_azurefunctions/_models.py @@ -0,0 +1,395 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Data models for Durable Agent Framework. + +This module defines the request and response models used by the framework. +""" + +from __future__ import annotations + +import inspect +import uuid +from collections.abc import MutableMapping +from dataclasses import dataclass +from importlib import import_module +from typing import TYPE_CHECKING, Any, cast + +import azure.durable_functions as df +from agent_framework import AgentThread, Role + +if TYPE_CHECKING: # pragma: no cover - type checking imports only + from pydantic import BaseModel + +_PydanticBaseModel: type[BaseModel] | None + +try: + from pydantic import BaseModel as _RuntimeBaseModel +except ImportError: # pragma: no cover - optional dependency + _PydanticBaseModel = None +else: + _PydanticBaseModel = _RuntimeBaseModel + + +@dataclass +class AgentSessionId: + """Represents an agent session ID, which is used to identify a long-running agent session. + + Attributes: + name: The name of the agent that owns the session (case-insensitive) + key: The unique key of the agent session (case-sensitive) + """ + + name: str + key: str + + ENTITY_NAME_PREFIX: str = "dafx-" + + @staticmethod + def to_entity_name(name: str) -> str: + """Converts an agent name to an entity name by adding the DAFx prefix. + + Args: + name: The agent name + + Returns: + The entity name with the dafx- prefix + """ + return f"{AgentSessionId.ENTITY_NAME_PREFIX}{name}" + + @staticmethod + def with_random_key(name: str) -> AgentSessionId: + """Creates a new AgentSessionId with the specified name and a randomly generated key. + + Args: + name: The name of the agent that owns the session + + Returns: + A new AgentSessionId with the specified name and a random GUID key + """ + return AgentSessionId(name=name, key=uuid.uuid4().hex) + + def to_entity_id(self) -> df.EntityId: + """Converts this AgentSessionId to a Durable Functions EntityId. + + Returns: + EntityId for use with Durable Functions APIs + """ + return df.EntityId(self.to_entity_name(self.name), self.key) + + @staticmethod + def from_entity_id(entity_id: df.EntityId) -> AgentSessionId: + """Creates an AgentSessionId from a Durable Functions EntityId. + + Args: + entity_id: The EntityId to convert + + Returns: + AgentSessionId instance + + Raises: + ValueError: If the entity ID does not have the expected prefix + """ + if not entity_id.name.startswith(AgentSessionId.ENTITY_NAME_PREFIX): + raise ValueError( + f"'{entity_id}' is not a valid agent session ID. " + f"Expected entity name to start with '{AgentSessionId.ENTITY_NAME_PREFIX}'" + ) + + agent_name = entity_id.name[len(AgentSessionId.ENTITY_NAME_PREFIX) :] + return AgentSessionId(name=agent_name, key=entity_id.key) + + def __str__(self) -> str: + """Returns a string representation in the form @name@key.""" + return f"@{self.name}@{self.key}" + + def __repr__(self) -> str: + """Returns a detailed string representation.""" + return f"AgentSessionId(name='{self.name}', key='{self.key}')" + + @staticmethod + def parse(session_id_string: str) -> AgentSessionId: + """Parses a string representation of an agent session ID. + + Args: + session_id_string: A string in the form @name@key + + Returns: + AgentSessionId instance + + Raises: + ValueError: If the string format is invalid + """ + if not session_id_string.startswith("@"): + raise ValueError(f"Invalid agent session ID format: {session_id_string}") + + parts = session_id_string[1:].split("@", 1) + if len(parts) != 2: + raise ValueError(f"Invalid agent session ID format: {session_id_string}") + + return AgentSessionId(name=parts[0], key=parts[1]) + + +class DurableAgentThread(AgentThread): + """Durable agent thread that tracks the owning :class:`AgentSessionId`.""" + + _SERIALIZED_SESSION_ID_KEY = "durable_session_id" + + def __init__( + self, + *, + session_id: AgentSessionId | None = None, + service_thread_id: str | None = None, + message_store: Any = None, + context_provider: Any = None, + ) -> None: + super().__init__( + service_thread_id=service_thread_id, + message_store=message_store, + context_provider=context_provider, + ) + self._session_id: AgentSessionId | None = session_id + + @property + def session_id(self) -> AgentSessionId | None: + """Returns the durable agent session identifier for this thread.""" + return self._session_id + + def attach_session(self, session_id: AgentSessionId) -> None: + """Associates the thread with the provided :class:`AgentSessionId`.""" + self._session_id = session_id + + @classmethod + def from_session_id( + cls, + session_id: AgentSessionId, + *, + service_thread_id: str | None = None, + message_store: Any = None, + context_provider: Any = None, + ) -> DurableAgentThread: + """Creates a durable thread pre-associated with the supplied session ID.""" + return cls( + session_id=session_id, + service_thread_id=service_thread_id, + message_store=message_store, + context_provider=context_provider, + ) + + async def serialize(self, **kwargs: Any) -> dict[str, Any]: + """Serializes thread state including the durable session identifier.""" + state = await super().serialize(**kwargs) + if self._session_id is not None: + state[self._SERIALIZED_SESSION_ID_KEY] = str(self._session_id) + return state + + @classmethod + async def deserialize( + cls, + serialized_thread_state: MutableMapping[str, Any], + *, + message_store: Any = None, + **kwargs: Any, + ) -> DurableAgentThread: + """Restores a durable thread, rehydrating the stored session identifier.""" + state_payload = dict(serialized_thread_state) + session_id_value = state_payload.pop(cls._SERIALIZED_SESSION_ID_KEY, None) + thread = await super().deserialize( + state_payload, + message_store=message_store, + **kwargs, + ) + if not isinstance(thread, DurableAgentThread): + raise TypeError("Deserialized thread is not a DurableAgentThread instance") + + if session_id_value is None: + return thread + + if not isinstance(session_id_value, str): + raise ValueError("durable_session_id must be a string when present in serialized state") + + thread.attach_session(AgentSessionId.parse(session_id_value)) + return thread + + +def _serialize_response_format(response_format: type[BaseModel] | None) -> Any: + """Serialize response format for transport across durable function boundaries.""" + if response_format is None: + return None + + if _PydanticBaseModel is None: + raise RuntimeError("pydantic is required to use structured response formats") + + if not inspect.isclass(response_format) or not issubclass(response_format, _PydanticBaseModel): + raise TypeError("response_format must be a Pydantic BaseModel type") + + return { + "__response_schema_type__": "pydantic_model", + "module": response_format.__module__, + "qualname": response_format.__qualname__, + } + + +def _deserialize_response_format(response_format: Any) -> type[BaseModel] | None: + """Deserialize response format back into actionable type if possible.""" + if response_format is None: + return None + + if ( + _PydanticBaseModel is not None + and inspect.isclass(response_format) + and issubclass(response_format, _PydanticBaseModel) + ): + return response_format + + if not isinstance(response_format, dict): + return None + + response_dict = cast(dict[str, Any], response_format) + + if response_dict.get("__response_schema_type__") != "pydantic_model": + return None + + module_name = response_dict.get("module") + qualname = response_dict.get("qualname") + if not module_name or not qualname: + return None + + try: + module = import_module(module_name) + except ImportError: # pragma: no cover - user provided module missing + return None + + attr: Any = module + for part in qualname.split("."): + try: + attr = getattr(attr, part) + except AttributeError: # pragma: no cover - invalid qualname + return None + + if _PydanticBaseModel is not None and inspect.isclass(attr) and issubclass(attr, _PydanticBaseModel): + return attr + + return None + + +@dataclass +class RunRequest: + """Represents a request to run an agent with a specific message and configuration. + + Attributes: + message: The message to send to the agent + role: The role of the message sender (user, system, or assistant) + response_format: Optional Pydantic BaseModel type describing the structured response format + enable_tool_calls: Whether to enable tool calls for this request + thread_id: Optional thread ID for tracking + correlation_id: Optional correlation ID for tracking the response to this specific request + """ + + message: str + role: Role = Role.USER + response_format: type[BaseModel] | None = None + enable_tool_calls: bool = True + thread_id: str | None = None + correlation_id: str | None = None + + def __init__( + self, + message: str, + role: Role | str | None = Role.USER, + response_format: type[BaseModel] | None = None, + enable_tool_calls: bool = True, + thread_id: str | None = None, + correlation_id: str | None = None, + ) -> None: + self.message = message + self.role = self.coerce_role(role) + self.response_format = response_format + self.enable_tool_calls = enable_tool_calls + self.thread_id = thread_id + self.correlation_id = correlation_id + + @staticmethod + def coerce_role(value: Role | str | None) -> Role: + """Normalize various role representations into a Role instance.""" + if isinstance(value, Role): + return value + if isinstance(value, str): + normalized = value.strip() + if not normalized: + return Role.USER + return Role(value=normalized.lower()) + return Role.USER + + def to_dict(self) -> dict[str, Any]: + """Convert to dictionary for JSON serialization.""" + result = { + "message": self.message, + "enable_tool_calls": self.enable_tool_calls, + "role": self.role.value, + } + if self.response_format: + result["response_format"] = _serialize_response_format(self.response_format) + if self.thread_id: + result["thread_id"] = self.thread_id + if self.correlation_id: + result["correlation_id"] = self.correlation_id + return result + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> RunRequest: + """Create RunRequest from dictionary.""" + return cls( + message=data.get("message", ""), + role=cls.coerce_role(data.get("role")), + response_format=_deserialize_response_format(data.get("response_format")), + enable_tool_calls=data.get("enable_tool_calls", True), + thread_id=data.get("thread_id"), + correlation_id=data.get("correlation_id"), + ) + + +@dataclass +class AgentResponse: + """Response from agent execution. + + Attributes: + response: The agent's text response (or None for structured responses) + message: The original message sent to the agent + thread_id: The thread identifier + status: Status of the execution (success, error, etc.) + message_count: Number of messages in the conversation + error: Error message if status is error + error_type: Type of error if status is error + structured_response: Structured response if response_format was provided + """ + + response: str | None + message: str + thread_id: str | None + status: str + message_count: int = 0 + error: str | None = None + error_type: str | None = None + structured_response: dict[str, Any] | None = None + + def to_dict(self) -> dict[str, Any]: + """Convert to dictionary for JSON serialization.""" + result: dict[str, Any] = { + "message": self.message, + "thread_id": self.thread_id, + "status": self.status, + "message_count": self.message_count, + } + + # Add response or structured_response based on what's available + if self.structured_response is not None: + result["structured_response"] = self.structured_response + elif self.response is not None: + result["response"] = self.response + + if self.error: + result["error"] = self.error + if self.error_type: + result["error_type"] = self.error_type + + return result diff --git a/python/packages/azurefunctions/agent_framework_azurefunctions/_orchestration.py b/python/packages/azurefunctions/agent_framework_azurefunctions/_orchestration.py new file mode 100644 index 0000000000..2fd4522964 --- /dev/null +++ b/python/packages/azurefunctions/agent_framework_azurefunctions/_orchestration.py @@ -0,0 +1,211 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Orchestration Support for Durable Agents. + +This module provides support for using agents inside Durable Function orchestrations. +""" + +import uuid +from collections.abc import AsyncIterator +from typing import TYPE_CHECKING, Any, TypeAlias, cast + +from agent_framework import AgentProtocol, AgentRunResponseUpdate, AgentThread, ChatMessage, get_logger + +from ._models import AgentSessionId, DurableAgentThread, RunRequest + +logger = get_logger("agent_framework.azurefunctions.orchestration") + +if TYPE_CHECKING: + from azure.durable_functions import DurableOrchestrationContext as _DurableOrchestrationContext + + AgentOrchestrationContextType: TypeAlias = _DurableOrchestrationContext +else: + AgentOrchestrationContextType = Any + + +class DurableAIAgent(AgentProtocol): + """A durable agent implementation that uses entity methods to interact with agent entities. + + This class implements AgentProtocol and provides methods to work with Azure Durable Functions + orchestrations, which use generators and yield instead of async/await. + + Key methods: + - get_new_thread(): Create a new conversation thread + - run(): Execute the agent and return a Task for yielding in orchestrations + + Note: The run() method is NOT async. It returns a Task directly that must be + yielded in orchestrations to wait for the entity call to complete. + + Example usage in orchestration: + writer = app.get_agent(context, "WriterAgent") + thread = writer.get_new_thread() # NOT yielded - returns immediately + + response = yield writer.run( # Yielded - waits for entity call + message="Write a haiku about coding", + thread=thread + ) + """ + + def __init__(self, context: AgentOrchestrationContextType, agent_name: str): + """Initialize the DurableAIAgent. + + Args: + context: The orchestration context + agent_name: Name of the agent (used to construct entity ID) + """ + self.context = context + self.agent_name = agent_name + self._id = str(uuid.uuid4()) + self._name = agent_name + self._display_name = agent_name + self._description = f"Durable agent proxy for {agent_name}" + logger.debug(f"[DurableAIAgent] Initialized for agent: {agent_name}") + + @property + def id(self) -> str: + """Get the unique identifier for this agent.""" + return self._id + + @property + def name(self) -> str | None: + """Get the name of the agent.""" + return self._name + + @property + def display_name(self) -> str: + """Get the display name of the agent.""" + return self._display_name + + @property + def description(self) -> str | None: + """Get the description of the agent.""" + return self._description + + def run( + self, + messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None, + *, + thread: AgentThread | None = None, + **kwargs: Any, + ) -> Any: # TODO(msft-team): Add a wrapper to respond correctly with `AgentRunResponse` + """Execute the agent with messages and return a Task for orchestrations. + + This method implements AgentProtocol and returns a Task that can be yielded + in Durable Functions orchestrations. + + Args: + messages: The message(s) to send to the agent + thread: Optional agent thread for conversation context + **kwargs: Additional arguments (enable_tool_calls, response_format, etc.) + + Returns: + Task that will resolve to the agent response + + Example: + @app.orchestration_trigger(context_name="context") + def my_orchestration(context): + agent = app.get_agent(context, "MyAgent") + thread = agent.get_new_thread() + result = yield agent.run("Hello", thread=thread) + """ + message_str = self._normalize_messages(messages) + + # Extract optional parameters from kwargs + enable_tool_calls = kwargs.get("enable_tool_calls", True) + response_format = kwargs.get("response_format") + + # Get the session ID for the entity + if isinstance(thread, DurableAgentThread) and thread.session_id is not None: + session_id = thread.session_id + else: + # Create a unique session ID for each call when no thread is provided + # This ensures each call gets its own conversation context + session_key = str(self.context.new_uuid()) + session_id = AgentSessionId(name=self.agent_name, key=session_key) + logger.warning(f"[DurableAIAgent] No thread provided, created unique session_id: {session_id}") + + # Create entity ID from session ID + entity_id = session_id.to_entity_id() + + # Generate a deterministic correlation ID for this call + # This is required by the entity and must be unique per call + correlation_id = str(self.context.new_uuid()) + + # Prepare the request using RunRequest model + run_request = RunRequest( + message=message_str, + enable_tool_calls=enable_tool_calls, + correlation_id=correlation_id, + thread_id=session_id.key, + response_format=response_format, + ) + + logger.debug(f"[DurableAIAgent] Calling entity {entity_id} with message: {message_str[:100]}...") + + # Call the entity and return the Task directly + # The orchestration will yield this Task + return self.context.call_entity(entity_id, "run_agent", run_request.to_dict()) + + def run_stream( + self, + messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None, + *, + thread: AgentThread | None = None, + **kwargs: Any, + ) -> AsyncIterator[AgentRunResponseUpdate]: + """Run the agent with streaming (not supported for durable agents). + + Raises: + NotImplementedError: Streaming is not supported for durable agents. + """ + raise NotImplementedError("Streaming is not supported for durable agents in orchestrations.") + + def get_new_thread(self, **kwargs: Any) -> AgentThread: + """Create a new agent thread for this orchestration instance. + + Each call creates a unique thread with its own conversation context. + The session ID is deterministic (uses context.new_uuid()) to ensure + orchestration replay works correctly. + + Returns: + A new AgentThread instance with a unique session ID + """ + # Generate a deterministic unique key for this thread + # Using context.new_uuid() ensures the same GUID is generated during replay + session_key = str(self.context.new_uuid()) + + # Create AgentSessionId with agent name and session key + session_id = AgentSessionId(name=self.agent_name, key=session_key) + + thread = DurableAgentThread.from_session_id(session_id, **kwargs) + + logger.debug(f"[DurableAIAgent] Created new thread with session_id: {session_id}") + return thread + + def _messages_to_string(self, messages: list[ChatMessage]) -> str: + """Convert a list of ChatMessage objects to a single string. + + Args: + messages: List of ChatMessage objects + + Returns: + Concatenated string of message contents + """ + return "\n".join([msg.text or "" for msg in messages]) + + def _normalize_messages(self, messages: str | ChatMessage | list[str] | list[ChatMessage] | None) -> str: + """Convert supported message inputs to a single string.""" + if messages is None: + return "" + if isinstance(messages, str): + return messages + if isinstance(messages, ChatMessage): + return messages.text or "" + if isinstance(messages, list): + if not messages: + return "" + first_item = messages[0] + if isinstance(first_item, str): + return "\n".join(cast(list[str], messages)) + return self._messages_to_string(cast(list[ChatMessage], messages)) + return str(messages) diff --git a/python/packages/azurefunctions/agent_framework_azurefunctions/_state.py b/python/packages/azurefunctions/agent_framework_azurefunctions/_state.py new file mode 100644 index 0000000000..c9d54b8333 --- /dev/null +++ b/python/packages/azurefunctions/agent_framework_azurefunctions/_state.py @@ -0,0 +1,179 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Agent State Management. + +This module defines the AgentState class for managing conversation state and +serializing agent framework responses. +""" + +from collections.abc import MutableMapping +from datetime import datetime, timezone +from typing import Any, cast + +from agent_framework import AgentRunResponse, ChatMessage, Role, get_logger + +logger = get_logger("agent_framework.azurefunctions.state") + + +class AgentState: + """Manages agent conversation state using agent_framework types (ChatMessage, AgentRunResponse). + + This class handles: + - Conversation history tracking using ChatMessage objects + - Agent response storage using AgentRunResponse objects with correlation IDs + - State persistence and restoration + - Message counting + """ + + def __init__(self) -> None: + """Initialize empty agent state.""" + self.conversation_history: list[ChatMessage] = [] + self.last_response: str | None = None + self.message_count: int = 0 + + def _current_timestamp(self) -> str: + """Return an ISO 8601 UTC timestamp.""" + return datetime.now(timezone.utc).isoformat() + + def add_user_message( + self, + content: str, + role: Role = Role.USER, + correlation_id: str | None = None, + ) -> None: + """Add a user message to the conversation history as a ChatMessage object. + + Args: + content: The message content + role: The message role (user, system, etc.) + correlation_id: Optional correlation identifier associated with the user message + """ + self.message_count += 1 + timestamp = self._current_timestamp() + additional_props: MutableMapping[str, Any] = {"timestamp": timestamp} + if correlation_id is not None: + additional_props["correlation_id"] = correlation_id + chat_message = ChatMessage(role=role, text=content, additional_properties=additional_props) + self.conversation_history.append(chat_message) + logger.debug(f"Added {role} ChatMessage to history (message #{self.message_count})") + + def add_assistant_message( + self, content: str, agent_response: AgentRunResponse, correlation_id: str | None = None + ) -> None: + """Add an assistant message to the conversation history with full agent response. + + Args: + content: The text content of the response + agent_response: The AgentRunResponse object from the agent framework + correlation_id: Optional correlation ID for tracking this response + """ + self.last_response = content + timestamp = self._current_timestamp() + serialized_response = self.serialize_response(agent_response) + + # Create a ChatMessage for the assistant response + # The agent_response already contains messages, but we store it as a custom ChatMessage + # with the agent_response stored in additional_properties for full metadata preservation + additional_props: dict[str, Any] = { + "agent_response": serialized_response, + "correlation_id": correlation_id, + "timestamp": timestamp, + "message_count": self.message_count, + } + chat_message = ChatMessage(role="assistant", text=content, additional_properties=additional_props) + + self.conversation_history.append(chat_message) + + logger.debug( + f"Added assistant ChatMessage to history with AgentRunResponse metadata (correlation_id: {correlation_id})" + ) + + def get_chat_messages(self) -> list[ChatMessage]: + """Return a copy of the full conversation history.""" + return list(self.conversation_history) + + def try_get_agent_response(self, correlation_id: str) -> dict[str, Any] | None: + """Get an agent response by correlation ID. + + Args: + correlation_id: The correlation ID to look up + + Returns: + The agent response data if found, None otherwise + """ + for message in reversed(self.conversation_history): + metadata = getattr(message, "additional_properties", {}) or {} + if metadata.get("correlation_id") == correlation_id: + return self._build_agent_response_payload(message, metadata) + + return None + + def serialize_response(self, response: AgentRunResponse) -> dict[str, Any]: + """Serialize an ``AgentRunResponse`` to a dictionary. + + Args: + response: The agent framework response object + + Returns: + Dictionary containing all response fields + """ + try: + return response.to_dict() + except Exception as exc: # pragma: no cover - defensive logging path + logger.warning(f"Error serializing response: {exc}") + return {"response": str(response), "serialization_error": str(exc)} + + def to_dict(self) -> dict[str, Any]: + """Get the current state as a dictionary for persistence. + + Returns: + Dictionary containing conversation_history (as serialized ChatMessages), + last_response, and message_count + """ + return { + "conversation_history": [msg.to_dict() for msg in self.conversation_history], + "last_response": self.last_response, + "message_count": self.message_count, + } + + def restore_state(self, state: dict[str, Any]) -> None: + """Restore state from a dictionary, reconstructing ChatMessage objects. + + Args: + state: Dictionary containing conversation_history, last_response, and message_count + """ + # Restore conversation history as ChatMessage objects + history_data = state.get("conversation_history", []) + restored_history: list[ChatMessage] = [] + for raw_message in history_data: + if isinstance(raw_message, dict): + restored_history.append(ChatMessage.from_dict(cast(dict[str, Any], raw_message))) + else: + restored_history.append(cast(ChatMessage, raw_message)) + + self.conversation_history = restored_history + + self.last_response = state.get("last_response") + self.message_count = state.get("message_count", 0) + logger.debug("Restored state: %s ChatMessages in history", len(self.conversation_history)) + + def reset(self) -> None: + """Reset the state to empty.""" + self.conversation_history = [] + self.last_response = None + self.message_count = 0 + logger.debug("State reset to empty") + + def __repr__(self) -> str: + """String representation of the state.""" + return f"AgentState(messages={self.message_count}, history_length={len(self.conversation_history)})" + + def _build_agent_response_payload(self, message: ChatMessage, metadata: dict[str, Any]) -> dict[str, Any]: + """Construct the agent response payload returned to callers.""" + return { + "content": message.text, + "agent_response": metadata.get("agent_response"), + "message_count": metadata.get("message_count", self.message_count), + "timestamp": metadata.get("timestamp"), + "correlation_id": metadata.get("correlation_id"), + } diff --git a/python/packages/azurefunctions/pyproject.toml b/python/packages/azurefunctions/pyproject.toml new file mode 100644 index 0000000000..a77fd4225c --- /dev/null +++ b/python/packages/azurefunctions/pyproject.toml @@ -0,0 +1,92 @@ +[project] +name = "agent-framework-azurefunctions" +description = "Azure Functions integration for Microsoft Agent Framework." +authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] +readme = "README.md" +requires-python = ">=3.10" +version = "1.0.0b251112.post1" +license-files = ["LICENSE"] +urls.homepage = "https://aka.ms/agent-framework" +urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" +urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true" +urls.issues = "https://github.com/microsoft/agent-framework/issues" +classifiers = [ + "License :: OSI Approved :: MIT License", + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Typing :: Typed", +] +dependencies = [ + "agent-framework-core", + "azure-functions", + "azure-functions-durable", +] + +[tool.uv] +prerelease = "if-necessary-or-explicit" +environments = [ + "sys_platform == 'darwin'", + "sys_platform == 'linux'", + "sys_platform == 'win32'" +] + +[tool.uv-dynamic-versioning] +fallback-version = "0.0.0" +[tool.pytest.ini_options] +testpaths = 'tests' +addopts = "-ra -q -r fEX" +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +filterwarnings = [ + "ignore:Support for class-based `config` is deprecated:DeprecationWarning:pydantic.*" +] +timeout = 120 +markers = [ + "integration: marks tests as integration tests (require running function app)", + "orchestration: marks tests that use orchestrations (require Azurite)", +] + +[tool.ruff] +extend = "../../pyproject.toml" + +[tool.coverage.run] +omit = [ + "**/__init__.py" +] + +[tool.pyright] +extends = "../../pyproject.toml" + +[tool.mypy] +plugins = ['pydantic.mypy'] +strict = true +python_version = "3.10" +ignore_missing_imports = true +disallow_untyped_defs = true +no_implicit_optional = true +check_untyped_defs = true +warn_return_any = true +show_error_codes = true +warn_unused_ignores = false +disallow_incomplete_defs = true +disallow_untyped_decorators = true + +[tool.bandit] +targets = ["agent_framework_azurefunctions"] +exclude_dirs = ["tests"] + +[tool.poe] +executor.type = "uv" +include = "../../shared_tasks.toml" +[tool.poe.tasks] +mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_azurefunctions" +test = "pytest --cov=agent_framework_azurefunctions --cov-report=term-missing:skip-covered tests" + +[build-system] +requires = ["flit-core >= 3.11,<4.0"] +build-backend = "flit_core.buildapi" diff --git a/python/packages/azurefunctions/tests/integration_tests/.env.example b/python/packages/azurefunctions/tests/integration_tests/.env.example new file mode 100644 index 0000000000..a8dc5d88b4 --- /dev/null +++ b/python/packages/azurefunctions/tests/integration_tests/.env.example @@ -0,0 +1,11 @@ +# Azure OpenAI Configuration +AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com/ +AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=your-deployment-name +FUNCTIONS_WORKER_RUNTIME=python +RUN_INTEGRATION_TESTS=true + +# Azure Functions Configuration +AzureWebJobsStorage=UseDevelopmentStorage=true +DURABLE_TASK_SCHEDULER_CONNECTION_STRING=Endpoint=http://localhost:8080;Authentication=None + +# Note: TASKHUB_NAME is not required for integration tests; it is auto-generated per test run. diff --git a/python/packages/azurefunctions/tests/integration_tests/README.md b/python/packages/azurefunctions/tests/integration_tests/README.md new file mode 100644 index 0000000000..d9ecb86234 --- /dev/null +++ b/python/packages/azurefunctions/tests/integration_tests/README.md @@ -0,0 +1,81 @@ +# Sample Integration Tests + +Integration tests that validate the Durable Agent Framework samples by running them as Azure Functions. + +## Setup + +### 1. Create `.env` file + +Copy `.env.example` to `.env` and fill in your Azure credentials: + +```bash +cp .env.example .env +``` + +Required variables: +- `AZURE_OPENAI_ENDPOINT` +- `AZURE_OPENAI_CHAT_DEPLOYMENT_NAME` +- `AZURE_OPENAI_API_KEY` +- `AzureWebJobsStorage` +- `DURABLE_TASK_SCHEDULER_CONNECTION_STRING` +- `FUNCTIONS_WORKER_RUNTIME` + +### 2. Start required services + +**Azurite (for orchestration tests):** +```bash +docker run -d -p 10000:10000 -p 10001:10001 -p 10002:10002 mcr.microsoft.com/azure-storage/azurite +``` + +**Durable Task Scheduler:** +```bash +docker run -d -p 8080:8080 -p 8082:8082 mcr.microsoft.com/dts/dts-emulator:latest +``` + +## Running Tests + +The tests automatically start and stop the Azure Functions app for each sample. + +### Run all sample tests +```bash +uv run pytest packages/azurefunctions/tests/integration_tests -v +``` + +### Run specific sample +```bash +uv run pytest packages/azurefunctions/tests/integration_tests/test_01_single_agent.py -v +``` + +### Run with verbose output +```bash +uv run pytest packages/azurefunctions/tests/integration_tests -sv +``` + +## How It Works + +Each test file uses pytest markers to automatically configure and start the function app: + +```python +pytestmark = [ + pytest.mark.sample("01_single_agent"), + pytest.mark.usefixtures("function_app_for_test"), + skip_if_azure_functions_integration_tests_disabled, +] +``` + +The `function_app_for_test` fixture: +1. Loads environment variables from `.env` +2. Validates required variables are present +3. Starts the function app on a dynamically allocated port +4. Waits for the app to be ready +5. Runs your tests +6. Tears down the function app + +## Troubleshooting + + +**Missing environment variables:** +Ensure your `.env` file contains all required variables from `.env.example`. + +**Tests timeout:** +Check that Azure OpenAI credentials are valid and the service is accessible. diff --git a/python/packages/ag-ui/agent_framework_ag_ui_examples/server/api/__init__.py b/python/packages/azurefunctions/tests/integration_tests/__init__.py similarity index 53% rename from python/packages/ag-ui/agent_framework_ag_ui_examples/server/api/__init__.py rename to python/packages/azurefunctions/tests/integration_tests/__init__.py index e50a96d510..2a50eae894 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui_examples/server/api/__init__.py +++ b/python/packages/azurefunctions/tests/integration_tests/__init__.py @@ -1,3 +1 @@ # Copyright (c) Microsoft. All rights reserved. - -"""API endpoints for AG-UI examples.""" diff --git a/python/packages/azurefunctions/tests/integration_tests/conftest.py b/python/packages/azurefunctions/tests/integration_tests/conftest.py new file mode 100644 index 0000000000..e2f19d6037 --- /dev/null +++ b/python/packages/azurefunctions/tests/integration_tests/conftest.py @@ -0,0 +1,121 @@ +# Copyright (c) Microsoft. All rights reserved. +""" +Pytest configuration for Durable Agent Framework tests. + +This module provides fixtures and configuration for pytest. +""" + +import subprocess +from collections.abc import Iterator, Mapping +from typing import Any + +import pytest +import requests + +from .testutils import ( + FunctionAppStartupError, + build_base_url, + cleanup_function_app, + find_available_port, + get_sample_path_from_marker, + load_and_validate_env, + start_function_app, + wait_for_function_app_ready, +) + + +def pytest_configure(config: pytest.Config) -> None: + """Register custom markers.""" + config.addinivalue_line("markers", "orchestration: marks tests that use orchestrations (require Azurite)") + config.addinivalue_line( + "markers", + "sample(path): specify the sample directory path for the test (e.g., @pytest.mark.sample('01_single_agent'))", + ) + + +@pytest.fixture(scope="session") +def function_app_running() -> bool: + """ + Check if the function app is running on localhost:7071. + + This fixture can be used to skip tests if the function app is not available. + """ + try: + response = requests.get("http://localhost:7071/api/health", timeout=2) + return response.status_code == 200 + except requests.exceptions.RequestException: + return False + + +@pytest.fixture(scope="session") +def skip_if_no_function_app(function_app_running: bool) -> None: + """Skip test if function app is not running.""" + if not function_app_running: + pytest.skip("Function app is not running on http://localhost:7071") + + +@pytest.fixture(scope="module") +def function_app_for_test(request: pytest.FixtureRequest) -> Iterator[dict[str, int | str]]: + """ + Start the function app for the corresponding sample based on marker. + + This fixture: + 1. Determines which sample to run from @pytest.mark.sample() + 2. Validates environment variables + 3. Starts the function app using 'func start' + 4. Waits for the app to be ready + 5. Tears down the app after tests complete + + Usage: + @pytest.mark.sample("01_single_agent") + @pytest.mark.usefixtures("function_app_for_test") + class TestSample01SingleAgent: + ... + """ + # Get sample path from marker + sample_path, error_message = get_sample_path_from_marker(request) + if error_message: + pytest.fail(error_message) + + assert sample_path is not None, "Sample path must be resolved before starting the function app" + + # Load .env file if it exists and validate required env vars + load_and_validate_env() + + max_attempts = 3 + last_error: Exception | None = None + func_process: subprocess.Popen[Any] | None = None + base_url = "" + port = 0 + + for _ in range(max_attempts): + port = find_available_port() + base_url = build_base_url(port) + func_process = start_function_app(sample_path, port) + + try: + wait_for_function_app_ready(func_process, port) + last_error = None + break + except FunctionAppStartupError as exc: + last_error = exc + cleanup_function_app(func_process) + func_process = None + + if func_process is None: + error_message = f"Function app failed to start after {max_attempts} attempt(s)." + if last_error is not None: + error_message += f" Last error: {last_error}" + pytest.fail(error_message) + + try: + yield {"base_url": base_url, "port": port} + finally: + if func_process is not None: + cleanup_function_app(func_process) + + +@pytest.fixture(scope="module") +def base_url(function_app_for_test: Mapping[str, int | str]) -> str: + """Expose the function app's base URL to tests.""" + return str(function_app_for_test["base_url"]) diff --git a/python/packages/azurefunctions/tests/integration_tests/test_01_single_agent.py b/python/packages/azurefunctions/tests/integration_tests/test_01_single_agent.py new file mode 100644 index 0000000000..f97916825c --- /dev/null +++ b/python/packages/azurefunctions/tests/integration_tests/test_01_single_agent.py @@ -0,0 +1,114 @@ +# Copyright (c) Microsoft. All rights reserved. +""" +Integration Tests for Single Agent Sample + +Tests the single agent sample with various message formats and session management. + +The function app is automatically started by the test fixture. + +Prerequisites: +- Azure OpenAI credentials configured (see packages/azurefunctions/tests/integration_tests/.env.example) +- Azurite or Azure Storage account configured + +Usage: + uv run pytest packages/azurefunctions/tests/integration_tests/test_01_single_agent.py -v +""" + +import pytest + +from .testutils import SampleTestHelper, skip_if_azure_functions_integration_tests_disabled + +# Module-level markers - applied to all tests in this file +pytestmark = [ + pytest.mark.sample("01_single_agent"), + pytest.mark.usefixtures("function_app_for_test"), + skip_if_azure_functions_integration_tests_disabled, +] + + +class TestSampleSingleAgent: + """Tests for 01_single_agent sample.""" + + @pytest.fixture(autouse=True) + def _set_base_url(self, base_url: str) -> None: + """Provide agent-specific base URL for the tests.""" + self.base_url = f"{base_url}/api/agents/Joker" + + def test_health_check(self, base_url: str) -> None: + """Test health check endpoint.""" + response = SampleTestHelper.get(f"{base_url}/api/health") + assert response.status_code == 200 + data = response.json() + assert data["status"] == "healthy" + + def test_simple_message_json(self) -> None: + """Test sending a simple message with JSON payload.""" + response = SampleTestHelper.post_json( + f"{self.base_url}/run", + {"message": "Tell me a short joke about cloud computing.", "thread_id": "test-simple-json"}, + ) + # Agent can return 200 (immediate) or 202 (async with wait_for_response=false) + assert response.status_code in [200, 202] + data = response.json() + + if response.status_code == 200: + # Synchronous response - check result directly + assert data["status"] == "success" + assert "response" in data + assert data["message_count"] >= 1 + else: + # Async response - check we got correlation info + assert "correlation_id" in data or "thread_id" in data + + def test_simple_message_plain_text(self) -> None: + """Test sending a message with plain text payload.""" + response = SampleTestHelper.post_text(f"{self.base_url}/run", "Tell me a short joke about networking.") + assert response.status_code in [200, 202] + + # Agent responded with plain text when the request body was text/plain. + assert response.text.strip() + assert response.headers.get("x-ms-thread-id") is not None + + def test_thread_id_in_query(self) -> None: + """Test using thread_id in query parameter.""" + response = SampleTestHelper.post_text( + f"{self.base_url}/run?thread_id=test-query-thread", "Tell me a short joke about weather in Texas." + ) + assert response.status_code in [200, 202] + + assert response.text.strip() + assert response.headers.get("x-ms-thread-id") == "test-query-thread" + + def test_conversation_continuity(self) -> None: + """Test conversation context is maintained across requests.""" + thread_id = "test-continuity" + + # First message + response1 = SampleTestHelper.post_json( + f"{self.base_url}/run", + {"message": "Tell me a short joke about weather in Seattle.", "thread_id": thread_id}, + ) + assert response1.status_code in [200, 202] + + if response1.status_code == 200: + data1 = response1.json() + assert data1["message_count"] == 1 + + # Second message in same session + response2 = SampleTestHelper.post_json( + f"{self.base_url}/run", {"message": "What about San Francisco?", "thread_id": thread_id} + ) + assert response2.status_code == 200 + data2 = response2.json() + assert data2["message_count"] == 2 + else: + # In async mode, we can't easily test message count + # Just verify we can make multiple calls + response2 = SampleTestHelper.post_json( + f"{self.base_url}/run", {"message": "What about Texas?", "thread_id": thread_id} + ) + assert response2.status_code == 202 + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/python/packages/azurefunctions/tests/integration_tests/test_02_multi_agent.py b/python/packages/azurefunctions/tests/integration_tests/test_02_multi_agent.py new file mode 100644 index 0000000000..f473a2be11 --- /dev/null +++ b/python/packages/azurefunctions/tests/integration_tests/test_02_multi_agent.py @@ -0,0 +1,64 @@ +# Copyright (c) Microsoft. All rights reserved. +""" +Integration Tests for Multi-Agent Sample + +Tests the multi-agent sample with different agent endpoints. + +The function app is automatically started by the test fixture. + +Prerequisites: +- Azure OpenAI credentials configured (see packages/azurefunctions/tests/integration_tests/.env.example) +- Azurite or Azure Storage account configured + +Usage: + uv run pytest packages/azurefunctions/tests/integration_tests/test_02_multi_agent.py -v +""" + +import pytest + +from .testutils import SampleTestHelper, skip_if_azure_functions_integration_tests_disabled + +# Module-level markers - applied to all tests in this file +pytestmark = [ + pytest.mark.sample("02_multi_agent"), + pytest.mark.usefixtures("function_app_for_test"), + skip_if_azure_functions_integration_tests_disabled, +] + + +class TestSampleMultiAgent: + """Tests for 02_multi_agent sample.""" + + @pytest.fixture(autouse=True) + def _set_agent_urls(self, base_url: str) -> None: + """Configure base URLs for Weather and Math agents.""" + self.weather_base_url = f"{base_url}/api/agents/WeatherAgent" + self.math_base_url = f"{base_url}/api/agents/MathAgent" + + def test_weather_agent(self) -> None: + """Test WeatherAgent endpoint.""" + response = SampleTestHelper.post_json( + f"{self.weather_base_url}/run", + {"message": "What is the weather in Seattle?"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["status"] == "success" + assert "response" in data + + def test_math_agent(self) -> None: + """Test MathAgent endpoint.""" + response = SampleTestHelper.post_json( + f"{self.math_base_url}/run", + {"message": "Calculate a 20% tip on a $50 bill", "wait_for_response": False}, + ) + assert response.status_code == 202 + data = response.json() + + assert data["status"] == "accepted" + assert "correlation_id" in data + assert "thread_id" in data + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/python/packages/azurefunctions/tests/integration_tests/test_03_callbacks.py b/python/packages/azurefunctions/tests/integration_tests/test_03_callbacks.py new file mode 100644 index 0000000000..06414f993a --- /dev/null +++ b/python/packages/azurefunctions/tests/integration_tests/test_03_callbacks.py @@ -0,0 +1,102 @@ +# Copyright (c) Microsoft. All rights reserved. +""" +Integration Tests for Callbacks Sample + +Tests the callbacks sample for event tracking and management. + +The function app is automatically started by the test fixture. + +Prerequisites: +- Azure OpenAI credentials configured (see packages/azurefunctions/tests/integration_tests/.env.example) +- Azurite or Azure Storage account configured + +Usage: + uv run pytest packages/azurefunctions/tests/integration_tests/test_03_callbacks.py -v +""" + +from typing import Any + +import pytest +import requests + +from .testutils import ( + TIMEOUT, + SampleTestHelper, + skip_if_azure_functions_integration_tests_disabled, +) + +# Module-level markers - applied to all tests in this file +pytestmark = [ + pytest.mark.sample("03_callbacks"), + pytest.mark.usefixtures("function_app_for_test"), + skip_if_azure_functions_integration_tests_disabled, +] + + +class TestSampleCallbacks: + """Tests for 03_callbacks sample.""" + + @pytest.fixture(autouse=True) + def _set_base_url(self, base_url: str) -> None: + """Provide the callback agent base URL for each test.""" + self.base_url = f"{base_url}/api/agents/CallbackAgent" + + @staticmethod + def _wait_for_callback_events(base_url: str, thread_id: str) -> list[dict[str, Any]]: + events: list[dict[str, Any]] = [] + response = SampleTestHelper.get(f"{base_url}/callbacks/{thread_id}") + if response.status_code == 200: + events = response.json() + return events + + def test_agent_with_callbacks(self) -> None: + """Test agent execution with callback tracking.""" + thread_id = "test-callback" + + response = SampleTestHelper.post_json( + f"{self.base_url}/run", + {"message": "Tell me about Python", "thread_id": thread_id}, + ) + assert response.status_code == 200 + data = response.json() + + assert data["status"] == "success" + + events = self._wait_for_callback_events(self.base_url, thread_id) + + assert events + assert any(event.get("event_type") == "final" for event in events) + + def test_get_callbacks(self) -> None: + """Test retrieving callback events.""" + thread_id = "test-callback-retrieve" + + # Send a message first + SampleTestHelper.post_json( + f"{self.base_url}/run", + {"message": "Hello", "thread_id": thread_id, "wait_for_response": False}, + ) + + # Get callbacks + response = SampleTestHelper.get(f"{self.base_url}/callbacks/{thread_id}") + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + + def test_delete_callbacks(self) -> None: + """Test clearing callback events.""" + thread_id = "test-callback-delete" + + # Send a message first + SampleTestHelper.post_json( + f"{self.base_url}/run", + {"message": "Test", "thread_id": thread_id, "wait_for_response": False}, + ) + + # Delete callbacks + response = requests.delete(f"{self.base_url}/callbacks/{thread_id}", timeout=TIMEOUT) + assert response.status_code == 204 + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/python/packages/azurefunctions/tests/integration_tests/test_04_single_agent_orchestration_chaining.py b/python/packages/azurefunctions/tests/integration_tests/test_04_single_agent_orchestration_chaining.py new file mode 100644 index 0000000000..e4bb1cd930 --- /dev/null +++ b/python/packages/azurefunctions/tests/integration_tests/test_04_single_agent_orchestration_chaining.py @@ -0,0 +1,53 @@ +# Copyright (c) Microsoft. All rights reserved. +""" +Integration Tests for Orchestration Chaining Sample + +Tests the orchestration chaining sample for sequential agent execution. + +The function app is automatically started by the test fixture. + +Prerequisites: +- Azure OpenAI credentials configured (see packages/azurefunctions/tests/integration_tests/.env.example) +- Azurite running for durable orchestrations (or Azure Storage account configured) + +Usage: + # Start Azurite (if not already running) + azurite & + + # Run tests + uv run pytest packages/azurefunctions/tests/integration_tests/test_04_single_agent_orchestration_chaining.py -v +""" + +import pytest + +from .testutils import SampleTestHelper, skip_if_azure_functions_integration_tests_disabled + +# Module-level markers - applied to all tests in this file +pytestmark = [ + pytest.mark.sample("04_single_agent_orchestration_chaining"), + pytest.mark.usefixtures("function_app_for_test"), + skip_if_azure_functions_integration_tests_disabled, +] + + +@pytest.mark.orchestration +class TestSampleOrchestrationChaining: + """Tests for 04_single_agent_orchestration_chaining sample.""" + + def test_orchestration_chaining(self, base_url: str) -> None: + """Test sequential agent calls in orchestration.""" + # Start orchestration + response = SampleTestHelper.post_json(f"{base_url}/api/singleagent/run", {}) + assert response.status_code == 202 + data = response.json() + assert "instanceId" in data + assert "statusQueryGetUri" in data + + # Wait for completion with output available + status = SampleTestHelper.wait_for_orchestration_with_output(data["statusQueryGetUri"]) + assert status["runtimeStatus"] == "Completed" + assert "output" in status + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/python/packages/azurefunctions/tests/integration_tests/test_05_multi_agent_orchestration_concurrency.py b/python/packages/azurefunctions/tests/integration_tests/test_05_multi_agent_orchestration_concurrency.py new file mode 100644 index 0000000000..aac8f361c6 --- /dev/null +++ b/python/packages/azurefunctions/tests/integration_tests/test_05_multi_agent_orchestration_concurrency.py @@ -0,0 +1,55 @@ +# Copyright (c) Microsoft. All rights reserved. +""" +Integration Tests for MultiAgent Concurrency Sample + +Tests the multi-agent concurrency sample for parallel agent execution. + +The function app is automatically started by the test fixture. + +Prerequisites: +- Azure OpenAI credentials configured (see packages/azurefunctions/tests/integration_tests/.env.example) +- Azurite running for durable orchestrations (or Azure Storage account configured) + +Usage: + # Start Azurite (if not already running) + azurite & + + # Run tests + uv run pytest packages/azurefunctions/tests/integration_tests/test_05_multi_agent_orchestration_concurrency.py -v +""" + +import pytest + +from .testutils import SampleTestHelper, skip_if_azure_functions_integration_tests_disabled + +# Module-level markers - applied to all tests in this file +pytestmark = [ + pytest.mark.orchestration, + pytest.mark.sample("05_multi_agent_orchestration_concurrency"), + pytest.mark.usefixtures("function_app_for_test"), + skip_if_azure_functions_integration_tests_disabled, +] + + +class TestSampleMultiAgentConcurrency: + """Tests for 05_multi_agent_orchestration_concurrency sample.""" + + def test_concurrent_agents(self, base_url: str) -> None: + """Test multiple agents running concurrently.""" + # Start orchestration + response = SampleTestHelper.post_text(f"{base_url}/api/multiagent/run", "What is temperature?") + assert response.status_code == 202 + data = response.json() + assert "instanceId" in data + assert "statusQueryGetUri" in data + + # Wait for completion + status = SampleTestHelper.wait_for_orchestration(data["statusQueryGetUri"]) + assert status["runtimeStatus"] == "Completed" + output = status["output"] + assert "physicist" in output + assert "chemist" in output + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/python/packages/azurefunctions/tests/integration_tests/test_06_multi_agent_orchestration_conditionals.py b/python/packages/azurefunctions/tests/integration_tests/test_06_multi_agent_orchestration_conditionals.py new file mode 100644 index 0000000000..d7f13777bb --- /dev/null +++ b/python/packages/azurefunctions/tests/integration_tests/test_06_multi_agent_orchestration_conditionals.py @@ -0,0 +1,73 @@ +# Copyright (c) Microsoft. All rights reserved. +""" +Integration Tests for MultiAgent Conditionals Sample + +Tests the multi-agent conditionals sample for conditional orchestration logic. + +The function app is automatically started by the test fixture. + +Prerequisites: +- Azure OpenAI credentials configured (see packages/azurefunctions/tests/integration_tests/.env.example) +- Azurite running for durable orchestrations (or Azure Storage account configured) + +Usage: + # Start Azurite (if not already running) + azurite & + + # Run tests + uv run pytest packages/azurefunctions/tests/integration_tests/test_06_multi_agent_orchestration_conditionals.py -v +""" + +import pytest + +from .testutils import SampleTestHelper, skip_if_azure_functions_integration_tests_disabled + +# Module-level markers - applied to all tests in this file +pytestmark = [ + pytest.mark.orchestration, + pytest.mark.sample("06_multi_agent_orchestration_conditionals"), + pytest.mark.usefixtures("function_app_for_test"), + skip_if_azure_functions_integration_tests_disabled, +] + + +class TestSampleMultiAgentConditionals: + """Tests for 06_multi_agent_orchestration_conditionals sample.""" + + def test_legitimate_email(self, base_url: str) -> None: + """Test conditional logic with legitimate email.""" + response = SampleTestHelper.post_json( + f"{base_url}/api/spamdetection/run", + { + "email_id": "email-test-001", + "email_content": "Hi John, I hope you are doing well. Can you send me the report?", + }, + ) + assert response.status_code == 202 + data = response.json() + assert "instanceId" in data + assert "statusQueryGetUri" in data + + # Wait for completion + status = SampleTestHelper.wait_for_orchestration(data["statusQueryGetUri"]) + assert status["runtimeStatus"] == "Completed" + assert "Email sent:" in status["output"] + + def test_spam_email(self, base_url: str) -> None: + """Test conditional logic with spam email.""" + response = SampleTestHelper.post_json( + f"{base_url}/api/spamdetection/run", + {"email_id": "email-test-002", "email_content": "URGENT! You have won $1,000,000! Click here now!"}, + ) + assert response.status_code == 202 + data = response.json() + assert "instanceId" in data + + # Wait for completion + status = SampleTestHelper.wait_for_orchestration(data["statusQueryGetUri"]) + assert status["runtimeStatus"] == "Completed" + assert "Email marked as spam:" in status["output"] + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/python/packages/azurefunctions/tests/integration_tests/test_07_single_agent_orchestration_hitl.py b/python/packages/azurefunctions/tests/integration_tests/test_07_single_agent_orchestration_hitl.py new file mode 100644 index 0000000000..ade46033bc --- /dev/null +++ b/python/packages/azurefunctions/tests/integration_tests/test_07_single_agent_orchestration_hitl.py @@ -0,0 +1,185 @@ +# Copyright (c) Microsoft. All rights reserved. +""" +Integration Tests for Human-in-the-Loop (HITL) Orchestration Sample + +Tests the HITL orchestration sample for content generation with human approval workflow. + +The function app is automatically started by the test fixture. + +Prerequisites: +- Azure OpenAI credentials configured (see packages/azurefunctions/tests/integration_tests/.env.example) +- Azurite running for durable orchestrations (or Azure Storage account configured) + +Usage: + # Start Azurite (if not already running) + azurite & + + # Run tests + uv run pytest packages/azurefunctions/tests/integration_tests/test_07_single_agent_orchestration_hitl.py -v +""" + +import time + +import pytest + +from .testutils import SampleTestHelper, skip_if_azure_functions_integration_tests_disabled + +# Module-level markers - applied to all tests in this file +pytestmark = [ + pytest.mark.sample("07_single_agent_orchestration_hitl"), + pytest.mark.usefixtures("function_app_for_test"), + skip_if_azure_functions_integration_tests_disabled, +] + + +@pytest.mark.orchestration +class TestSampleHITLOrchestration: + """Tests for 07_single_agent_orchestration_hitl sample.""" + + @pytest.fixture(autouse=True) + def _set_hitl_base_url(self, base_url: str) -> None: + """Prepare the HITL API base URL for the module's tests.""" + self.hitl_base_url = f"{base_url}/api/hitl" + + def test_hitl_orchestration_approval(self) -> None: + """Test HITL orchestration with human approval.""" + # Start orchestration + response = SampleTestHelper.post_json( + f"{self.hitl_base_url}/run", + {"topic": "artificial intelligence", "max_review_attempts": 3, "approval_timeout_hours": 1.0}, + ) + assert response.status_code == 202 + data = response.json() + assert "instanceId" in data + assert "statusQueryGetUri" in data + assert data["topic"] == "artificial intelligence" + instance_id = data["instanceId"] + + # Wait a bit for the orchestration to generate initial content + time.sleep(5) + + # Check status to ensure it's waiting for approval + status_response = SampleTestHelper.get(data["statusQueryGetUri"]) + assert status_response.status_code == 200 + status = status_response.json() + assert status["runtimeStatus"] in ["Running", "Pending"] + + # Send approval + approval_response = SampleTestHelper.post_json( + f"{self.hitl_base_url}/approve/{instance_id}", {"approved": True, "feedback": ""} + ) + assert approval_response.status_code == 200 + approval_data = approval_response.json() + assert approval_data["approved"] is True + + # Wait for orchestration to complete + status = SampleTestHelper.wait_for_orchestration(data["statusQueryGetUri"]) + assert status["runtimeStatus"] == "Completed" + assert "output" in status + assert "content" in status["output"] + + def test_hitl_orchestration_rejection_with_feedback(self) -> None: + """Test HITL orchestration with rejection and subsequent approval.""" + # Start orchestration + response = SampleTestHelper.post_json( + f"{self.hitl_base_url}/run", + {"topic": "machine learning", "max_review_attempts": 3, "approval_timeout_hours": 1.0}, + ) + assert response.status_code == 202 + data = response.json() + instance_id = data["instanceId"] + + # Wait for initial content generation + time.sleep(5) + + # Send rejection with feedback + rejection_response = SampleTestHelper.post_json( + f"{self.hitl_base_url}/approve/{instance_id}", + {"approved": False, "feedback": "Please make it more concise and focus on practical applications."}, + ) + assert rejection_response.status_code == 200 + + # Wait for regeneration + time.sleep(5) + + # Check status - should still be running + status_response = SampleTestHelper.get(data["statusQueryGetUri"]) + assert status_response.status_code == 200 + status = status_response.json() + assert status["runtimeStatus"] in ["Running", "Pending"] + + # Now approve the revised content + approval_response = SampleTestHelper.post_json( + f"{self.hitl_base_url}/approve/{instance_id}", {"approved": True, "feedback": ""} + ) + assert approval_response.status_code == 200 + + # Wait for completion + status = SampleTestHelper.wait_for_orchestration(data["statusQueryGetUri"]) + assert status["runtimeStatus"] == "Completed" + assert "output" in status + + def test_hitl_orchestration_missing_topic(self) -> None: + """Test HITL orchestration with missing topic.""" + response = SampleTestHelper.post_json(f"{self.hitl_base_url}/run", {"max_review_attempts": 3}) + assert response.status_code == 400 + data = response.json() + assert "error" in data + + def test_hitl_get_status(self) -> None: + """Test getting orchestration status.""" + # Start orchestration + response = SampleTestHelper.post_json( + f"{self.hitl_base_url}/run", + {"topic": "quantum computing", "max_review_attempts": 2, "approval_timeout_hours": 1.0}, + ) + assert response.status_code == 202 + data = response.json() + instance_id = data["instanceId"] + + # Get status + status_response = SampleTestHelper.get(f"{self.hitl_base_url}/status/{instance_id}") + assert status_response.status_code == 200 + status = status_response.json() + assert "instanceId" in status + assert "runtimeStatus" in status + assert status["instanceId"] == instance_id + + # Cleanup: approve to complete orchestration + time.sleep(5) + SampleTestHelper.post_json(f"{self.hitl_base_url}/approve/{instance_id}", {"approved": True, "feedback": ""}) + + def test_hitl_approval_invalid_payload(self) -> None: + """Test sending approval with invalid payload.""" + # Start orchestration first + response = SampleTestHelper.post_json( + f"{self.hitl_base_url}/run", + {"topic": "test topic", "max_review_attempts": 1, "approval_timeout_hours": 1.0}, + ) + assert response.status_code == 202 + data = response.json() + instance_id = data["instanceId"] + + time.sleep(3) + + # Send approval without 'approved' field + approval_response = SampleTestHelper.post_json( + f"{self.hitl_base_url}/approve/{instance_id}", {"feedback": "Some feedback"} + ) + assert approval_response.status_code == 400 + error_data = approval_response.json() + assert "error" in error_data + + # Cleanup + SampleTestHelper.post_json(f"{self.hitl_base_url}/approve/{instance_id}", {"approved": True, "feedback": ""}) + + def test_hitl_status_invalid_instance(self) -> None: + """Test getting status for non-existent instance.""" + response = SampleTestHelper.get(f"{self.hitl_base_url}/status/invalid-instance-id") + assert response.status_code == 404 + data = response.json() + assert "error" in data + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/python/packages/azurefunctions/tests/integration_tests/testutils.py b/python/packages/azurefunctions/tests/integration_tests/testutils.py new file mode 100644 index 0000000000..75deb352bd --- /dev/null +++ b/python/packages/azurefunctions/tests/integration_tests/testutils.py @@ -0,0 +1,397 @@ +# Copyright (c) Microsoft. All rights reserved. +""" +Shared test helper utilities for sample integration tests. + +This module provides common utilities for testing Azure Functions samples. +""" + +import os +import socket +import subprocess +import sys +import time +import uuid +from contextlib import suppress +from pathlib import Path +from typing import Any + +import pytest +import requests + +# Configuration +TIMEOUT = 30 # seconds +ORCHESTRATION_TIMEOUT = 180 # seconds for orchestrations +_DEFAULT_HOST = "localhost" + + +class FunctionAppStartupError(RuntimeError): + """Raised when the Azure Functions host fails to start reliably.""" + + pass + + +def _load_env_file_if_present() -> None: + """Load environment variables from the local .env file when available.""" + env_file = Path(__file__).parent / ".env" + if not env_file.exists(): + return + + try: + from dotenv import load_dotenv + + load_dotenv(env_file) + except ImportError: + # python-dotenv not available; rely on existing environment + pass + + +def _should_skip_azure_functions_integration_tests() -> tuple[bool, str]: + """Determine whether Azure Functions integration tests should be skipped.""" + _load_env_file_if_present() + + run_integration_tests = os.getenv("RUN_INTEGRATION_TESTS", "false").lower() == "true" + if not run_integration_tests: + return ( + True, + "Integration tests are disabled. Set RUN_INTEGRATION_TESTS=true to enable Azure Functions sample tests.", + ) + + endpoint = os.getenv("AZURE_OPENAI_ENDPOINT", "").strip() + if not endpoint or endpoint == "https://your-resource.openai.azure.com/": + return True, "No real AZURE_OPENAI_ENDPOINT provided; skipping integration tests." + + deployment_name = os.getenv("AZURE_OPENAI_CHAT_DEPLOYMENT_NAME", "").strip() + if not deployment_name or deployment_name == "your-deployment-name": + return True, "No real AZURE_OPENAI_CHAT_DEPLOYMENT_NAME provided; skipping integration tests." + + return False, "Integration tests enabled." + + +_SKIP_AZURE_FUNCTIONS_INTEGRATION_TESTS, _AZURE_FUNCTIONS_SKIP_REASON = _should_skip_azure_functions_integration_tests() + +skip_if_azure_functions_integration_tests_disabled = pytest.mark.skipif( + _SKIP_AZURE_FUNCTIONS_INTEGRATION_TESTS, + reason=_AZURE_FUNCTIONS_SKIP_REASON, +) + + +class SampleTestHelper: + """Helper class for testing samples.""" + + @staticmethod + def post_json(url: str, data: dict[str, Any], timeout: int = TIMEOUT) -> requests.Response: + """POST JSON data to a URL.""" + return requests.post(url, json=data, headers={"Content-Type": "application/json"}, timeout=timeout) + + @staticmethod + def post_text(url: str, text: str, timeout: int = TIMEOUT) -> requests.Response: + """POST plain text to a URL.""" + return requests.post(url, data=text, headers={"Content-Type": "text/plain"}, timeout=timeout) + + @staticmethod + def get(url: str, timeout: int = TIMEOUT) -> requests.Response: + """GET request to a URL.""" + return requests.get(url, timeout=timeout) + + @staticmethod + def wait_for_orchestration( + status_url: str, max_wait: int = ORCHESTRATION_TIMEOUT, poll_interval: int = 2 + ) -> dict[str, Any]: + """ + Wait for an orchestration to complete. + + Args: + status_url: URL to poll for orchestration status + max_wait: Maximum seconds to wait + poll_interval: Seconds between polls + + Returns: + Final orchestration status + + Raises: + TimeoutError: If orchestration doesn't complete in time + """ + start_time = time.time() + while time.time() - start_time < max_wait: + response = requests.get(status_url, timeout=TIMEOUT) + response.raise_for_status() + status = response.json() + + runtime_status = status.get("runtimeStatus", "") + if runtime_status in ["Completed", "Failed", "Terminated"]: + return status + + time.sleep(poll_interval) + + raise TimeoutError(f"Orchestration did not complete within {max_wait} seconds") + + @staticmethod + def wait_for_orchestration_with_output( + status_url: str, max_wait: int = ORCHESTRATION_TIMEOUT, poll_interval: int = 2 + ) -> dict[str, Any]: + """ + Wait for an orchestration to complete and have output available. + + This is a specialized version of wait_for_orchestration that also + ensures the output field is present, handling timing race conditions. + + Args: + status_url: URL to poll for orchestration status + max_wait: Maximum seconds to wait + poll_interval: Seconds between polls + + Returns: + Final orchestration status with output + + Raises: + TimeoutError: If orchestration doesn't complete with output in time + """ + start_time = time.time() + while time.time() - start_time < max_wait: + response = requests.get(status_url, timeout=TIMEOUT) + response.raise_for_status() + status = response.json() + + runtime_status = status.get("runtimeStatus", "") + if runtime_status in ["Failed", "Terminated"]: + return status + if runtime_status == "Completed" and status.get("output"): + return status + # If completed but no output, continue polling for a bit more to + # handle the race condition where output has not been persisted yet. + + time.sleep(poll_interval) + + # Provide detailed error message based on final status + final_response = requests.get(status_url, timeout=TIMEOUT) + final_response.raise_for_status() + final_status = final_response.json() + final_runtime_status = final_status.get("runtimeStatus", "Unknown") + + if final_runtime_status == "Completed": + if "output" not in final_status: + raise TimeoutError( + "Orchestration completed but 'output' field is missing after " + f"{max_wait} seconds. Final status: {final_status}" + ) + if not final_status["output"]: + raise TimeoutError( + "Orchestration completed but output is empty after " + f"{max_wait} seconds. Final status: {final_status}" + ) + raise TimeoutError( + "Orchestration completed with output but validation failed after " + f"{max_wait} seconds. Final status: {final_status}" + ) + raise TimeoutError( + "Orchestration did not complete within " + f"{max_wait} seconds. Final status: {final_runtime_status}, " + f"Full status: {final_status}" + ) + + +# Function App Lifecycle Management Helpers + + +def _resolve_repo_root() -> Path: + """Resolve the repository root, preferring GITHUB_WORKSPACE when available.""" + workspace = os.getenv("GITHUB_WORKSPACE") + if workspace: + candidate = Path(workspace).expanduser() + if not (candidate / "samples").exists() and (candidate / "python" / "samples").exists(): + return (candidate / "python").resolve() + return candidate.resolve() + + # If `GITHUB_WORKSPACE` is not set, + # go up from testutils.py -> integration_tests -> tests -> azurefunctions -> packages -> python + return Path(__file__).resolve().parents[4] + + +def get_sample_path_from_marker(request) -> tuple[Path | None, str | None]: + """ + Get sample path from @pytest.mark.sample() marker. + + Returns a tuple of (sample_path, error_message). + If successful, error_message is None. + If failed, sample_path is None and error_message contains the reason. + """ + marker = request.node.get_closest_marker("sample") + + if not marker: + return ( + None, + ( + "No @pytest.mark.sample() marker found on test. Add pytestmark with " + "@pytest.mark.sample('sample_name') to the test module." + ), + ) + + if not marker.args: + return ( + None, + "@pytest.mark.sample() marker found but no sample name provided. Use @pytest.mark.sample('sample_name').", + ) + + sample_name = marker.args[0] + repo_root = _resolve_repo_root() + sample_path = repo_root / "samples" / "getting_started" / "azure_functions" / sample_name + + if not sample_path.exists(): + return None, f"Sample directory does not exist: {sample_path}" + + return sample_path, None + + +def find_available_port(host: str = _DEFAULT_HOST) -> int: + """Find an available TCP port on the given host.""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind((host, 0)) + return sock.getsockname()[1] + + +def build_base_url(port: int, host: str = _DEFAULT_HOST) -> str: + """Construct a base URL for the Azure Functions host.""" + return f"http://{host}:{port}" + + +def is_port_in_use(port: int, host: str = _DEFAULT_HOST) -> bool: + """ + Check if a port is already in use. + + Returns True if the port is in use, False otherwise. + """ + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + return sock.connect_ex((host, port)) == 0 + + +def load_and_validate_env() -> None: + """ + Load .env file from current directory if it exists, + then validate that required environment variables are present. + + Raises pytest.fail if required environment variables are missing. + """ + _load_env_file_if_present() + + # Required environment variables for Azure Functions samples + # These match the variables defined in .env.example + required_env_vars = [ + "AZURE_OPENAI_ENDPOINT", + "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME", + "AzureWebJobsStorage", + "DURABLE_TASK_SCHEDULER_CONNECTION_STRING", + "FUNCTIONS_WORKER_RUNTIME", + ] + + # Check if required env vars are set + missing_vars = [var for var in required_env_vars if not os.environ.get(var)] + + if missing_vars: + pytest.fail( + f"Missing required environment variables: {', '.join(missing_vars)}. " + "Please create a .env file in tests/integration_tests/ based on .env.example or " + "set these variables in your environment." + ) + + +def start_function_app(sample_path: Path, port: int) -> subprocess.Popen: + """ + Start a function app in the specified sample directory. + + Returns the subprocess.Popen object for the running process. + """ + env = os.environ.copy() + # Use a unique TASKHUB_NAME for each test run to ensure test isolation. + # This prevents conflicts between parallel or repeated test runs, as Durable Functions + # use the task hub name to separate orchestration state. + env["TASKHUB_NAME"] = f"test{uuid.uuid4().hex[:8]}" + + # On Windows, use CREATE_NEW_PROCESS_GROUP to allow proper termination + # shell=True only on Windows to handle PATH resolution + if sys.platform == "win32": + return subprocess.Popen( + ["func", "start", "--port", str(port)], + cwd=str(sample_path), + creationflags=subprocess.CREATE_NEW_PROCESS_GROUP, + shell=True, + env=env, + ) + # On Unix, don't use shell=True to avoid shell wrapper issues + return subprocess.Popen(["func", "start", "--port", str(port)], cwd=str(sample_path), env=env) + + +def wait_for_function_app_ready(func_process: subprocess.Popen, port: int, max_wait: int = 60) -> None: + """Block until the Azure Functions host responds healthy or fail fast.""" + start_time = time.time() + health_url = f"{build_base_url(port)}/api/health" + last_error: Exception | None = None + + while time.time() - start_time < max_wait: + # If the process exited early, capture any previously seen error and fail fast. + if func_process.poll() is not None: + raise FunctionAppStartupError( + f"Function app process exited with code {func_process.returncode} before becoming healthy" + ) from last_error + + if is_port_in_use(port): + try: + response = requests.get(health_url, timeout=5) + if response.status_code == 200: + return + last_error = RuntimeError(f"Health check returned {response.status_code}") + except requests.RequestException as exc: + last_error = exc + + time.sleep(1) + + raise FunctionAppStartupError( + f"Function app did not become healthy on port {port} within {max_wait} seconds" + ) from last_error + + +def cleanup_function_app(func_process: subprocess.Popen) -> None: + """ + Clean up the function app process and all its children. + + Uses psutil if available for more thorough cleanup, falls back to basic termination. + """ + try: + import psutil + + if func_process.poll() is None: # Process still running + # Get parent process + parent = psutil.Process(func_process.pid) + + # Get all child processes recursively + children = parent.children(recursive=True) + + # Kill children first + for child in children: + with suppress(psutil.NoSuchProcess, psutil.AccessDenied): + child.kill() + + # Kill parent + with suppress(psutil.NoSuchProcess, psutil.AccessDenied): + parent.kill() + + # Wait for all to terminate + _gone, alive = psutil.wait_procs(children + [parent], timeout=3) + + # Force kill any remaining + for proc in alive: + with suppress(psutil.NoSuchProcess, psutil.AccessDenied): + proc.kill() + except ImportError: + # Fallback if psutil not available + try: + if func_process.poll() is None: + func_process.kill() + func_process.wait() + except Exception: + # Ignore all exceptions during fallback cleanup; best effort to terminate process. + pass + except Exception: + pass # Best effort cleanup + + # Give the port time to be released + time.sleep(2) diff --git a/python/packages/azurefunctions/tests/test_app.py b/python/packages/azurefunctions/tests/test_app.py new file mode 100644 index 0000000000..c11bb873d6 --- /dev/null +++ b/python/packages/azurefunctions/tests/test_app.py @@ -0,0 +1,738 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Unit tests for AgentFunctionApp.""" + +from collections.abc import Awaitable, Callable +from typing import Any, TypeVar +from unittest.mock import ANY, AsyncMock, Mock, patch + +import azure.durable_functions as df +import azure.functions as func +import pytest +from agent_framework import AgentRunResponse, ChatMessage + +from agent_framework_azurefunctions import AgentFunctionApp +from agent_framework_azurefunctions._app import WAIT_FOR_RESPONSE_FIELD, WAIT_FOR_RESPONSE_HEADER +from agent_framework_azurefunctions._entities import AgentEntity, AgentState, create_agent_entity + +TFunc = TypeVar("TFunc", bound=Callable[..., Any]) + + +class TestAgentFunctionAppInit: + """Test suite for AgentFunctionApp initialization.""" + + def test_init_with_defaults(self) -> None: + """Test initialization with default parameters.""" + mock_agent = Mock() + mock_agent.name = "TestAgent" + + app = AgentFunctionApp(agents=[mock_agent]) + + assert len(app.agents) == 1 + assert "TestAgent" in app.agents + assert app.enable_health_check is True + + def test_init_with_custom_auth_level(self) -> None: + """Test initialization with custom auth level.""" + mock_agent = Mock() + mock_agent.name = "TestAgent" + + app = AgentFunctionApp(agents=[mock_agent], http_auth_level=func.AuthLevel.FUNCTION) + + # App should be created successfully + assert "TestAgent" in app.agents + + def test_init_with_health_check_disabled(self) -> None: + """Test initialization with health check disabled.""" + mock_agent = Mock() + mock_agent.name = "TestAgent" + + app = AgentFunctionApp(agents=[mock_agent], enable_health_check=False) + + assert app.enable_health_check is False + + def test_init_with_http_endpoints_disabled(self) -> None: + """Test initialization with HTTP endpoints disabled.""" + mock_agent = Mock() + mock_agent.name = "TestAgent" + + app = AgentFunctionApp(agents=[mock_agent], enable_http_endpoints=False) + + assert app.enable_http_endpoints is False + + def test_init_stores_agent_reference(self) -> None: + """Test that agent reference is stored correctly.""" + mock_agent = Mock() + mock_agent.name = "TestAgent" + + app = AgentFunctionApp(agents=[mock_agent]) + + assert app.agents["TestAgent"].name == "TestAgent" + + def test_add_agent_uses_specific_callback(self) -> None: + """Verify that a per-agent callback overrides the default.""" + + mock_agent = Mock() + mock_agent.name = "CallbackAgent" + specific_callback = Mock() + + with patch.object(AgentFunctionApp, "_setup_agent_functions") as setup_mock: + app = AgentFunctionApp(default_callback=Mock()) + app.add_agent(mock_agent, callback=specific_callback) + + setup_mock.assert_called_once() + _, _, passed_callback, enable_http_endpoint = setup_mock.call_args[0] + assert passed_callback is specific_callback + assert enable_http_endpoint is True + + def test_default_callback_applied_when_no_specific(self) -> None: + """Ensure the default callback is supplied when add_agent lacks override.""" + + mock_agent = Mock() + mock_agent.name = "DefaultAgent" + default_callback = Mock() + + with patch.object(AgentFunctionApp, "_setup_agent_functions") as setup_mock: + app = AgentFunctionApp(default_callback=default_callback) + app.add_agent(mock_agent) + + setup_mock.assert_called_once() + _, _, passed_callback, enable_http_endpoint = setup_mock.call_args[0] + assert passed_callback is default_callback + assert enable_http_endpoint is True + + def test_init_with_agents_uses_default_callback(self) -> None: + """Agents provided in __init__ should receive the default callback.""" + + mock_agent = Mock() + mock_agent.name = "InitAgent" + default_callback = Mock() + + with patch.object(AgentFunctionApp, "_setup_agent_functions") as setup_mock: + AgentFunctionApp(agents=[mock_agent], default_callback=default_callback) + + setup_mock.assert_called_once() + _, _, passed_callback, enable_http_endpoint = setup_mock.call_args[0] + assert passed_callback is default_callback + assert enable_http_endpoint is True + + +class TestAgentFunctionAppSetup: + """Test suite for AgentFunctionApp setup and configuration.""" + + def test_app_is_dfapp_instance(self) -> None: + """Test that AgentFunctionApp is a DFApp instance.""" + mock_agent = Mock() + mock_agent.name = "TestAgent" + + app = AgentFunctionApp(agents=[mock_agent]) + + assert isinstance(app, df.DFApp) + + def test_setup_creates_http_trigger(self) -> None: + """Test that setup creates an HTTP trigger.""" + mock_agent = Mock() + mock_agent.name = "TestAgent" + + def passthrough_decorator(*args: Any, **kwargs: Any) -> Callable[[TFunc], TFunc]: + def decorator(func: TFunc) -> TFunc: + return func + + return decorator + + with ( + patch.object(AgentFunctionApp, "route", new=passthrough_decorator), + patch.object(AgentFunctionApp, "durable_client_input", new=passthrough_decorator), + patch.object(AgentFunctionApp, "entity_trigger", new=passthrough_decorator), + ): + app = AgentFunctionApp(agents=[mock_agent]) + + # Verify agent is registered + assert "TestAgent" in app.agents + + def test_http_function_name_uses_prefix_format(self) -> None: + """Ensure function names follow the prefix-agent naming convention.""" + mock_agent = Mock() + mock_agent.name = "Agent 42" + + captured_names: list[str] = [] + + def capture_function_name( + self: AgentFunctionApp, name: str, *args: Any, **kwargs: Any + ) -> Callable[[TFunc], TFunc]: + def decorator(func: TFunc) -> TFunc: + captured_names.append(name) + return func + + return decorator + + def passthrough_decorator(*args: Any, **kwargs: Any) -> Callable[[TFunc], TFunc]: + def decorator(func: TFunc) -> TFunc: + return func + + return decorator + + with ( + patch.object(AgentFunctionApp, "function_name", new=capture_function_name), + patch.object(AgentFunctionApp, "route", new=passthrough_decorator), + patch.object(AgentFunctionApp, "durable_client_input", new=passthrough_decorator), + patch.object(AgentFunctionApp, "entity_trigger", new=passthrough_decorator), + ): + AgentFunctionApp(agents=[mock_agent]) + + assert captured_names == ["http-Agent_42"] + + def test_setup_skips_http_trigger_when_disabled(self) -> None: + """Test that HTTP trigger is not created when disabled.""" + mock_agent = Mock() + mock_agent.name = "TestAgent" + + captured_routes: list[str | None] = [] + + def capture_route(*args: Any, **kwargs: Any) -> Callable[[TFunc], TFunc]: + def decorator(func: TFunc) -> TFunc: + route_key = kwargs.get("route") if kwargs else None + captured_routes.append(route_key) + return func + + return decorator + + def passthrough_decorator(*args: Any, **kwargs: Any) -> Callable[[TFunc], TFunc]: + def decorator(func: TFunc) -> TFunc: + return func + + return decorator + + with ( + patch.object(AgentFunctionApp, "function_name", new=passthrough_decorator), + patch.object(AgentFunctionApp, "route", new=capture_route), + patch.object(AgentFunctionApp, "durable_client_input", new=passthrough_decorator), + patch.object(AgentFunctionApp, "entity_trigger", new=passthrough_decorator), + ): + app = AgentFunctionApp(agents=[mock_agent], enable_http_endpoints=False) + + # Verify agent is registered + assert "TestAgent" in app.agents + + # Verify that no HTTP run route was created + run_route = f"agents/{mock_agent.name}/run" + assert run_route not in captured_routes + + def test_agent_override_enables_http_route_when_app_disabled(self) -> None: + """Agent-level override should enable HTTP route even when app disables it.""" + + mock_agent = Mock() + mock_agent.name = "OverrideAgent" + + with ( + patch.object(AgentFunctionApp, "_setup_http_run_route") as http_route_mock, + patch.object(AgentFunctionApp, "_setup_agent_entity") as agent_entity_mock, + ): + app = AgentFunctionApp(enable_health_check=False, enable_http_endpoints=False) + app.add_agent(mock_agent, enable_http_endpoint=True) + + http_route_mock.assert_called_once_with("OverrideAgent") + agent_entity_mock.assert_called_once_with(mock_agent, "OverrideAgent", ANY) + assert app.agent_http_endpoint_flags["OverrideAgent"] is True + + def test_agent_override_disables_http_route_when_app_enabled(self) -> None: + """Agent-level override should disable HTTP route even when app enables it.""" + + mock_agent = Mock() + mock_agent.name = "DisabledOverride" + + with ( + patch.object(AgentFunctionApp, "_setup_http_run_route") as http_route_mock, + patch.object(AgentFunctionApp, "_setup_agent_entity") as agent_entity_mock, + ): + app = AgentFunctionApp(enable_health_check=False, enable_http_endpoints=True) + app.add_agent(mock_agent, enable_http_endpoint=False) + + http_route_mock.assert_not_called() + agent_entity_mock.assert_called_once_with(mock_agent, "DisabledOverride", ANY) + assert app.agent_http_endpoint_flags["DisabledOverride"] is False + + def test_multiple_apps_independent(self) -> None: + """Test that multiple AgentFunctionApp instances are independent.""" + agent1 = Mock() + agent1.name = "Agent1" + agent2 = Mock() + agent2.name = "Agent2" + + app1 = AgentFunctionApp(agents=[agent1]) + app2 = AgentFunctionApp(agents=[agent2]) + + assert app1.agents["Agent1"].name == "Agent1" + assert app2.agents["Agent2"].name == "Agent2" + assert "Agent1" in app1.agents + assert "Agent2" in app2.agents + + +class TestWaitForResponseAndCorrelationId: + """Tests for wait_for_response flag and correlation ID handling.""" + + def _create_app(self) -> AgentFunctionApp: + mock_agent = Mock() + mock_agent.__class__.__name__ = "MockAgent" + mock_agent.name = "MockAgent" + return AgentFunctionApp(agents=[mock_agent], enable_health_check=False) + + def _make_request( + self, + headers: dict[str, str] | None = None, + params: dict[str, str] | None = None, + ) -> Mock: + request = Mock() + request.headers = headers or {} + request.params = params or {} + return request + + def test_wait_for_response_header_true(self) -> None: + """Test that the wait-for-response header is honored.""" + app = self._create_app() + request = self._make_request(headers={WAIT_FOR_RESPONSE_HEADER: "true"}) + + assert app._should_wait_for_response(request, {}) is True + + def test_wait_for_response_body_snake_case(self) -> None: + """Test that payload controls wait_for_response.""" + app = self._create_app() + request = self._make_request() + + assert app._should_wait_for_response(request, {WAIT_FOR_RESPONSE_FIELD: "true"}) is True + assert app._should_wait_for_response(request, {WAIT_FOR_RESPONSE_FIELD: "false"}) is False + assert app._should_wait_for_response(request, {WAIT_FOR_RESPONSE_FIELD: "0"}) is False + + def test_wait_for_response_query_parameter(self) -> None: + """Test that query parameter controls wait_for_response.""" + app = self._create_app() + request = self._make_request(params={WAIT_FOR_RESPONSE_FIELD: "true"}) + + assert app._should_wait_for_response(request, {}) is True + + def test_wait_for_response_query_precedence(self) -> None: + """Test that query parameter overrides body value.""" + app = self._create_app() + request = self._make_request(params={WAIT_FOR_RESPONSE_FIELD: "false"}) + + assert app._should_wait_for_response(request, {WAIT_FOR_RESPONSE_FIELD: "true"}) is False + + +class TestAgentEntityOperations: + """Test suite for entity operations.""" + + async def test_entity_run_agent_operation(self) -> None: + """Test that entity can run agent operation.""" + mock_agent = Mock() + mock_agent.run = AsyncMock( + return_value=AgentRunResponse(messages=[ChatMessage(role="assistant", text="Test response")]) + ) + + entity = AgentEntity(mock_agent) + mock_context = Mock() + + result = await entity.run_agent( + mock_context, + {"message": "Test message", "thread_id": "test-conv-123", "correlation_id": "corr-app-entity-1"}, + ) + + assert result["status"] == "success" + assert result["response"] == "Test response" + assert result["message"] == "Test message" + assert result["thread_id"] == "test-conv-123" + assert entity.state.message_count == 1 + + async def test_entity_stores_conversation_history(self) -> None: + """Test that the entity stores conversation history.""" + mock_agent = Mock() + mock_agent.run = AsyncMock( + return_value=AgentRunResponse(messages=[ChatMessage(role="assistant", text="Response 1")]) + ) + + entity = AgentEntity(mock_agent) + mock_context = Mock() + + # Send first message + await entity.run_agent( + mock_context, {"message": "Message 1", "thread_id": "conv-1", "correlation_id": "corr-app-entity-2"} + ) + + history = entity.state.conversation_history + assert len(history) == 2 # User + assistant + + user_msg = history[0] + user_role = getattr(user_msg.role, "value", user_msg.role) + assert user_role == "user" + assert user_msg.text == "Message 1" + + assistant_msg = history[1] + assistant_role = getattr(assistant_msg.role, "value", assistant_msg.role) + assert assistant_role == "assistant" + assert assistant_msg.text == "Response 1" + + async def test_entity_increments_message_count(self) -> None: + """Test that the entity increments the message count.""" + mock_agent = Mock() + mock_agent.run = AsyncMock( + return_value=AgentRunResponse(messages=[ChatMessage(role="assistant", text="Response")]) + ) + + entity = AgentEntity(mock_agent) + mock_context = Mock() + + assert entity.state.message_count == 0 + + await entity.run_agent( + mock_context, {"message": "Message 1", "thread_id": "conv-1", "correlation_id": "corr-app-entity-3a"} + ) + assert entity.state.message_count == 1 + + await entity.run_agent( + mock_context, {"message": "Message 2", "thread_id": "conv-1", "correlation_id": "corr-app-entity-3b"} + ) + assert entity.state.message_count == 2 + + def test_entity_reset(self) -> None: + """Test that entity reset clears state.""" + mock_agent = Mock() + entity = AgentEntity(mock_agent) + + # Set some state + entity.state.message_count = 10 + entity.state.last_response = "Some response" + entity.state.conversation_history = [ + ChatMessage(role="user", text="test", additional_properties={"timestamp": "2024-01-01T00:00:00Z"}) + ] + + # Reset + mock_context = Mock() + entity.reset(mock_context) + + assert entity.state.message_count == 0 + assert entity.state.last_response is None + assert len(entity.state.conversation_history) == 0 + + +class TestAgentEntityFactory: + """Test suite for the entity factory function.""" + + def test_create_agent_entity_returns_function(self) -> None: + """Test that create_agent_entity returns a function.""" + mock_agent = Mock() + entity_function = create_agent_entity(mock_agent) + + assert callable(entity_function) + + def test_entity_function_handles_run_agent_operation(self) -> None: + """Test that the entity function handles the run_agent operation.""" + mock_agent = Mock() + mock_agent.run = AsyncMock( + return_value=AgentRunResponse(messages=[ChatMessage(role="assistant", text="Response")]) + ) + + entity_function = create_agent_entity(mock_agent) + + # Mock context + mock_context = Mock() + mock_context.operation_name = "run_agent" + mock_context.get_input.return_value = { + "message": "Test message", + "thread_id": "conv-123", + "correlation_id": "corr-app-factory-1", + } + mock_context.get_state.return_value = None + + # Execute entity function + entity_function(mock_context) + + # Verify result was set + assert mock_context.set_result.called + assert mock_context.set_state.called + + def test_entity_function_handles_reset_operation(self) -> None: + """Test that the entity function handles the reset operation.""" + mock_agent = Mock() + entity_function = create_agent_entity(mock_agent) + + # Mock context + mock_context = Mock() + mock_context.operation_name = "reset" + mock_context.get_state.return_value = { + "message_count": 5, + "conversation_history": [{"role": "user", "content": "test"}], + "last_response": "Test", + } + + # Execute entity function + entity_function(mock_context) + + # Verify result was set + assert mock_context.set_result.called + result_call = mock_context.set_result.call_args[0][0] + assert result_call["status"] == "reset" + + def test_entity_function_handles_unknown_operation(self) -> None: + """Test that the entity function handles an unknown operation.""" + mock_agent = Mock() + entity_function = create_agent_entity(mock_agent) + + # Mock context with unknown operation + mock_context = Mock() + mock_context.operation_name = "unknown_operation" + mock_context.get_state.return_value = None + + # Execute entity function + entity_function(mock_context) + + # Verify error result was set + assert mock_context.set_result.called + result_call = mock_context.set_result.call_args[0][0] + assert "error" in result_call + assert "unknown_operation" in result_call["error"] + + def test_entity_function_restores_state(self) -> None: + """Test that the entity function restores state from the context.""" + mock_agent = Mock() + entity_function = create_agent_entity(mock_agent) + + # Mock context with existing state + existing_state = { + "message_count": 3, + "conversation_history": [{"role": "user", "content": "msg1"}, {"role": "assistant", "content": "resp1"}], + "last_response": "resp1", + } + + mock_context = Mock() + mock_context.operation_name = "reset" + mock_context.get_state.return_value = existing_state + + with patch.object(AgentState, "restore_state") as restore_state_mock: + entity_function(mock_context) + + restore_state_mock.assert_called_once_with(existing_state) + + +class TestErrorHandling: + """Test suite for error handling.""" + + async def test_entity_handles_agent_error(self) -> None: + """Test that the entity handles agent execution errors.""" + mock_agent = Mock() + mock_agent.run = AsyncMock(side_effect=Exception("Agent error")) + + entity = AgentEntity(mock_agent) + mock_context = Mock() + + result = await entity.run_agent( + mock_context, {"message": "Test message", "thread_id": "conv-1", "correlation_id": "corr-app-error-1"} + ) + + assert result["status"] == "error" + assert "error" in result + assert "Agent error" in result["error"] + assert result["error_type"] == "Exception" + + def test_entity_function_handles_exception(self) -> None: + """Test that the entity function handles exceptions gracefully.""" + mock_agent = Mock() + # Force an exception by making get_input fail + mock_agent.run = AsyncMock(side_effect=Exception("Test error")) + + entity_function = create_agent_entity(mock_agent) + + mock_context = Mock() + mock_context.operation_name = "run_agent" + mock_context.get_input.side_effect = Exception("Input error") + mock_context.get_state.return_value = None + + # Execute entity function - should not raise + entity_function(mock_context) + + # Verify error result was set + assert mock_context.set_result.called + result_call = mock_context.set_result.call_args[0][0] + assert "error" in result_call + + +class TestIncomingRequestParsing: + """Tests for parsing run requests with JSON and plain text bodies.""" + + def _create_app(self) -> AgentFunctionApp: + mock_agent = Mock() + mock_agent.name = "ParserAgent" + return AgentFunctionApp(agents=[mock_agent], enable_health_check=False) + + def test_parse_plain_text_body(self) -> None: + """Test parsing a plain-text request body.""" + app = self._create_app() + + request = Mock() + request.headers = {} + request.params = {} + request.get_json.side_effect = ValueError("Invalid JSON") + request.get_body.return_value = b"Plain text message" + + req_body, message, response_format = app._parse_incoming_request(request) + + assert req_body == {} + assert message == "Plain text message" + + assert response_format == "text" + + def test_parse_plain_text_trims_whitespace(self) -> None: + """Plain-text parser returns an empty string when the body contains only whitespace.""" + app = self._create_app() + + request = Mock() + request.headers = {} + request.params = {} + request.get_json.side_effect = ValueError("Invalid JSON") + request.get_body.return_value = b" " + + req_body, message, response_format = app._parse_incoming_request(request) + + assert req_body == {} + assert message == "" + assert response_format == "text" + + def test_accept_header_prefers_json(self) -> None: + """Test that the Accept header can force JSON responses for plain-text bodies.""" + app = self._create_app() + + request = Mock() + request.headers = {"accept": "application/json"} + request.params = {} + request.get_json.side_effect = ValueError("Invalid JSON") + request.get_body.return_value = b"Plain text message" + + _, message, response_format = app._parse_incoming_request(request) + + assert message == "Plain text message" + assert response_format == "json" + + def test_extract_thread_id_from_query_params(self) -> None: + """Test thread identifier extraction from query parameters.""" + app = self._create_app() + + request = Mock() + request.params = {"thread_id": "query-thread"} + req_body = {} + + thread_id = app._resolve_thread_id(request, req_body) + + assert thread_id == "query-thread" + + +class TestHttpRunRoute: + """Tests for the HTTP run route behavior.""" + + @staticmethod + def _get_run_handler(agent: Mock) -> Callable[[func.HttpRequest, Any], Awaitable[func.HttpResponse]]: + captured_handlers: dict[str | None, Callable[..., Awaitable[func.HttpResponse]]] = {} + + def capture_decorator(*args: Any, **kwargs: Any) -> Callable[[TFunc], TFunc]: + def decorator(func: TFunc) -> TFunc: + return func + + return decorator + + def capture_route(*args: Any, **kwargs: Any) -> Callable[[TFunc], TFunc]: + def decorator(func: TFunc) -> TFunc: + route_key = kwargs.get("route") if kwargs else None + captured_handlers[route_key] = func + return func + + return decorator + + with ( + patch.object(AgentFunctionApp, "function_name", new=capture_decorator), + patch.object(AgentFunctionApp, "route", new=capture_route), + patch.object(AgentFunctionApp, "durable_client_input", new=capture_decorator), + patch.object(AgentFunctionApp, "entity_trigger", new=capture_decorator), + ): + AgentFunctionApp(agents=[agent], enable_health_check=False) + + run_route = f"agents/{agent.name}/run" + return captured_handlers[run_route] + + async def test_http_run_accepts_plain_text(self) -> None: + """Test that the HTTP handler accepts plain-text requests.""" + mock_agent = Mock() + mock_agent.name = "HttpAgent" + + handler = self._get_run_handler(mock_agent) + + request = Mock() + request.headers = {WAIT_FOR_RESPONSE_HEADER: "false"} + request.params = {} + request.route_params = {} + request.get_json.side_effect = ValueError("Invalid JSON") + request.get_body.return_value = b"Plain text via HTTP" + + client = AsyncMock() + + response = await handler(request, client) + + assert response.status_code == 202 + assert response.mimetype == "text/plain" + assert response.headers.get("x-ms-thread-id") is not None + assert response.get_body().decode("utf-8") == "Agent request accepted" + + signal_args = client.signal_entity.call_args[0] + run_request = signal_args[2] + + assert run_request["message"] == "Plain text via HTTP" + assert run_request["role"] == "user" + assert "thread_id" in run_request + + async def test_http_run_accept_header_returns_json(self) -> None: + """Test that Accept header requesting JSON results in JSON response.""" + mock_agent = Mock() + mock_agent.name = "HttpAgentJson" + + handler = self._get_run_handler(mock_agent) + + request = Mock() + request.headers = {WAIT_FOR_RESPONSE_HEADER: "false", "Accept": "application/json"} + request.params = {} + request.route_params = {} + request.get_json.side_effect = ValueError("Invalid JSON") + request.get_body.return_value = b"Plain text via HTTP" + + client = AsyncMock() + + response = await handler(request, client) + + assert response.status_code == 202 + assert response.mimetype == "application/json" + assert response.headers.get("x-ms-thread-id") is None + body = response.get_body().decode("utf-8") + assert '"status": "accepted"' in body + + async def test_http_run_rejects_empty_message(self) -> None: + """Test that the HTTP handler rejects empty messages with a 400 response.""" + mock_agent = Mock() + mock_agent.name = "HttpAgentEmpty" + + handler = self._get_run_handler(mock_agent) + + request = Mock() + request.headers = {WAIT_FOR_RESPONSE_HEADER: "false"} + request.params = {} + request.route_params = {} + request.get_json.side_effect = ValueError("Invalid JSON") + request.get_body.return_value = b" " + + client = AsyncMock() + + response = await handler(request, client) + + assert response.status_code == 400 + assert response.mimetype == "text/plain" + assert response.headers.get("x-ms-thread-id") is not None + assert response.get_body().decode("utf-8") == "Message is required" + client.signal_entity.assert_not_called() + + +if __name__ == "__main__": + pytest.main([__file__, "-v", "--tb=short"]) diff --git a/python/packages/azurefunctions/tests/test_entities.py b/python/packages/azurefunctions/tests/test_entities.py new file mode 100644 index 0000000000..5b053efbf3 --- /dev/null +++ b/python/packages/azurefunctions/tests/test_entities.py @@ -0,0 +1,904 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Unit tests for AgentEntity and entity operations. + +Run with: pytest tests/test_entities.py -v +""" + +import asyncio +from collections.abc import AsyncIterator, Callable +from datetime import datetime +from typing import Any, TypeVar +from unittest.mock import AsyncMock, Mock, patch + +import pytest +from agent_framework import AgentRunResponse, AgentRunResponseUpdate, ChatMessage, Role +from pydantic import BaseModel + +from agent_framework_azurefunctions._entities import AgentEntity, create_agent_entity +from agent_framework_azurefunctions._models import RunRequest +from agent_framework_azurefunctions._state import AgentState + +TFunc = TypeVar("TFunc", bound=Callable[..., Any]) + + +def _role_value(chat_message: ChatMessage) -> str: + """Helper to extract the string role from a ChatMessage.""" + role = getattr(chat_message, "role", None) + role_value = getattr(role, "value", role) + if role_value is None: + return "" + return str(role_value) + + +def _agent_response(text: str | None) -> AgentRunResponse: + """Create an AgentRunResponse with a single assistant message.""" + message = ( + ChatMessage(role="assistant", text=text) if text is not None else ChatMessage(role="assistant", contents=[]) + ) + return AgentRunResponse(messages=[message]) + + +class RecordingCallback: + """Callback implementation capturing streaming and final responses for assertions.""" + + def __init__(self): + self.stream_mock = AsyncMock() + self.response_mock = AsyncMock() + + async def on_streaming_response_update( + self, + update: AgentRunResponseUpdate, + context: Any, + ) -> None: + await self.stream_mock(update, context) + + async def on_agent_response(self, response: AgentRunResponse, context: Any) -> None: + await self.response_mock(response, context) + + +class EntityStructuredResponse(BaseModel): + answer: float + + +class TestAgentEntityInit: + """Test suite for AgentEntity initialization.""" + + def test_init_creates_entity(self) -> None: + """Test that AgentEntity initializes correctly.""" + mock_agent = Mock() + + entity = AgentEntity(mock_agent) + + assert entity.agent == mock_agent + assert entity.state.conversation_history == [] + assert entity.state.last_response is None + assert entity.state.message_count == 0 + + def test_init_stores_agent_reference(self) -> None: + """Test that the agent reference is stored correctly.""" + mock_agent = Mock() + mock_agent.name = "TestAgent" + + entity = AgentEntity(mock_agent) + + assert entity.agent.name == "TestAgent" + + def test_init_with_different_agent_types(self) -> None: + """Test initialization with different agent types.""" + agent1 = Mock() + agent1.__class__.__name__ = "AzureOpenAIAgent" + + agent2 = Mock() + agent2.__class__.__name__ = "CustomAgent" + + entity1 = AgentEntity(agent1) + entity2 = AgentEntity(agent2) + + assert entity1.agent.__class__.__name__ == "AzureOpenAIAgent" + assert entity2.agent.__class__.__name__ == "CustomAgent" + + +class TestAgentEntityRunAgent: + """Test suite for the run_agent operation.""" + + async def test_run_agent_executes_agent(self) -> None: + """Test that run_agent executes the agent.""" + mock_agent = Mock() + mock_response = _agent_response("Test response") + mock_agent.run = AsyncMock(return_value=mock_response) + + entity = AgentEntity(mock_agent) + mock_context = Mock() + + result = await entity.run_agent( + mock_context, {"message": "Test message", "thread_id": "conv-123", "correlation_id": "corr-entity-1"} + ) + + # Verify agent.run was called + mock_agent.run.assert_called_once() + _, kwargs = mock_agent.run.call_args + sent_messages = kwargs.get("messages") + assert isinstance(sent_messages, list) + assert len(sent_messages) == 1 + sent_message = sent_messages[0] + assert isinstance(sent_message, ChatMessage) + assert sent_message.text == "Test message" + assert _role_value(sent_message) == "user" + + # Verify result + assert result["status"] == "success" + assert result["response"] == "Test response" + assert result["message"] == "Test message" + assert result["thread_id"] == "conv-123" + + async def test_run_agent_streaming_callbacks_invoked(self) -> None: + """Ensure streaming updates trigger callbacks and run() is not used.""" + + updates = [ + AgentRunResponseUpdate(text="Hello"), + AgentRunResponseUpdate(text=" world"), + ] + + async def update_generator() -> AsyncIterator[AgentRunResponseUpdate]: + for update in updates: + yield update + + mock_agent = Mock() + mock_agent.name = "StreamingAgent" + mock_agent.run_stream = Mock(return_value=update_generator()) + mock_agent.run = AsyncMock(side_effect=AssertionError("run() should not be called when streaming succeeds")) + + callback = RecordingCallback() + entity = AgentEntity(mock_agent, callback=callback) + mock_context = Mock() + + result = await entity.run_agent( + mock_context, + { + "message": "Tell me something", + "thread_id": "session-1", + "correlation_id": "corr-stream-1", + }, + ) + + assert result["status"] == "success" + assert "Hello" in result.get("response", "") + assert callback.stream_mock.await_count == len(updates) + assert callback.response_mock.await_count == 1 + mock_agent.run.assert_not_called() + + # Validate callback arguments + stream_calls = callback.stream_mock.await_args_list + for expected_update, recorded_call in zip(updates, stream_calls, strict=True): + assert recorded_call.args[0] is expected_update + context = recorded_call.args[1] + assert context.agent_name == "StreamingAgent" + assert context.correlation_id == "corr-stream-1" + assert context.thread_id == "session-1" + assert context.request_message == "Tell me something" + + final_call = callback.response_mock.await_args + assert final_call is not None + final_response, final_context = final_call.args + assert final_context.agent_name == "StreamingAgent" + assert final_context.correlation_id == "corr-stream-1" + assert final_context.thread_id == "session-1" + assert final_context.request_message == "Tell me something" + assert getattr(final_response, "text", "").strip() + + async def test_run_agent_final_callback_without_streaming(self) -> None: + """Ensure the final callback fires even when streaming is unavailable.""" + + mock_agent = Mock() + mock_agent.name = "NonStreamingAgent" + mock_agent.run_stream = None + agent_response = _agent_response("Final response") + mock_agent.run = AsyncMock(return_value=agent_response) + + callback = RecordingCallback() + entity = AgentEntity(mock_agent, callback=callback) + mock_context = Mock() + + result = await entity.run_agent( + mock_context, + { + "message": "Hi", + "thread_id": "session-2", + "correlation_id": "corr-final-1", + }, + ) + + assert result["status"] == "success" + assert result.get("response") == "Final response" + assert callback.stream_mock.await_count == 0 + assert callback.response_mock.await_count == 1 + + final_call = callback.response_mock.await_args + assert final_call is not None + assert final_call.args[0] is agent_response + final_context = final_call.args[1] + assert final_context.agent_name == "NonStreamingAgent" + assert final_context.correlation_id == "corr-final-1" + assert final_context.thread_id == "session-2" + assert final_context.request_message == "Hi" + + async def test_run_agent_updates_conversation_history(self) -> None: + """Test that run_agent updates the conversation history.""" + mock_agent = Mock() + mock_response = _agent_response("Agent response") + mock_agent.run = AsyncMock(return_value=mock_response) + + entity = AgentEntity(mock_agent) + mock_context = Mock() + + await entity.run_agent( + mock_context, {"message": "User message", "thread_id": "conv-1", "correlation_id": "corr-entity-2"} + ) + + # Should have 2 entries: user message + assistant response + history = entity.state.conversation_history + + assert len(history) == 2 + + user_msg = history[0] + assert _role_value(user_msg) == "user" + assert user_msg.text == "User message" + + assistant_msg = history[1] + assert _role_value(assistant_msg) == "assistant" + assert assistant_msg.text == "Agent response" + + async def test_run_agent_increments_message_count(self) -> None: + """Test that run_agent increments the message count.""" + mock_agent = Mock() + mock_agent.run = AsyncMock(return_value=_agent_response("Response")) + + entity = AgentEntity(mock_agent) + mock_context = Mock() + + assert entity.state.message_count == 0 + + await entity.run_agent( + mock_context, {"message": "Message 1", "thread_id": "conv-1", "correlation_id": "corr-entity-3a"} + ) + assert entity.state.message_count == 1 + + await entity.run_agent( + mock_context, {"message": "Message 2", "thread_id": "conv-1", "correlation_id": "corr-entity-3b"} + ) + assert entity.state.message_count == 2 + + await entity.run_agent( + mock_context, {"message": "Message 3", "thread_id": "conv-1", "correlation_id": "corr-entity-3c"} + ) + assert entity.state.message_count == 3 + + async def test_run_agent_stores_last_response(self) -> None: + """Test that run_agent stores the last response.""" + mock_agent = Mock() + mock_agent.run = AsyncMock(return_value=_agent_response("Response 1")) + + entity = AgentEntity(mock_agent) + mock_context = Mock() + + await entity.run_agent( + mock_context, {"message": "Message 1", "thread_id": "conv-1", "correlation_id": "corr-entity-4a"} + ) + assert entity.state.last_response == "Response 1" + + mock_agent.run = AsyncMock(return_value=_agent_response("Response 2")) + await entity.run_agent( + mock_context, {"message": "Message 2", "thread_id": "conv-1", "correlation_id": "corr-entity-4b"} + ) + assert entity.state.last_response == "Response 2" + + async def test_run_agent_with_none_thread_id(self) -> None: + """Test run_agent with a None thread identifier.""" + mock_agent = Mock() + mock_agent.run = AsyncMock(return_value=_agent_response("Response")) + + entity = AgentEntity(mock_agent) + mock_context = Mock() + + with pytest.raises(ValueError, match="thread_id"): + await entity.run_agent( + mock_context, {"message": "Message", "thread_id": None, "correlation_id": "corr-entity-5"} + ) + + async def test_run_agent_handles_response_without_text_attribute(self) -> None: + """Test that run_agent handles responses without a text attribute.""" + mock_agent = Mock() + + class NoTextResponse(AgentRunResponse): + @property + def text(self) -> str: # type: ignore[override] + raise AttributeError("text attribute missing") + + mock_response = NoTextResponse(messages=[ChatMessage(role="assistant", text="ignored")]) + mock_agent.run = AsyncMock(return_value=mock_response) + + entity = AgentEntity(mock_agent) + mock_context = Mock() + + result = await entity.run_agent( + mock_context, {"message": "Message", "thread_id": "conv-1", "correlation_id": "corr-entity-6"} + ) + + # Should handle gracefully + assert result["status"] == "success" + assert result["response"] == "Error extracting response" + + async def test_run_agent_handles_none_response_text(self) -> None: + """Test that run_agent handles responses with None text.""" + mock_agent = Mock() + mock_agent.run = AsyncMock(return_value=_agent_response(None)) + + entity = AgentEntity(mock_agent) + mock_context = Mock() + + result = await entity.run_agent( + mock_context, {"message": "Message", "thread_id": "conv-1", "correlation_id": "corr-entity-7"} + ) + + assert result["status"] == "success" + assert result["response"] == "No response" + + async def test_run_agent_multiple_conversations(self) -> None: + """Test that run_agent maintains history across multiple messages.""" + mock_agent = Mock() + mock_agent.run = AsyncMock(return_value=_agent_response("Response")) + + entity = AgentEntity(mock_agent) + mock_context = Mock() + + # Send multiple messages + await entity.run_agent( + mock_context, {"message": "Message 1", "thread_id": "conv-1", "correlation_id": "corr-entity-8a"} + ) + await entity.run_agent( + mock_context, {"message": "Message 2", "thread_id": "conv-1", "correlation_id": "corr-entity-8b"} + ) + await entity.run_agent( + mock_context, {"message": "Message 3", "thread_id": "conv-1", "correlation_id": "corr-entity-8c"} + ) + + history = entity.state.conversation_history + assert len(history) == 6 + assert entity.state.message_count == 3 + + +class TestAgentEntityReset: + """Test suite for the reset operation.""" + + def test_reset_clears_conversation_history(self) -> None: + """Test that reset clears the conversation history.""" + mock_agent = Mock() + entity = AgentEntity(mock_agent) + + # Add some history + entity.state.conversation_history = [ + ChatMessage(role="user", text="msg1"), + ChatMessage(role="assistant", text="resp1"), + ] + + mock_context = Mock() + entity.reset(mock_context) + + assert entity.state.conversation_history == [] + + def test_reset_clears_last_response(self) -> None: + """Test that reset clears the last response.""" + mock_agent = Mock() + entity = AgentEntity(mock_agent) + + entity.state.last_response = "Some response" + + mock_context = Mock() + entity.reset(mock_context) + + assert entity.state.last_response is None + + def test_reset_clears_message_count(self) -> None: + """Test that reset clears the message count.""" + mock_agent = Mock() + entity = AgentEntity(mock_agent) + + entity.state.message_count = 10 + + mock_context = Mock() + entity.reset(mock_context) + + assert entity.state.message_count == 0 + + async def test_reset_after_conversation(self) -> None: + """Test reset after a full conversation.""" + mock_agent = Mock() + mock_agent.run = AsyncMock(return_value=_agent_response("Response")) + + entity = AgentEntity(mock_agent) + mock_context = Mock() + + # Have a conversation + await entity.run_agent( + mock_context, {"message": "Message 1", "thread_id": "conv-1", "correlation_id": "corr-entity-10a"} + ) + await entity.run_agent( + mock_context, {"message": "Message 2", "thread_id": "conv-1", "correlation_id": "corr-entity-10b"} + ) + + # Verify state before reset + assert entity.state.message_count == 2 + assert len(entity.state.conversation_history) == 4 + + # Reset + entity.reset(mock_context) + + # Verify state after reset + assert entity.state.message_count == 0 + assert len(entity.state.conversation_history) == 0 + assert entity.state.last_response is None + + +class TestCreateAgentEntity: + """Test suite for the create_agent_entity factory function.""" + + def test_create_agent_entity_returns_callable(self) -> None: + """Test that create_agent_entity returns a callable.""" + mock_agent = Mock() + + entity_function = create_agent_entity(mock_agent) + + assert callable(entity_function) + + def test_entity_function_handles_run_agent(self) -> None: + """Test that the entity function handles the run_agent operation.""" + mock_agent = Mock() + mock_agent.run = AsyncMock(return_value=_agent_response("Response")) + + entity_function = create_agent_entity(mock_agent) + + # Mock context + mock_context = Mock() + mock_context.operation_name = "run_agent" + mock_context.get_input.return_value = { + "message": "Test message", + "thread_id": "conv-123", + "correlation_id": "corr-entity-factory", + } + mock_context.get_state.return_value = None + + # Execute + entity_function(mock_context) + + # Verify result and state were set + assert mock_context.set_result.called + assert mock_context.set_state.called + + def test_entity_function_handles_reset(self) -> None: + """Test that the entity function handles the reset operation.""" + mock_agent = Mock() + + entity_function = create_agent_entity(mock_agent) + + # Mock context with existing state + mock_context = Mock() + mock_context.operation_name = "reset" + mock_context.get_state.return_value = { + "message_count": 5, + "conversation_history": [ + ChatMessage( + role="user", text="test", additional_properties={"timestamp": "2024-01-01T00:00:00Z"} + ).to_dict() + ], + "last_response": "Test", + } + + # Execute + entity_function(mock_context) + + # Verify reset result + assert mock_context.set_result.called + result = mock_context.set_result.call_args[0][0] + assert result["status"] == "reset" + + # Verify state was cleared + assert mock_context.set_state.called + state = mock_context.set_state.call_args[0][0] + assert state["message_count"] == 0 + assert state["conversation_history"] == [] + assert state["last_response"] is None + + def test_entity_function_handles_unknown_operation(self) -> None: + """Test that the entity function handles unknown operations.""" + mock_agent = Mock() + + entity_function = create_agent_entity(mock_agent) + + mock_context = Mock() + mock_context.operation_name = "invalid_operation" + mock_context.get_state.return_value = None + + # Execute + entity_function(mock_context) + + # Verify error result + assert mock_context.set_result.called + result = mock_context.set_result.call_args[0][0] + assert "error" in result + assert "invalid_operation" in result["error"].lower() + + def test_entity_function_creates_new_entity_on_first_call(self) -> None: + """Test that the entity function creates a new entity when no state exists.""" + mock_agent = Mock() + mock_agent.__class__.__name__ = "Agent" + + entity_function = create_agent_entity(mock_agent) + mock_context = Mock() + mock_context.operation_name = "reset" + mock_context.get_state.return_value = None # No existing state + + # Execute + entity_function(mock_context) + + # Verify new entity state was created + assert mock_context.set_result.called + result = mock_context.set_result.call_args[0][0] + assert result["status"] == "reset" + assert mock_context.set_state.called + state = mock_context.set_state.call_args[0][0] + assert state["message_count"] == 0 + assert state["conversation_history"] == [] + + def test_entity_function_restores_existing_state(self) -> None: + """Test that the entity function restores existing state.""" + mock_agent = Mock() + + entity_function = create_agent_entity(mock_agent) + + existing_state = { + "message_count": 5, + "conversation_history": [ + ChatMessage( + role="user", text="msg1", additional_properties={"timestamp": "2024-01-01T00:00:00Z"} + ).to_dict(), + ChatMessage( + role="assistant", text="resp1", additional_properties={"timestamp": "2024-01-01T00:05:00Z"} + ).to_dict(), + ], + "last_response": "resp1", + } + + mock_context = Mock() + mock_context.operation_name = "reset" + mock_context.get_state.return_value = existing_state + + with patch.object(AgentState, "restore_state") as restore_state_mock: + entity_function(mock_context) + + restore_state_mock.assert_called_once_with(existing_state) + + +class TestErrorHandling: + """Test suite for error handling in entities.""" + + async def test_run_agent_handles_agent_exception(self) -> None: + """Test that run_agent handles agent exceptions.""" + mock_agent = Mock() + mock_agent.run = AsyncMock(side_effect=Exception("Agent failed")) + + entity = AgentEntity(mock_agent) + mock_context = Mock() + + result = await entity.run_agent( + mock_context, {"message": "Message", "thread_id": "conv-1", "correlation_id": "corr-entity-error-1"} + ) + + assert result["status"] == "error" + assert "error" in result + assert "Agent failed" in result["error"] + assert result["error_type"] == "Exception" + + async def test_run_agent_handles_value_error(self) -> None: + """Test that run_agent handles ValueError instances.""" + mock_agent = Mock() + mock_agent.run = AsyncMock(side_effect=ValueError("Invalid input")) + + entity = AgentEntity(mock_agent) + mock_context = Mock() + + result = await entity.run_agent( + mock_context, {"message": "Message", "thread_id": "conv-1", "correlation_id": "corr-entity-error-2"} + ) + + assert result["status"] == "error" + assert result["error_type"] == "ValueError" + assert "Invalid input" in result["error"] + + async def test_run_agent_handles_timeout_error(self) -> None: + """Test that run_agent handles TimeoutError instances.""" + mock_agent = Mock() + mock_agent.run = AsyncMock(side_effect=TimeoutError("Request timeout")) + + entity = AgentEntity(mock_agent) + mock_context = Mock() + + result = await entity.run_agent( + mock_context, {"message": "Message", "thread_id": "conv-1", "correlation_id": "corr-entity-error-3"} + ) + + assert result["status"] == "error" + assert result["error_type"] == "TimeoutError" + + def test_entity_function_handles_exception_in_operation(self) -> None: + """Test that the entity function handles exceptions gracefully.""" + mock_agent = Mock() + + entity_function = create_agent_entity(mock_agent) + + mock_context = Mock() + mock_context.operation_name = "run_agent" + mock_context.get_input.side_effect = Exception("Input error") + mock_context.get_state.return_value = None + + # Execute - should not raise + entity_function(mock_context) + + # Verify error was set + assert mock_context.set_result.called + result = mock_context.set_result.call_args[0][0] + assert "error" in result + + async def test_run_agent_preserves_message_on_error(self) -> None: + """Test that run_agent preserves message information on error.""" + mock_agent = Mock() + mock_agent.run = AsyncMock(side_effect=Exception("Error")) + + entity = AgentEntity(mock_agent) + mock_context = Mock() + + result = await entity.run_agent( + mock_context, + {"message": "Test message", "thread_id": "conv-123", "correlation_id": "corr-entity-error-4"}, + ) + + # Even on error, message info should be preserved + assert result["message"] == "Test message" + assert result["thread_id"] == "conv-123" + assert result["status"] == "error" + + +class TestConversationHistory: + """Test suite for conversation history tracking.""" + + async def test_conversation_history_has_timestamps(self) -> None: + """Test that conversation history entries include timestamps.""" + mock_agent = Mock() + mock_agent.run = AsyncMock(return_value=_agent_response("Response")) + + entity = AgentEntity(mock_agent) + mock_context = Mock() + + await entity.run_agent( + mock_context, {"message": "Message", "thread_id": "conv-1", "correlation_id": "corr-entity-history-1"} + ) + + # Check both user and assistant messages have timestamps + for entry in entity.state.conversation_history: + timestamp = entry.additional_properties.get("timestamp") + assert timestamp is not None + # Verify timestamp is in ISO format + datetime.fromisoformat(timestamp) + + async def test_conversation_history_ordering(self) -> None: + """Test that conversation history maintains the correct order.""" + mock_agent = Mock() + + entity = AgentEntity(mock_agent) + mock_context = Mock() + + # Send multiple messages with different responses + mock_agent.run = AsyncMock(return_value=_agent_response("Response 1")) + await entity.run_agent( + mock_context, + {"message": "Message 1", "thread_id": "conv-1", "correlation_id": "corr-entity-history-2a"}, + ) + + mock_agent.run = AsyncMock(return_value=_agent_response("Response 2")) + await entity.run_agent( + mock_context, + {"message": "Message 2", "thread_id": "conv-1", "correlation_id": "corr-entity-history-2b"}, + ) + + mock_agent.run = AsyncMock(return_value=_agent_response("Response 3")) + await entity.run_agent( + mock_context, + {"message": "Message 3", "thread_id": "conv-1", "correlation_id": "corr-entity-history-2c"}, + ) + + # Verify order + history = entity.state.conversation_history + assert history[0].text == "Message 1" + assert history[1].text == "Response 1" + assert history[2].text == "Message 2" + assert history[3].text == "Response 2" + assert history[4].text == "Message 3" + assert history[5].text == "Response 3" + + async def test_conversation_history_role_alternation(self) -> None: + """Test that conversation history alternates between user and assistant roles.""" + mock_agent = Mock() + mock_agent.run = AsyncMock(return_value=_agent_response("Response")) + + entity = AgentEntity(mock_agent) + mock_context = Mock() + + await entity.run_agent( + mock_context, + {"message": "Message 1", "thread_id": "conv-1", "correlation_id": "corr-entity-history-3a"}, + ) + await entity.run_agent( + mock_context, + {"message": "Message 2", "thread_id": "conv-1", "correlation_id": "corr-entity-history-3b"}, + ) + + # Check role alternation + history = entity.state.conversation_history + assert _role_value(history[0]) == "user" + assert _role_value(history[1]) == "assistant" + assert _role_value(history[2]) == "user" + assert _role_value(history[3]) == "assistant" + + +class TestRunRequestSupport: + """Test suite for RunRequest support in entities.""" + + async def test_run_agent_with_run_request_object(self) -> None: + """Test run_agent with a RunRequest object.""" + mock_agent = Mock() + mock_agent.run = AsyncMock(return_value=_agent_response("Response")) + + entity = AgentEntity(mock_agent) + mock_context = Mock() + + request = RunRequest( + message="Test message", + thread_id="conv-123", + role=Role.USER, + enable_tool_calls=True, + correlation_id="corr-runreq-1", + ) + + result = await entity.run_agent(mock_context, request) + + assert result["status"] == "success" + assert result["response"] == "Response" + assert result["message"] == "Test message" + assert result["thread_id"] == "conv-123" + + async def test_run_agent_with_dict_request(self) -> None: + """Test run_agent with a dictionary request.""" + mock_agent = Mock() + mock_agent.run = AsyncMock(return_value=_agent_response("Response")) + + entity = AgentEntity(mock_agent) + mock_context = Mock() + + request_dict = { + "message": "Test message", + "thread_id": "conv-456", + "role": "system", + "enable_tool_calls": False, + "correlation_id": "corr-runreq-2", + } + + result = await entity.run_agent(mock_context, request_dict) + + assert result["status"] == "success" + assert result["message"] == "Test message" + assert result["thread_id"] == "conv-456" + + async def test_run_agent_with_string_raises_without_correlation(self) -> None: + """Test that run_agent rejects legacy string input without correlation ID.""" + mock_agent = Mock() + mock_agent.run = AsyncMock(return_value=_agent_response("Response")) + + entity = AgentEntity(mock_agent) + mock_context = Mock() + + with pytest.raises(ValueError): + await entity.run_agent(mock_context, "Simple message") + + async def test_run_agent_stores_role_in_history(self) -> None: + """Test that run_agent stores the role in conversation history.""" + mock_agent = Mock() + mock_agent.run = AsyncMock(return_value=_agent_response("Response")) + + entity = AgentEntity(mock_agent) + mock_context = Mock() + + # Send as system role + request = RunRequest( + message="System message", + thread_id="conv-runreq-3", + role=Role.SYSTEM, + correlation_id="corr-runreq-3", + ) + + await entity.run_agent(mock_context, request) + + # Check that system role was stored + history = entity.state.conversation_history + assert _role_value(history[0]) == "system" + assert history[0].text == "System message" + + async def test_run_agent_with_response_format(self) -> None: + """Test run_agent with a JSON response format.""" + mock_agent = Mock() + # Return JSON response + mock_agent.run = AsyncMock(return_value=_agent_response('{"answer": 42}')) + + entity = AgentEntity(mock_agent) + mock_context = Mock() + + request = RunRequest( + message="What is the answer?", + thread_id="conv-runreq-4", + response_format=EntityStructuredResponse, + correlation_id="corr-runreq-4", + ) + + result = await entity.run_agent(mock_context, request) + + assert result["status"] == "success" + # Should have structured_response + if "structured_response" in result: + assert result["structured_response"]["answer"] == 42 + + async def test_run_agent_disable_tool_calls(self) -> None: + """Test run_agent with tool calls disabled.""" + mock_agent = Mock() + mock_agent.run = AsyncMock(return_value=_agent_response("Response")) + + entity = AgentEntity(mock_agent) + mock_context = Mock() + + request = RunRequest( + message="Test", thread_id="conv-runreq-5", enable_tool_calls=False, correlation_id="corr-runreq-5" + ) + + result = await entity.run_agent(mock_context, request) + + assert result["status"] == "success" + # Agent should have been called (tool disabling is framework-dependent) + mock_agent.run.assert_called_once() + + async def test_entity_function_with_run_request_dict(self) -> None: + """Test that the entity function handles the RunRequest dict format.""" + mock_agent = Mock() + mock_agent.run = AsyncMock(return_value=_agent_response("Response")) + + entity_function = create_agent_entity(mock_agent) + + mock_context = Mock() + mock_context.operation_name = "run_agent" + mock_context.get_input.return_value = { + "message": "Test message", + "thread_id": "conv-789", + "role": "user", + "enable_tool_calls": True, + "correlation_id": "corr-runreq-6", + } + mock_context.get_state.return_value = None + + await asyncio.to_thread(entity_function, mock_context) + + # Verify result was set + assert mock_context.set_result.called + result = mock_context.set_result.call_args[0][0] + assert result["status"] == "success" + assert result["message"] == "Test message" + + +if __name__ == "__main__": + pytest.main([__file__, "-v", "--tb=short"]) diff --git a/python/packages/azurefunctions/tests/test_models.py b/python/packages/azurefunctions/tests/test_models.py new file mode 100644 index 0000000000..bb802956ff --- /dev/null +++ b/python/packages/azurefunctions/tests/test_models.py @@ -0,0 +1,470 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Unit tests for data models (AgentSessionId, RunRequest, AgentResponse).""" + +import azure.durable_functions as df +import pytest +from agent_framework import Role +from pydantic import BaseModel + +from agent_framework_azurefunctions._models import AgentResponse, AgentSessionId, RunRequest + + +class ModuleStructuredResponse(BaseModel): + value: int + + +class TestAgentSessionId: + """Test suite for AgentSessionId.""" + + def test_init_creates_session_id(self) -> None: + """Test that AgentSessionId initializes correctly.""" + session_id = AgentSessionId(name="AgentEntity", key="test-key-123") + + assert session_id.name == "AgentEntity" + assert session_id.key == "test-key-123" + + def test_with_random_key_generates_guid(self) -> None: + """Test that with_random_key generates a GUID.""" + session_id = AgentSessionId.with_random_key(name="AgentEntity") + + assert session_id.name == "AgentEntity" + assert len(session_id.key) == 32 # UUID hex is 32 chars + # Verify it's a valid hex string + int(session_id.key, 16) + + def test_with_random_key_unique_keys(self) -> None: + """Test that with_random_key generates unique keys.""" + session_id1 = AgentSessionId.with_random_key(name="AgentEntity") + session_id2 = AgentSessionId.with_random_key(name="AgentEntity") + + assert session_id1.key != session_id2.key + + def test_to_entity_id_conversion(self) -> None: + """Test conversion to EntityId.""" + session_id = AgentSessionId(name="AgentEntity", key="test-key") + entity_id = session_id.to_entity_id() + + assert isinstance(entity_id, df.EntityId) + assert entity_id.name == "dafx-AgentEntity" + assert entity_id.key == "test-key" + + def test_from_entity_id_conversion(self) -> None: + """Test creation from EntityId.""" + entity_id = df.EntityId(name="dafx-AgentEntity", key="test-key") + session_id = AgentSessionId.from_entity_id(entity_id) + + assert isinstance(session_id, AgentSessionId) + assert session_id.name == "AgentEntity" + assert session_id.key == "test-key" + + def test_round_trip_entity_id_conversion(self) -> None: + """Test round-trip conversion to and from EntityId.""" + original = AgentSessionId(name="AgentEntity", key="test-key") + entity_id = original.to_entity_id() + restored = AgentSessionId.from_entity_id(entity_id) + + assert restored.name == original.name + assert restored.key == original.key + + def test_str_representation(self) -> None: + """Test string representation.""" + session_id = AgentSessionId(name="AgentEntity", key="test-key-123") + str_repr = str(session_id) + + assert str_repr == "@AgentEntity@test-key-123" + + def test_repr_representation(self) -> None: + """Test repr representation.""" + session_id = AgentSessionId(name="AgentEntity", key="test-key") + repr_str = repr(session_id) + + assert "AgentSessionId" in repr_str + assert "AgentEntity" in repr_str + assert "test-key" in repr_str + + def test_parse_valid_session_id(self) -> None: + """Test parsing valid session ID string.""" + session_id = AgentSessionId.parse("@AgentEntity@test-key-123") + + assert session_id.name == "AgentEntity" + assert session_id.key == "test-key-123" + + def test_parse_invalid_format_no_prefix(self) -> None: + """Test parsing invalid format without @ prefix.""" + with pytest.raises(ValueError) as exc_info: + AgentSessionId.parse("AgentEntity@test-key") + + assert "Invalid agent session ID format" in str(exc_info.value) + + def test_parse_invalid_format_single_part(self) -> None: + """Test parsing invalid format with single part.""" + with pytest.raises(ValueError) as exc_info: + AgentSessionId.parse("@AgentEntity") + + assert "Invalid agent session ID format" in str(exc_info.value) + + def test_parse_with_multiple_at_signs_in_key(self) -> None: + """Test parsing with @ signs in the key.""" + session_id = AgentSessionId.parse("@AgentEntity@key-with@symbols") + + assert session_id.name == "AgentEntity" + assert session_id.key == "key-with@symbols" + + def test_parse_round_trip(self) -> None: + """Test round-trip parse and string conversion.""" + original = AgentSessionId(name="AgentEntity", key="test-key") + str_repr = str(original) + parsed = AgentSessionId.parse(str_repr) + + assert parsed.name == original.name + assert parsed.key == original.key + + def test_to_entity_name_adds_prefix(self) -> None: + """Test that to_entity_name adds the dafx- prefix.""" + entity_name = AgentSessionId.to_entity_name("TestAgent") + assert entity_name == "dafx-TestAgent" + + def test_from_entity_id_strips_prefix(self) -> None: + """Test that from_entity_id strips the dafx- prefix.""" + entity_id = df.EntityId(name="dafx-TestAgent", key="key123") + session_id = AgentSessionId.from_entity_id(entity_id) + + assert session_id.name == "TestAgent" + assert session_id.key == "key123" + + def test_from_entity_id_raises_without_prefix(self) -> None: + """Test that from_entity_id raises ValueError when entity name lacks the prefix.""" + entity_id = df.EntityId(name="TestAgent", key="key123") + + with pytest.raises(ValueError) as exc_info: + AgentSessionId.from_entity_id(entity_id) + + assert "not a valid agent session ID" in str(exc_info.value) + assert "dafx-" in str(exc_info.value) + + +class TestRunRequest: + """Test suite for RunRequest.""" + + def test_init_with_defaults(self) -> None: + """Test RunRequest initialization with defaults.""" + request = RunRequest(message="Hello", thread_id="thread-default") + + assert request.message == "Hello" + assert request.role == Role.USER + assert request.response_format is None + assert request.enable_tool_calls is True + assert request.thread_id == "thread-default" + + def test_init_with_all_fields(self) -> None: + """Test RunRequest initialization with all fields.""" + schema = ModuleStructuredResponse + request = RunRequest( + message="Hello", + thread_id="thread-123", + role=Role.SYSTEM, + response_format=schema, + enable_tool_calls=False, + ) + + assert request.message == "Hello" + assert request.role == Role.SYSTEM + assert request.response_format is schema + assert request.enable_tool_calls is False + assert request.thread_id == "thread-123" + + def test_init_coerces_string_role(self) -> None: + """Ensure string role values are coerced into Role instances.""" + request = RunRequest(message="Hello", thread_id="thread-str-role", role="system") # type: ignore[arg-type] + + assert request.role == Role.SYSTEM + + def test_to_dict_with_defaults(self) -> None: + """Test to_dict with default values.""" + request = RunRequest(message="Test message", thread_id="thread-to-dict") + data = request.to_dict() + + assert data["message"] == "Test message" + assert data["enable_tool_calls"] is True + assert data["role"] == "user" + assert "response_format" not in data or data["response_format"] is None + assert data["thread_id"] == "thread-to-dict" + + def test_to_dict_with_all_fields(self) -> None: + """Test to_dict with all fields.""" + schema = ModuleStructuredResponse + request = RunRequest( + message="Hello", + thread_id="thread-456", + role=Role.ASSISTANT, + response_format=schema, + enable_tool_calls=False, + ) + data = request.to_dict() + + assert data["message"] == "Hello" + assert data["role"] == "assistant" + assert data["response_format"]["__response_schema_type__"] == "pydantic_model" + assert data["response_format"]["module"] == schema.__module__ + assert data["response_format"]["qualname"] == schema.__qualname__ + assert data["enable_tool_calls"] is False + assert data["thread_id"] == "thread-456" + + def test_from_dict_with_defaults(self) -> None: + """Test from_dict with minimal data.""" + data = {"message": "Hello", "thread_id": "thread-from-dict"} + request = RunRequest.from_dict(data) + + assert request.message == "Hello" + assert request.role == Role.USER + assert request.enable_tool_calls is True + assert request.thread_id == "thread-from-dict" + + def test_from_dict_with_all_fields(self) -> None: + """Test from_dict with all fields.""" + data = { + "message": "Test", + "role": "system", + "response_format": { + "__response_schema_type__": "pydantic_model", + "module": ModuleStructuredResponse.__module__, + "qualname": ModuleStructuredResponse.__qualname__, + }, + "enable_tool_calls": False, + "thread_id": "thread-789", + } + request = RunRequest.from_dict(data) + + assert request.message == "Test" + assert request.role == Role.SYSTEM + assert request.response_format is ModuleStructuredResponse + assert request.enable_tool_calls is False + assert request.thread_id == "thread-789" + + def test_from_dict_with_unknown_role_preserves_value(self) -> None: + """Test from_dict keeps custom roles intact.""" + data = {"message": "Test", "role": "reviewer", "thread_id": "thread-with-custom-role"} + request = RunRequest.from_dict(data) + + assert request.role.value == "reviewer" + assert request.role != Role.USER + + def test_from_dict_empty_message(self) -> None: + """Test from_dict with empty message.""" + data = {"thread_id": "thread-empty"} + request = RunRequest.from_dict(data) + + assert request.message == "" + assert request.role == Role.USER + assert request.thread_id == "thread-empty" + + def test_round_trip_dict_conversion(self) -> None: + """Test round-trip to_dict and from_dict.""" + original = RunRequest( + message="Test message", + thread_id="thread-123", + role=Role.SYSTEM, + response_format=ModuleStructuredResponse, + enable_tool_calls=False, + ) + + data = original.to_dict() + restored = RunRequest.from_dict(data) + + assert restored.message == original.message + assert restored.role == original.role + assert restored.response_format is ModuleStructuredResponse + assert restored.enable_tool_calls == original.enable_tool_calls + assert restored.thread_id == original.thread_id + + def test_round_trip_with_pydantic_response_format(self) -> None: + """Ensure Pydantic response formats serialize and deserialize properly.""" + original = RunRequest( + message="Structured", + thread_id="thread-pydantic", + response_format=ModuleStructuredResponse, + ) + + data = original.to_dict() + + assert data["response_format"]["__response_schema_type__"] == "pydantic_model" + assert data["response_format"]["module"] == ModuleStructuredResponse.__module__ + assert data["response_format"]["qualname"] == ModuleStructuredResponse.__qualname__ + + restored = RunRequest.from_dict(data) + assert restored.response_format is ModuleStructuredResponse + + def test_init_with_correlation_id(self) -> None: + """Test RunRequest initialization with correlation_id.""" + request = RunRequest(message="Test message", thread_id="thread-corr-init", correlation_id="corr-123") + + assert request.message == "Test message" + assert request.correlation_id == "corr-123" + + def test_to_dict_with_correlation_id(self) -> None: + """Test to_dict includes correlation_id.""" + request = RunRequest(message="Test", thread_id="thread-corr-to-dict", correlation_id="corr-456") + data = request.to_dict() + + assert data["message"] == "Test" + assert data["correlation_id"] == "corr-456" + + def test_from_dict_with_correlation_id(self) -> None: + """Test from_dict with correlation_id.""" + data = {"message": "Test", "correlation_id": "corr-789", "thread_id": "thread-corr-from-dict"} + request = RunRequest.from_dict(data) + + assert request.message == "Test" + assert request.correlation_id == "corr-789" + assert request.thread_id == "thread-corr-from-dict" + + def test_round_trip_with_correlation_id(self) -> None: + """Test round-trip to_dict and from_dict with correlation_id.""" + original = RunRequest( + message="Test message", + thread_id="thread-123", + role=Role.SYSTEM, + correlation_id="corr-123", + ) + + data = original.to_dict() + restored = RunRequest.from_dict(data) + + assert restored.message == original.message + assert restored.role == original.role + assert restored.correlation_id == original.correlation_id + assert restored.thread_id == original.thread_id + + +class TestAgentResponse: + """Test suite for AgentResponse.""" + + def test_init_with_required_fields(self) -> None: + """Test AgentResponse initialization with required fields.""" + response = AgentResponse( + response="Test response", message="Test message", thread_id="thread-123", status="success" + ) + + assert response.response == "Test response" + assert response.message == "Test message" + assert response.thread_id == "thread-123" + assert response.status == "success" + assert response.message_count == 0 + assert response.error is None + assert response.error_type is None + assert response.structured_response is None + + def test_init_with_all_fields(self) -> None: + """Test AgentResponse initialization with all fields.""" + structured = {"answer": "42"} + response = AgentResponse( + response=None, + message="What is the answer?", + thread_id="thread-456", + status="success", + message_count=5, + error=None, + error_type=None, + structured_response=structured, + ) + + assert response.response is None + assert response.structured_response == structured + assert response.message_count == 5 + + def test_to_dict_with_text_response(self) -> None: + """Test to_dict with text response.""" + response = AgentResponse( + response="Text response", message="Message", thread_id="thread-1", status="success", message_count=3 + ) + data = response.to_dict() + + assert data["response"] == "Text response" + assert data["message"] == "Message" + assert data["thread_id"] == "thread-1" + assert data["status"] == "success" + assert data["message_count"] == 3 + assert "structured_response" not in data + assert "error" not in data + assert "error_type" not in data + + def test_to_dict_with_structured_response(self) -> None: + """Test to_dict with structured response.""" + structured = {"answer": 42, "confidence": 0.95} + response = AgentResponse( + response=None, + message="Question", + thread_id="thread-2", + status="success", + structured_response=structured, + ) + data = response.to_dict() + + assert data["structured_response"] == structured + assert "response" not in data + + def test_to_dict_with_error(self) -> None: + """Test to_dict with error.""" + response = AgentResponse( + response=None, + message="Failed message", + thread_id="thread-3", + status="error", + error="Something went wrong", + error_type="ValueError", + ) + data = response.to_dict() + + assert data["status"] == "error" + assert data["error"] == "Something went wrong" + assert data["error_type"] == "ValueError" + + def test_to_dict_prefers_structured_over_text(self) -> None: + """Test to_dict prefers structured_response over response.""" + structured = {"result": "structured"} + response = AgentResponse( + response="Text response", + message="Message", + thread_id="thread-4", + status="success", + structured_response=structured, + ) + data = response.to_dict() + + assert "structured_response" in data + assert data["structured_response"] == structured + # Text response should not be included when structured is present + assert "response" not in data + + +class TestModelIntegration: + """Test suite for integration between models.""" + + def test_run_request_with_session_id(self) -> None: + """Test using RunRequest with AgentSessionId.""" + session_id = AgentSessionId.with_random_key("AgentEntity") + request = RunRequest(message="Test message", thread_id=str(session_id)) + + assert request.thread_id is not None + assert request.thread_id == str(session_id) + assert request.thread_id.startswith("@AgentEntity@") + + def test_response_from_run_request(self) -> None: + """Test creating AgentResponse from RunRequest.""" + request = RunRequest(message="What is 2+2?", thread_id="thread-123", role=Role.USER) + + response = AgentResponse( + response="4", + message=request.message, + thread_id=request.thread_id, + status="success", + message_count=1, + ) + + assert response.message == request.message + assert response.thread_id == request.thread_id + + +if __name__ == "__main__": + pytest.main([__file__, "-v", "--tb=short"]) diff --git a/python/packages/azurefunctions/tests/test_multi_agent.py b/python/packages/azurefunctions/tests/test_multi_agent.py new file mode 100644 index 0000000000..0c0be7f35d --- /dev/null +++ b/python/packages/azurefunctions/tests/test_multi_agent.py @@ -0,0 +1,150 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Unit tests for multi-agent support in AgentFunctionApp.""" + +from unittest.mock import Mock + +import pytest + +from agent_framework_azurefunctions import AgentFunctionApp + + +class TestMultiAgentInit: + """Test suite for multi-agent initialization.""" + + def test_init_with_agents_list(self) -> None: + """Test initialization with list of agents.""" + agent1 = Mock() + agent1.name = "Agent1" + agent2 = Mock() + agent2.name = "Agent2" + + app = AgentFunctionApp(agents=[agent1, agent2]) + + assert len(app.agents) == 2 + assert "Agent1" in app.agents + assert "Agent2" in app.agents + assert app.agents["Agent1"] == agent1 + assert app.agents["Agent2"] == agent2 + + def test_init_with_empty_agents_list(self) -> None: + """Test initialization with empty list of agents.""" + app = AgentFunctionApp(agents=[]) + + assert len(app.agents) == 0 + + def test_init_with_no_agents(self) -> None: + """Test initialization without any agents.""" + app = AgentFunctionApp() + + assert len(app.agents) == 0 + + def test_init_with_duplicate_agent_names(self) -> None: + """Test initialization with agents having the same name raises error.""" + agent1 = Mock() + agent1.name = "TestAgent" + agent2 = Mock() + agent2.name = "TestAgent" + + with pytest.raises(ValueError, match="already registered"): + AgentFunctionApp(agents=[agent1, agent2]) + + def test_init_with_agent_without_name(self) -> None: + """Test initialization with agent missing name attribute raises error.""" + agent1 = Mock() + agent1.name = "Agent1" + agent2 = Mock(spec=[]) # Mock without name attribute + + with pytest.raises(ValueError, match="does not have a 'name' attribute"): + AgentFunctionApp(agents=[agent1, agent2]) + + +class TestAddAgentMethod: + """Test suite for add_agent() method.""" + + def test_add_agent_to_empty_app(self) -> None: + """Test adding agent to app initialized without agents.""" + app = AgentFunctionApp() + + agent = Mock() + agent.name = "NewAgent" + + app.add_agent(agent) + + assert len(app.agents) == 1 + assert "NewAgent" in app.agents + assert app.agents["NewAgent"] == agent + + def test_add_multiple_agents(self) -> None: + """Test adding multiple agents sequentially.""" + app = AgentFunctionApp() + + agent1 = Mock() + agent1.name = "Agent1" + agent2 = Mock() + agent2.name = "Agent2" + + app.add_agent(agent1) + app.add_agent(agent2) + + assert len(app.agents) == 2 + assert "Agent1" in app.agents + assert "Agent2" in app.agents + + def test_add_agent_with_duplicate_name_raises_error(self) -> None: + """Test that adding agent with duplicate name raises ValueError.""" + agent1 = Mock() + agent1.name = "MyAgent" + agent2 = Mock() + agent2.name = "MyAgent" + + app = AgentFunctionApp(agents=[agent1]) + + # Try to add another agent with the same name + with pytest.raises(ValueError, match="already registered"): + app.add_agent(agent2) + + def test_add_agent_to_app_with_existing_agents(self) -> None: + """Test adding agent to app that already has agents.""" + agent1 = Mock() + agent1.name = "Agent1" + agent2 = Mock() + agent2.name = "Agent2" + + app = AgentFunctionApp(agents=[agent1]) + app.add_agent(agent2) + + assert len(app.agents) == 2 + assert "Agent1" in app.agents + assert "Agent2" in app.agents + + def test_add_agent_without_name_raises_error(self) -> None: + """Test that adding agent without name attribute raises error.""" + app = AgentFunctionApp() + + agent = Mock(spec=[]) # Mock without name attribute + + with pytest.raises(ValueError, match="does not have a 'name' attribute"): + app.add_agent(agent) + + +class TestHealthCheckWithMultipleAgents: + """Test suite for health check with multiple agents.""" + + def test_health_check_returns_all_agents(self) -> None: + """Test that health check returns information about all agents.""" + agent1 = Mock() + agent1.name = "Agent1" + agent2 = Mock() + agent2.name = "Agent2" + + app = AgentFunctionApp(agents=[agent1, agent2]) + + # Note: We can't easily test the actual health check endpoint without running the app + # But we can verify the agents dictionary is properly populated + assert len(app.agents) == 2 + assert app.enable_health_check is True + + +if __name__ == "__main__": + pytest.main([__file__, "-v", "--tb=short"]) diff --git a/python/packages/azurefunctions/tests/test_orchestration.py b/python/packages/azurefunctions/tests/test_orchestration.py new file mode 100644 index 0000000000..c30f9f0bec --- /dev/null +++ b/python/packages/azurefunctions/tests/test_orchestration.py @@ -0,0 +1,442 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Unit tests for orchestration support (DurableAIAgent).""" + +from typing import Any +from unittest.mock import Mock + +import pytest +from agent_framework import AgentThread + +from agent_framework_azurefunctions import AgentFunctionApp, DurableAIAgent +from agent_framework_azurefunctions._models import AgentSessionId, DurableAgentThread + + +def _app_with_registered_agents(*agent_names: str) -> AgentFunctionApp: + app = AgentFunctionApp(enable_health_check=False, enable_http_endpoints=False) + for name in agent_names: + agent = Mock() + agent.name = name + app.add_agent(agent) + return app + + +class TestDurableAIAgent: + """Test suite for DurableAIAgent wrapper.""" + + def test_init(self) -> None: + """Test DurableAIAgent initialization.""" + mock_context = Mock() + mock_context.instance_id = "test-instance-123" + + agent = DurableAIAgent(mock_context, "TestAgent") + + assert agent.context == mock_context + assert agent.agent_name == "TestAgent" + + def test_implements_agent_protocol(self) -> None: + """Test that DurableAIAgent implements AgentProtocol.""" + from agent_framework import AgentProtocol + + mock_context = Mock() + agent = DurableAIAgent(mock_context, "TestAgent") + + # Check that agent satisfies AgentProtocol + assert isinstance(agent, AgentProtocol) + + def test_has_agent_protocol_properties(self) -> None: + """Test that DurableAIAgent has AgentProtocol properties.""" + mock_context = Mock() + agent = DurableAIAgent(mock_context, "TestAgent") + + # AgentProtocol properties + assert hasattr(agent, "id") + assert hasattr(agent, "name") + assert hasattr(agent, "description") + assert hasattr(agent, "display_name") + + # Verify values + assert agent.name == "TestAgent" + assert agent.description == "Durable agent proxy for TestAgent" + assert agent.display_name == "TestAgent" + assert agent.id is not None # Auto-generated UUID + + def test_get_new_thread(self) -> None: + """Test creating a new agent thread.""" + mock_context = Mock() + mock_context.instance_id = "test-instance-456" + mock_context.new_uuid = Mock(return_value="test-guid-456") + + agent = DurableAIAgent(mock_context, "WriterAgent") + thread = agent.get_new_thread() + + assert isinstance(thread, DurableAgentThread) + assert thread.session_id is not None + session_id = thread.session_id + assert isinstance(session_id, AgentSessionId) + assert session_id.name == "WriterAgent" + assert session_id.key == "test-guid-456" + mock_context.new_uuid.assert_called_once() + + def test_get_new_thread_deterministic(self) -> None: + """Test that get_new_thread creates deterministic session IDs.""" + + mock_context = Mock() + mock_context.instance_id = "test-instance-789" + mock_context.new_uuid = Mock(side_effect=["session-guid-1", "session-guid-2"]) + + agent = DurableAIAgent(mock_context, "EditorAgent") + + # Create multiple threads - they should have unique session IDs + thread1 = agent.get_new_thread() + thread2 = agent.get_new_thread() + + assert isinstance(thread1, DurableAgentThread) + assert isinstance(thread2, DurableAgentThread) + + session_id1 = thread1.session_id + session_id2 = thread2.session_id + assert session_id1 is not None and session_id2 is not None + assert isinstance(session_id1, AgentSessionId) + assert isinstance(session_id2, AgentSessionId) + assert session_id1.name == "EditorAgent" + assert session_id2.name == "EditorAgent" + assert session_id1.key == "session-guid-1" + assert session_id2.key == "session-guid-2" + assert mock_context.new_uuid.call_count == 2 + + def test_run_creates_entity_call(self) -> None: + """Test that run() creates proper entity call and returns a Task.""" + mock_context = Mock() + mock_context.instance_id = "test-instance-001" + mock_context.new_uuid = Mock(side_effect=["thread-guid", "correlation-guid"]) + + # Mock call_entity to return a Task-like object + mock_task = Mock() + mock_task._is_scheduled = False # Task attribute that orchestration checks + + mock_context.call_entity = Mock(return_value=mock_task) + + agent = DurableAIAgent(mock_context, "TestAgent") + + # Create thread + thread = agent.get_new_thread() + + # Call run() - it should return the Task directly + task = agent.run(messages="Test message", thread=thread, enable_tool_calls=True) + + # Verify run() returns the Task from call_entity + assert task == mock_task + + # Verify call_entity was called with correct parameters + assert mock_context.call_entity.called + call_args = mock_context.call_entity.call_args + entity_id, operation, request = call_args[0] + + assert operation == "run_agent" + assert request["message"] == "Test message" + assert request["enable_tool_calls"] is True + assert "correlation_id" in request + assert request["correlation_id"] == "correlation-guid" + assert "thread_id" in request + assert request["thread_id"] == "thread-guid" + + def test_run_without_thread(self) -> None: + """Test that run() works without explicit thread (creates unique session key).""" + mock_context = Mock() + mock_context.instance_id = "test-instance-002" + # Two calls to new_uuid: one for session_key, one for correlation_id + mock_context.new_uuid = Mock(side_effect=["auto-generated-guid", "correlation-guid"]) + + mock_task = Mock() + mock_task._is_scheduled = False + mock_context.call_entity = Mock(return_value=mock_task) + + agent = DurableAIAgent(mock_context, "TestAgent") + + # Call without thread + task = agent.run(messages="Test message") + + assert task == mock_task + + # Verify the entity ID uses the auto-generated GUID with dafx- prefix + call_args = mock_context.call_entity.call_args + entity_id = call_args[0][0] + assert entity_id.name == "dafx-TestAgent" + assert entity_id.key == "auto-generated-guid" + # Should be called twice: once for session_key, once for correlation_id + assert mock_context.new_uuid.call_count == 2 + + def test_run_with_response_format(self) -> None: + """Test that run() passes response format correctly.""" + mock_context = Mock() + mock_context.instance_id = "test-instance-003" + + mock_task = Mock() + mock_task._is_scheduled = False + mock_context.call_entity = Mock(return_value=mock_task) + + agent = DurableAIAgent(mock_context, "TestAgent") + + from pydantic import BaseModel + + class SampleSchema(BaseModel): + key: str + + # Create thread and call + thread = agent.get_new_thread() + + task = agent.run(messages="Test message", thread=thread, response_format=SampleSchema) + + assert task == mock_task + + # Verify schema was passed in the call_entity arguments + call_args = mock_context.call_entity.call_args + input_data = call_args[0][2] # Third argument is input_data + assert "response_format" in input_data + assert input_data["response_format"]["__response_schema_type__"] == "pydantic_model" + assert input_data["response_format"]["module"] == SampleSchema.__module__ + assert input_data["response_format"]["qualname"] == SampleSchema.__qualname__ + + def test_messages_to_string(self) -> None: + """Test converting ChatMessage list to string.""" + from agent_framework import ChatMessage + + mock_context = Mock() + agent = DurableAIAgent(mock_context, "TestAgent") + + messages = [ + ChatMessage(role="user", text="Hello"), + ChatMessage(role="assistant", text="Hi there"), + ChatMessage(role="user", text="How are you?"), + ] + + result = agent._messages_to_string(messages) + + assert result == "Hello\nHi there\nHow are you?" + + def test_run_with_chat_message(self) -> None: + """Test that run() handles ChatMessage input.""" + from agent_framework import ChatMessage + + mock_context = Mock() + mock_context.new_uuid = Mock(side_effect=["thread-guid", "correlation-guid"]) + mock_task = Mock() + mock_context.call_entity = Mock(return_value=mock_task) + + agent = DurableAIAgent(mock_context, "TestAgent") + thread = agent.get_new_thread() + + # Call with ChatMessage + msg = ChatMessage(role="user", text="Hello") + task = agent.run(messages=msg, thread=thread) + + assert task == mock_task + + # Verify message was converted to string + call_args = mock_context.call_entity.call_args + request = call_args[0][2] + assert request["message"] == "Hello" + + def test_run_stream_raises_not_implemented(self) -> None: + """Test that run_stream() method raises NotImplementedError.""" + mock_context = Mock() + agent = DurableAIAgent(mock_context, "TestAgent") + + with pytest.raises(NotImplementedError) as exc_info: + agent.run_stream("Test message") + + error_msg = str(exc_info.value) + assert "Streaming is not supported" in error_msg + + def test_entity_id_format(self) -> None: + """Test that EntityId is created with correct format (name, key).""" + from azure.durable_functions import EntityId + + mock_context = Mock() + mock_context.new_uuid = Mock(return_value="test-guid-789") + mock_context.call_entity = Mock(return_value=Mock()) + + agent = DurableAIAgent(mock_context, "WriterAgent") + thread = agent.get_new_thread() + + # Call run() to trigger entity ID creation + agent.run("Test", thread=thread) + + # Verify call_entity was called with correct EntityId + call_args = mock_context.call_entity.call_args + entity_id = call_args[0][0] + + # EntityId should be EntityId(name="dafx-WriterAgent", key="test-guid-789") + # Which formats as "@dafx-writeragent@test-guid-789" + assert isinstance(entity_id, EntityId) + assert entity_id.name == "dafx-WriterAgent" + assert entity_id.key == "test-guid-789" + assert str(entity_id) == "@dafx-writeragent@test-guid-789" + + +class TestAgentFunctionAppGetAgent: + """Test suite for AgentFunctionApp.get_agent.""" + + def test_get_agent_method(self) -> None: + """Test get_agent method creates DurableAIAgent for registered agent.""" + app = _app_with_registered_agents("MyAgent") + mock_context = Mock() + mock_context.instance_id = "test-instance-100" + + agent = app.get_agent(mock_context, "MyAgent") + + assert isinstance(agent, DurableAIAgent) + assert agent.agent_name == "MyAgent" + assert agent.context == mock_context + + def test_get_agent_raises_for_unregistered_agent(self) -> None: + """Test get_agent raises ValueError when agent is not registered.""" + app = _app_with_registered_agents("KnownAgent") + + with pytest.raises(ValueError, match=r"Agent 'MissingAgent' is not registered with this app\."): + app.get_agent(Mock(), "MissingAgent") + + +class TestOrchestrationIntegration: + """Integration tests for orchestration scenarios.""" + + def test_sequential_agent_calls_simulation(self) -> None: + """Simulate sequential agent calls in an orchestration.""" + mock_context = Mock() + mock_context.instance_id = "test-orchestration-001" + # new_uuid will be called 3 times: + # 1. thread creation + # 2. correlation_id for first call + # 3. correlation_id for second call + mock_context.new_uuid = Mock(side_effect=["deterministic-guid-001", "corr-1", "corr-2"]) + + # Track entity calls + entity_calls: list[dict[str, Any]] = [] + + def mock_call_entity_side_effect(entity_id: Any, operation: str, input_data: dict[str, Any]) -> Mock: + entity_calls.append({"entity_id": str(entity_id), "operation": operation, "input": input_data}) + + # Return a mock Task + mock_task = Mock() + mock_task._is_scheduled = False + return mock_task + + mock_context.call_entity = Mock(side_effect=mock_call_entity_side_effect) + + app = _app_with_registered_agents("WriterAgent") + agent = app.get_agent(mock_context, "WriterAgent") + + # Create thread + thread = agent.get_new_thread() + + # First call - returns Task + task1 = agent.run("Write something", thread=thread) + assert hasattr(task1, "_is_scheduled") + + # Second call - returns Task + task2 = agent.run("Improve: something", thread=thread) + assert hasattr(task2, "_is_scheduled") + + # Verify both calls used the same entity (same session key) + assert len(entity_calls) == 2 + assert entity_calls[0]["entity_id"] == entity_calls[1]["entity_id"] + # EntityId format is @dafx-writeragent@deterministic-guid-001 + assert entity_calls[0]["entity_id"] == "@dafx-writeragent@deterministic-guid-001" + # new_uuid called 3 times: thread + 2 correlation IDs + assert mock_context.new_uuid.call_count == 3 + + def test_multiple_agents_in_orchestration(self) -> None: + """Test using multiple different agents in one orchestration.""" + mock_context = Mock() + mock_context.instance_id = "test-orchestration-002" + # Mock new_uuid to return different GUIDs for each call + # Order: writer thread, editor thread, writer correlation, editor correlation + mock_context.new_uuid = Mock(side_effect=["writer-guid-001", "editor-guid-002", "writer-corr", "editor-corr"]) + + entity_calls: list[str] = [] + + def mock_call_entity_side_effect(entity_id: Any, operation: str, input_data: dict[str, Any]) -> Mock: + entity_calls.append(str(entity_id)) + mock_task = Mock() + mock_task._is_scheduled = False + return mock_task + + mock_context.call_entity = Mock(side_effect=mock_call_entity_side_effect) + + app = _app_with_registered_agents("WriterAgent", "EditorAgent") + writer = app.get_agent(mock_context, "WriterAgent") + editor = app.get_agent(mock_context, "EditorAgent") + + writer_thread = writer.get_new_thread() + editor_thread = editor.get_new_thread() + + # Call both agents - returns Tasks + writer_task = writer.run("Write", thread=writer_thread) + editor_task = editor.run("Edit", thread=editor_thread) + + assert hasattr(writer_task, "_is_scheduled") + assert hasattr(editor_task, "_is_scheduled") + + # Verify different entity IDs were used + assert len(entity_calls) == 2 + # EntityId format is @dafx-agentname@guid (lowercased agent name with dafx- prefix) + assert entity_calls[0] == "@dafx-writeragent@writer-guid-001" + assert entity_calls[1] == "@dafx-editoragent@editor-guid-002" + + +class TestAgentThreadSerialization: + """Test that AgentThread can be serialized for orchestration state.""" + + async def test_agent_thread_serialize(self) -> None: + """Test that AgentThread can be serialized.""" + thread = AgentThread() + + # Serialize + serialized = await thread.serialize() + + assert isinstance(serialized, dict) + assert "service_thread_id" in serialized + + async def test_agent_thread_deserialize(self) -> None: + """Test that AgentThread can be deserialized.""" + thread = AgentThread() + serialized = await thread.serialize() + + # Deserialize + restored = await AgentThread.deserialize(serialized) + + assert isinstance(restored, AgentThread) + assert restored.service_thread_id == thread.service_thread_id + + async def test_durable_agent_thread_serialization(self) -> None: + """Test that DurableAgentThread persists session metadata during serialization.""" + mock_context = Mock() + mock_context.instance_id = "test-instance-999" + mock_context.new_uuid = Mock(return_value="test-guid-999") + + agent = DurableAIAgent(mock_context, "TestAgent") + thread = agent.get_new_thread() + + assert isinstance(thread, DurableAgentThread) + # Verify custom attribute and property exist + assert thread.session_id is not None + session_id = thread.session_id + assert isinstance(session_id, AgentSessionId) + assert session_id.name == "TestAgent" + assert session_id.key == "test-guid-999" + + # Standard serialization should still work + serialized = await thread.serialize() + assert isinstance(serialized, dict) + assert serialized.get("durable_session_id") == str(session_id) + + # After deserialization, we'd need to restore the custom attribute + # This would be handled by the orchestration framework + restored = await DurableAgentThread.deserialize(serialized) + assert isinstance(restored, DurableAgentThread) + assert restored.session_id == session_id + + +if __name__ == "__main__": + pytest.main([__file__, "-v", "--tb=short"]) diff --git a/python/packages/azurefunctions/tests/test_state.py b/python/packages/azurefunctions/tests/test_state.py new file mode 100644 index 0000000000..52aa7458f0 --- /dev/null +++ b/python/packages/azurefunctions/tests/test_state.py @@ -0,0 +1,110 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Unit tests for AgentState correlation ID tracking.""" + +from unittest.mock import Mock + +import pytest +from agent_framework import AgentRunResponse + +from agent_framework_azurefunctions._state import AgentState + + +class TestAgentStateCorrelationId: + """Test suite for AgentState correlation ID tracking.""" + + def _create_mock_response(self, text: str = "Response") -> Mock: + """Create a mock AgentRunResponse with the provided text.""" + mock_response = Mock(spec=AgentRunResponse) + mock_response.to_dict.return_value = {"text": text, "messages": []} + return mock_response + + def test_add_assistant_message_with_correlation_id(self) -> None: + state = AgentState() + state.add_user_message("Hello", correlation_id="corr-123-request") + state.add_assistant_message("Response", self._create_mock_response(), correlation_id="corr-123") + message_metadata = state.conversation_history[-1].additional_properties or {} + assert message_metadata.get("correlation_id") == "corr-123" + + response_data = state.try_get_agent_response("corr-123") + assert response_data is not None + assert response_data["content"] == "Response" + assert response_data["agent_response"] == {"text": "Response", "messages": []} + + def test_try_get_agent_response_returns_response(self) -> None: + state = AgentState() + state.add_user_message("Hello", correlation_id="corr-200-request") + state.add_assistant_message("Response", self._create_mock_response(), correlation_id="corr-456") + + response_data = state.try_get_agent_response("corr-456") + + assert response_data is not None + assert response_data["content"] == "Response" + + def test_try_get_agent_response_returns_none_for_missing_id(self) -> None: + state = AgentState() + state.add_user_message("Hello", correlation_id="corr-300-request") + state.add_assistant_message("Response", self._create_mock_response(), correlation_id="corr-123") + + assert state.try_get_agent_response("non-existent") is None + + def test_multiple_responses_tracked_separately(self) -> None: + state = AgentState() + + for index in range(3): + state.add_user_message(f"Message {index}", correlation_id=f"corr-{index}-request") + state.add_assistant_message( + f"Response {index}", + self._create_mock_response(text=f"Response {index}"), + correlation_id=f"corr-{index}", + ) + + for index in range(3): + payload = state.try_get_agent_response(f"corr-{index}") + assert payload is not None + assert payload["content"] == f"Response {index}" + + def test_add_assistant_message_without_correlation_id(self) -> None: + state = AgentState() + state.add_user_message("Hello", correlation_id="corr-400-request") + state.add_assistant_message("Response", self._create_mock_response()) + + assert state.try_get_agent_response("missing") is None + assert state.last_response == "Response" + + def test_to_dict_does_not_duplicate_agent_responses(self) -> None: + state = AgentState() + state.add_user_message("Hello", correlation_id="corr-500-request") + state.add_assistant_message("Response", self._create_mock_response(), correlation_id="corr-123") + + state_snapshot = state.to_dict() + + assert "agent_responses" not in state_snapshot + metadata = state_snapshot["conversation_history"][-1]["additional_properties"] + assert metadata["correlation_id"] == "corr-123" + + def test_restore_state_preserves_agent_response_lookup(self) -> None: + state = AgentState() + state.add_user_message("Hello", correlation_id="corr-600-request") + state.add_assistant_message("Response", self._create_mock_response(), correlation_id="corr-123") + + restored_state = AgentState() + restored_state.restore_state(state.to_dict()) + + payload = restored_state.try_get_agent_response("corr-123") + assert payload is not None + assert payload["content"] == "Response" + + def test_reset_clears_conversation_history(self) -> None: + state = AgentState() + state.add_user_message("Hello", correlation_id="corr-700-request") + state.add_assistant_message("Response", self._create_mock_response(), correlation_id="corr-123") + + state.reset() + + assert len(state.conversation_history) == 0 + assert state.try_get_agent_response("corr-123") is None + + +if __name__ == "__main__": + pytest.main([__file__, "-v", "--tb=short"]) diff --git a/python/packages/chatkit/README.md b/python/packages/chatkit/README.md index 237cf94227..5997ec49b5 100644 --- a/python/packages/chatkit/README.md +++ b/python/packages/chatkit/README.md @@ -60,8 +60,17 @@ class MyChatKitServer(ChatKitServer[dict[str, Any]]): if input_user_message is None: return - # Convert ChatKit message to Agent Framework format - agent_messages = await simple_to_agent_input(input_user_message) + # Load full thread history to maintain conversation context + thread_items_page = await self.store.load_thread_items( + thread_id=thread.id, + after=None, + limit=1000, + order="asc", + context=context, + ) + + # Convert all ChatKit messages to Agent Framework format + agent_messages = await simple_to_agent_input(thread_items_page.data) # Run the agent and stream responses response_stream = agent.run_stream(agent_messages) diff --git a/python/packages/chatkit/pyproject.toml b/python/packages/chatkit/pyproject.toml index 8c0a5047e4..bec671e599 100644 --- a/python/packages/chatkit/pyproject.toml +++ b/python/packages/chatkit/pyproject.toml @@ -4,7 +4,7 @@ description = "OpenAI ChatKit integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b251111" +version = "1.0.0b251112.post1" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -56,7 +56,7 @@ omit = [ ] [tool.pyright] -extend = "../../pyproject.toml" +extends = "../../pyproject.toml" exclude = ['tests', 'chatkit-python', 'openai-chatkit-advanced-samples'] [tool.mypy] @@ -86,4 +86,4 @@ test = "pytest --cov=agent_framework_chatkit --cov-report=term-missing:skip-cove [build-system] requires = ["flit-core >= 3.11,<4.0"] -build-backend = "flit_core.buildapi" \ No newline at end of file +build-backend = "flit_core.buildapi" diff --git a/python/packages/copilotstudio/pyproject.toml b/python/packages/copilotstudio/pyproject.toml index 9872355b4e..83902be5d7 100644 --- a/python/packages/copilotstudio/pyproject.toml +++ b/python/packages/copilotstudio/pyproject.toml @@ -4,7 +4,7 @@ description = "Copilot Studio integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b251111" +version = "1.0.0b251112.post1" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" diff --git a/python/packages/core/agent_framework/_clients.py b/python/packages/core/agent_framework/_clients.py index 116148b80f..630e7f8709 100644 --- a/python/packages/core/agent_framework/_clients.py +++ b/python/packages/core/agent_framework/_clients.py @@ -564,10 +564,6 @@ class BaseChatClient(SerializationMixin, ABC): # Validate that store is True when conversation_id is set if chat_options.conversation_id is not None and chat_options.store is not True: - logger.warning( - "When conversation_id is set, store must be True for service-managed threads. " - "Automatically setting store=True." - ) chat_options.store = True if chat_options.instructions: @@ -663,10 +659,6 @@ class BaseChatClient(SerializationMixin, ABC): # Validate that store is True when conversation_id is set if chat_options.conversation_id is not None and chat_options.store is not True: - logger.warning( - "When conversation_id is set, store must be True for service-managed threads. " - "Automatically setting store=True." - ) chat_options.store = True if chat_options.instructions: diff --git a/python/packages/core/agent_framework/_mcp.py b/python/packages/core/agent_framework/_mcp.py index 873b7f04cc..66c96425c8 100644 --- a/python/packages/core/agent_framework/_mcp.py +++ b/python/packages/core/agent_framework/_mcp.py @@ -81,10 +81,16 @@ def _mcp_type_to_ai_content( case types.TextContent(): return TextContent(text=mcp_type.text, raw_representation=mcp_type) case types.ImageContent() | types.AudioContent(): - return DataContent(uri=mcp_type.data, media_type=mcp_type.mimeType, raw_representation=mcp_type) + return DataContent( + uri=mcp_type.data, + media_type=mcp_type.mimeType, + raw_representation=mcp_type, + ) case types.ResourceLink(): return UriContent( - uri=str(mcp_type.uri), media_type=mcp_type.mimeType or "application/json", raw_representation=mcp_type + uri=str(mcp_type.uri), + media_type=mcp_type.mimeType or "application/json", + raw_representation=mcp_type, ) case _: match mcp_type.resource: @@ -92,14 +98,14 @@ def _mcp_type_to_ai_content( return TextContent( text=mcp_type.resource.text, raw_representation=mcp_type, - additional_properties=mcp_type.annotations.model_dump() if mcp_type.annotations else None, + additional_properties=(mcp_type.annotations.model_dump() if mcp_type.annotations else None), ) case types.BlobResourceContents(): return DataContent( uri=mcp_type.resource.blob, media_type=mcp_type.resource.mimeType, raw_representation=mcp_type, - additional_properties=mcp_type.annotations.model_dump() if mcp_type.annotations else None, + additional_properties=(mcp_type.annotations.model_dump() if mcp_type.annotations else None), ) @@ -124,9 +130,11 @@ def _ai_content_to_mcp_types( # uri's are not limited in MCP but they have to be set. # the uri of data content, contains the data uri, which # is not the uri meant here, UriContent would match this. - uri=content.additional_properties.get("uri", "af://binary") - if content.additional_properties - else "af://binary", # type: ignore[reportArgumentType] + uri=( + content.additional_properties.get("uri", "af://binary") + if content.additional_properties + else "af://binary" + ), # type: ignore[reportArgumentType] ), ) return None @@ -135,9 +143,9 @@ def _ai_content_to_mcp_types( type="resource_link", uri=content.uri, # type: ignore[reportArgumentType] mimeType=content.media_type, - name=content.additional_properties.get("name", "Unknown") - if content.additional_properties - else "Unknown", + name=( + content.additional_properties.get("name", "Unknown") if content.additional_properties else "Unknown" + ), ) case _: return None @@ -272,7 +280,7 @@ class MCPTool: self, name: str, description: str | None = None, - approval_mode: Literal["always_require", "never_require"] | HostedMCPSpecificApproval | None = None, + approval_mode: (Literal["always_require", "never_require"] | HostedMCPSpecificApproval | None) = None, allowed_tools: Collection[str] | None = None, load_tools: bool = True, load_prompts: bool = True, @@ -300,6 +308,8 @@ class MCPTool: self.chat_client = chat_client self._functions: list[AIFunction[Any, Any]] = [] self.is_connected: bool = False + self._tools_loaded: bool = False + self._prompts_loaded: bool = False def __str__(self) -> str: return f"MCPTool(name={self.name}, description={self.description})" @@ -336,7 +346,9 @@ class MCPTool: ClientSession( read_stream=transport[0], write_stream=transport[1], - read_timeout_seconds=timedelta(seconds=self.request_timeout) if self.request_timeout else None, + read_timeout_seconds=( + timedelta(seconds=self.request_timeout) if self.request_timeout else None + ), message_handler=self.message_handler, logging_callback=self.logging_callback, sampling_callback=self.sampling_callback, @@ -345,7 +357,8 @@ class MCPTool: except Exception as ex: await self._exit_stack.aclose() raise ToolException( - message="Failed to create MCP session. Please check your configuration.", inner_exception=ex + message="Failed to create MCP session. Please check your configuration.", + inner_exception=ex, ) from ex try: await session.initialize() @@ -368,8 +381,10 @@ class MCPTool: self.is_connected = True if self.load_tools_flag: await self.load_tools() + self._tools_loaded = True if self.load_prompts_flag: await self.load_prompts() + self._prompts_loaded = True if logger.level != logging.NOTSET: try: @@ -380,7 +395,9 @@ class MCPTool: logger.warning("Failed to set log level to %s", logger.level, exc_info=exc) async def sampling_callback( - self, context: RequestContext[ClientSession, Any], params: types.CreateMessageRequestParams + self, + context: RequestContext[ClientSession, Any], + params: types.CreateMessageRequestParams, ) -> types.CreateMessageResult | types.ErrorData: """Callback function for sampling. @@ -458,7 +475,7 @@ class MCPTool: async def message_handler( self, - message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception, + message: (RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception), ) -> None: """Handle messages from the MCP server. @@ -517,8 +534,17 @@ class MCPTool: exc_info=exc, ) prompt_list = None + + # Track existing function names to prevent duplicates + existing_names = {func.name for func in self._functions} + for prompt in prompt_list.prompts if prompt_list else []: local_name = _normalize_mcp_name(prompt.name) + + # Skip if already loaded + if local_name in existing_names: + continue + input_model = _get_input_model_from_mcp_prompt(prompt) approval_mode = self._determine_approval_mode(local_name) func: AIFunction[BaseModel, list[ChatMessage]] = AIFunction( @@ -529,6 +555,7 @@ class MCPTool: input_model=input_model, ) self._functions.append(func) + existing_names.add(local_name) async def load_tools(self) -> None: """Load tools from the MCP server. @@ -549,8 +576,17 @@ class MCPTool: exc_info=exc, ) tool_list = None + + # Track existing function names to prevent duplicates + existing_names = {func.name for func in self._functions} + for tool in tool_list.tools if tool_list else []: local_name = _normalize_mcp_name(tool.name) + + # Skip if already loaded + if local_name in existing_names: + continue + input_model = _get_input_model_from_mcp_tool(tool) approval_mode = self._determine_approval_mode(local_name) # Create AIFunctions out of each tool @@ -562,6 +598,7 @@ class MCPTool: input_model=input_model, ) self._functions.append(func) + existing_names.add(local_name) async def close(self) -> None: """Disconnect from the MCP server. @@ -662,7 +699,10 @@ class MCPTool: raise ToolExecutionException("Failed to enter context manager.", inner_exception=ex) from ex async def __aexit__( - self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: Any + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: Any, ) -> None: """Exit the async context manager. @@ -714,7 +754,7 @@ class MCPStdioTool(MCPTool): request_timeout: int | None = None, session: ClientSession | None = None, description: str | None = None, - approval_mode: Literal["always_require", "never_require"] | HostedMCPSpecificApproval | None = None, + approval_mode: (Literal["always_require", "never_require"] | HostedMCPSpecificApproval | None) = None, allowed_tools: Collection[str] | None = None, args: list[str] | None = None, env: dict[str, str] | None = None, @@ -824,7 +864,7 @@ class MCPStreamableHTTPTool(MCPTool): request_timeout: int | None = None, session: ClientSession | None = None, description: str | None = None, - approval_mode: Literal["always_require", "never_require"] | HostedMCPSpecificApproval | None = None, + approval_mode: (Literal["always_require", "never_require"] | HostedMCPSpecificApproval | None) = None, allowed_tools: Collection[str] | None = None, headers: dict[str, Any] | None = None, timeout: float | None = None, @@ -939,7 +979,7 @@ class MCPWebsocketTool(MCPTool): request_timeout: int | None = None, session: ClientSession | None = None, description: str | None = None, - approval_mode: Literal["always_require", "never_require"] | HostedMCPSpecificApproval | None = None, + approval_mode: (Literal["always_require", "never_require"] | HostedMCPSpecificApproval | None) = None, allowed_tools: Collection[str] | None = None, chat_client: "ChatClientProtocol | None" = None, additional_properties: dict[str, Any] | None = None, diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index 117c9efe52..6edd258e15 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -1636,7 +1636,7 @@ def _handle_function_calls_response( # this runs in every but the first run # we need to keep track of all function call messages fcc_messages.extend(response.messages) - if getattr(kwargs.get("chat_options"), "store", False): + if response.conversation_id is not None: prepped_messages.clear() prepped_messages.append(result_message) else: @@ -1839,7 +1839,7 @@ def _handle_function_calls_streaming_response( # this runs in every but the first run # we need to keep track of all function call messages fcc_messages.extend(response.messages) - if getattr(kwargs.get("chat_options"), "store", False): + if response.conversation_id is not None: prepped_messages.clear() prepped_messages.append(result_message) else: diff --git a/python/packages/core/agent_framework/azure/__init__.py b/python/packages/core/agent_framework/azure/__init__.py index 5dfab603cb..c1b64f2117 100644 --- a/python/packages/core/agent_framework/azure/__init__.py +++ b/python/packages/core/agent_framework/azure/__init__.py @@ -5,12 +5,17 @@ import importlib from typing import Any _IMPORTS: dict[str, tuple[str, str]] = { + "AgentCallbackContext": ("agent_framework_azurefunctions", "azurefunctions"), + "AgentFunctionApp": ("agent_framework_azurefunctions", "azurefunctions"), + "AgentResponseCallbackProtocol": ("agent_framework_azurefunctions", "azurefunctions"), "AzureAIAgentClient": ("agent_framework_azure_ai", "azure-ai"), + "AzureAIClient": ("agent_framework_azure_ai", "azure-ai"), "AzureOpenAIAssistantsClient": ("agent_framework.azure._assistants_client", "core"), "AzureOpenAIChatClient": ("agent_framework.azure._chat_client", "core"), "AzureAISettings": ("agent_framework_azure_ai", "azure-ai"), "AzureOpenAISettings": ("agent_framework.azure._shared", "core"), "AzureOpenAIResponsesClient": ("agent_framework.azure._responses_client", "core"), + "DurableAIAgent": ("agent_framework_azurefunctions", "azurefunctions"), "get_entra_auth_token": ("agent_framework.azure._entra_id_authentication", "core"), } diff --git a/python/packages/core/agent_framework/azure/__init__.pyi b/python/packages/core/agent_framework/azure/__init__.pyi index 742325a736..aba582b5b5 100644 --- a/python/packages/core/agent_framework/azure/__init__.pyi +++ b/python/packages/core/agent_framework/azure/__init__.pyi @@ -1,6 +1,12 @@ # Copyright (c) Microsoft. All rights reserved. -from agent_framework_azure_ai import AzureAIAgentClient, AzureAISettings +from agent_framework_azure_ai import AzureAIAgentClient, AzureAIClient, AzureAISettings +from agent_framework_azurefunctions import ( + AgentCallbackContext, + AgentFunctionApp, + AgentResponseCallbackProtocol, + DurableAIAgent, +) from agent_framework.azure._assistants_client import AzureOpenAIAssistantsClient from agent_framework.azure._chat_client import AzureOpenAIChatClient @@ -9,11 +15,16 @@ from agent_framework.azure._responses_client import AzureOpenAIResponsesClient from agent_framework.azure._shared import AzureOpenAISettings __all__ = [ + "AgentCallbackContext", + "AgentFunctionApp", + "AgentResponseCallbackProtocol", "AzureAIAgentClient", + "AzureAIClient", "AzureAISettings", "AzureOpenAIAssistantsClient", "AzureOpenAIChatClient", "AzureOpenAIResponsesClient", "AzureOpenAISettings", + "DurableAIAgent", "get_entra_auth_token", ] diff --git a/python/packages/core/agent_framework/openai/_assistants_client.py b/python/packages/core/agent_framework/openai/_assistants_client.py index 8a28075e62..6255a6b8db 100644 --- a/python/packages/core/agent_framework/openai/_assistants_client.py +++ b/python/packages/core/agent_framework/openai/_assistants_client.py @@ -161,7 +161,8 @@ class OpenAIAssistantsClient(OpenAIConfigMixin, BaseChatClient): async def close(self) -> None: """Clean up any assistants we created.""" if self._should_delete_assistant and self.assistant_id is not None: - await self.client.beta.assistants.delete(self.assistant_id) + client = await self.ensure_client() + await client.beta.assistants.delete(self.assistant_id) object.__setattr__(self, "assistant_id", None) object.__setattr__(self, "_should_delete_assistant", False) @@ -215,7 +216,11 @@ class OpenAIAssistantsClient(OpenAIConfigMixin, BaseChatClient): """ # If no assistant is provided, create a temporary assistant if self.assistant_id is None: - created_assistant = await self.client.beta.assistants.create(name=self.assistant_name, model=self.model_id) + if not self.model_id: + raise ServiceInitializationError("Parameter 'model_id' is required for assistant creation.") + + client = await self.ensure_client() + created_assistant = await client.beta.assistants.create(name=self.assistant_name, model=self.model_id) self.assistant_id = created_assistant.id self._should_delete_assistant = True @@ -233,6 +238,7 @@ class OpenAIAssistantsClient(OpenAIConfigMixin, BaseChatClient): Returns: tuple: (stream, final_thread_id) """ + client = await self.ensure_client() # Get any active run for this thread thread_run = await self._get_active_thread_run(thread_id) @@ -240,7 +246,7 @@ class OpenAIAssistantsClient(OpenAIConfigMixin, BaseChatClient): if thread_run is not None and tool_run_id is not None and tool_run_id == thread_run.id and tool_outputs: # There's an active run and we have tool results to submit, so submit the results. - stream = self.client.beta.threads.runs.submit_tool_outputs_stream( # type: ignore[reportDeprecated] + stream = client.beta.threads.runs.submit_tool_outputs_stream( # type: ignore[reportDeprecated] run_id=tool_run_id, thread_id=thread_run.thread_id, tool_outputs=tool_outputs ) final_thread_id = thread_run.thread_id @@ -249,7 +255,7 @@ class OpenAIAssistantsClient(OpenAIConfigMixin, BaseChatClient): final_thread_id = await self._prepare_thread(thread_id, thread_run, run_options) # Now create a new run and stream the results. - stream = self.client.beta.threads.runs.stream( # type: ignore[reportDeprecated] + stream = client.beta.threads.runs.stream( # type: ignore[reportDeprecated] assistant_id=assistant_id, thread_id=final_thread_id, **run_options ) @@ -257,19 +263,21 @@ class OpenAIAssistantsClient(OpenAIConfigMixin, BaseChatClient): async def _get_active_thread_run(self, thread_id: str | None) -> Run | None: """Get any active run for the given thread.""" + client = await self.ensure_client() if thread_id is None: return None - async for run in self.client.beta.threads.runs.list(thread_id=thread_id, limit=1, order="desc"): # type: ignore[reportDeprecated] + async for run in client.beta.threads.runs.list(thread_id=thread_id, limit=1, order="desc"): # type: ignore[reportDeprecated] if run.status not in ["completed", "cancelled", "failed", "expired"]: return run return None async def _prepare_thread(self, thread_id: str | None, thread_run: Run | None, run_options: dict[str, Any]) -> str: """Prepare the thread for a new run, creating or cleaning up as needed.""" + client = await self.ensure_client() if thread_id is None: # No thread ID was provided, so create a new thread. - thread = await self.client.beta.threads.create( # type: ignore[reportDeprecated] + thread = await client.beta.threads.create( # type: ignore[reportDeprecated] messages=run_options["additional_messages"], tool_resources=run_options.get("tool_resources"), metadata=run_options.get("metadata"), @@ -280,7 +288,7 @@ class OpenAIAssistantsClient(OpenAIConfigMixin, BaseChatClient): if thread_run is not None: # There was an active run; we need to cancel it before starting a new run. - await self.client.beta.threads.runs.cancel(run_id=thread_run.id, thread_id=thread_id) # type: ignore[reportDeprecated] + await client.beta.threads.runs.cancel(run_id=thread_run.id, thread_id=thread_id) # type: ignore[reportDeprecated] return thread_id diff --git a/python/packages/core/agent_framework/openai/_chat_client.py b/python/packages/core/agent_framework/openai/_chat_client.py index e6a4087508..02e0743e1b 100644 --- a/python/packages/core/agent_framework/openai/_chat_client.py +++ b/python/packages/core/agent_framework/openai/_chat_client.py @@ -69,10 +69,11 @@ class OpenAIBaseChatClient(OpenAIBase, BaseChatClient): chat_options: ChatOptions, **kwargs: Any, ) -> ChatResponse: + client = await self.ensure_client() options_dict = self._prepare_options(messages, chat_options) try: return self._create_chat_response( - await self.client.chat.completions.create(stream=False, **options_dict), chat_options + await client.chat.completions.create(stream=False, **options_dict), chat_options ) except BadRequestError as ex: if ex.code == "content_filter": @@ -97,10 +98,11 @@ class OpenAIBaseChatClient(OpenAIBase, BaseChatClient): chat_options: ChatOptions, **kwargs: Any, ) -> AsyncIterable[ChatResponseUpdate]: + client = await self.ensure_client() options_dict = self._prepare_options(messages, chat_options) options_dict["stream_options"] = {"include_usage": True} try: - async for chunk in await self.client.chat.completions.create(stream=True, **options_dict): + async for chunk in await client.chat.completions.create(stream=True, **options_dict): if len(chunk.choices) == 0 and chunk.usage is None: continue yield self._create_chat_response_update(chunk) diff --git a/python/packages/core/agent_framework/openai/_responses_client.py b/python/packages/core/agent_framework/openai/_responses_client.py index 149fe4bfac..447333447a 100644 --- a/python/packages/core/agent_framework/openai/_responses_client.py +++ b/python/packages/core/agent_framework/openai/_responses_client.py @@ -89,23 +89,24 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient): chat_options: ChatOptions, **kwargs: Any, ) -> ChatResponse: - options_dict = self._prepare_options(messages, chat_options) + client = await self.ensure_client() + run_options = await self.prepare_options(messages, chat_options) try: - if not chat_options.response_format: - response = await self.client.responses.create( + response_format = run_options.pop("response_format", None) + if not response_format: + response = await client.responses.create( stream=False, - **options_dict, + **run_options, ) - chat_options.conversation_id = response.id if chat_options.store is True else None + chat_options.conversation_id = self.get_conversation_id(response, chat_options.store) return self._create_response_content(response, chat_options=chat_options) # create call does not support response_format, so we need to handle it via parse call - resp_format = chat_options.response_format - parsed_response: ParsedResponse[BaseModel] = await self.client.responses.parse( - text_format=resp_format, + parsed_response: ParsedResponse[BaseModel] = await client.responses.parse( + text_format=response_format, stream=False, - **options_dict, + **run_options, ) - chat_options.conversation_id = parsed_response.id if chat_options.store is True else None + chat_options.conversation_id = self.get_conversation_id(parsed_response, chat_options.store) return self._create_response_content(parsed_response, chat_options=chat_options) except BadRequestError as ex: if ex.code == "content_filter": @@ -130,13 +131,15 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient): chat_options: ChatOptions, **kwargs: Any, ) -> AsyncIterable[ChatResponseUpdate]: - options_dict = self._prepare_options(messages, chat_options) + client = await self.ensure_client() + run_options = await self.prepare_options(messages, chat_options) function_call_ids: dict[int, tuple[str, str]] = {} # output_index: (call_id, name) try: - if not chat_options.response_format: - response = await self.client.responses.create( + response_format = run_options.pop("response_format", None) + if not response_format: + response = await client.responses.create( stream=True, - **options_dict, + **run_options, ) async for chunk in response: update = self._create_streaming_response_content( @@ -145,9 +148,9 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient): yield update return # create call does not support response_format, so we need to handle it via stream call - async with self.client.responses.stream( - text_format=chat_options.response_format, - **options_dict, + async with client.responses.stream( + text_format=response_format, + **run_options, ) as response: async for chunk in response: update = self._create_streaming_response_content( @@ -170,6 +173,12 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient): inner_exception=ex, ) from ex + def get_conversation_id( + self, response: OpenAIResponse | ParsedResponse[BaseModel], store: bool | None + ) -> str | None: + """Get the conversation ID from the response if store is True.""" + return response.id if store else None + # region Prep methods def _tools_to_response_tools( @@ -180,31 +189,7 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient): if isinstance(tool, ToolProtocol): match tool: case HostedMCPTool(): - mcp: Mcp = { - "type": "mcp", - "server_label": tool.name.replace(" ", "_"), - "server_url": str(tool.url), - "server_description": tool.description, - "headers": tool.headers, - } - if tool.allowed_tools: - mcp["allowed_tools"] = list(tool.allowed_tools) - if tool.approval_mode: - match tool.approval_mode: - case str(): - mcp["require_approval"] = ( - "always" if tool.approval_mode == "always_require" else "never" - ) - case _: - if always_require_approvals := tool.approval_mode.get("always_require_approval"): - mcp["require_approval"] = { - "always": {"tool_names": list(always_require_approvals)} - } - if never_require_approvals := tool.approval_mode.get("never_require_approval"): - mcp["require_approval"] = { - "never": {"tool_names": list(never_require_approvals)} - } - response_tools.append(mcp) + response_tools.append(self.get_mcp_tool(tool)) case HostedCodeInterpreterTool(): tool_args: CodeInterpreterContainerCodeInterpreterToolAuto = {"type": "auto"} if tool.inputs: @@ -306,12 +291,36 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient): response_tools.append(tool_dict) return response_tools - def _prepare_options(self, messages: MutableSequence[ChatMessage], chat_options: ChatOptions) -> dict[str, Any]: + def get_mcp_tool(self, tool: HostedMCPTool) -> Any: + """Get MCP tool from HostedMCPTool.""" + mcp: Mcp = { + "type": "mcp", + "server_label": tool.name.replace(" ", "_"), + "server_url": str(tool.url), + "server_description": tool.description, + "headers": tool.headers, + } + if tool.allowed_tools: + mcp["allowed_tools"] = list(tool.allowed_tools) + if tool.approval_mode: + match tool.approval_mode: + case str(): + mcp["require_approval"] = "always" if tool.approval_mode == "always_require" else "never" + case _: + if always_require_approvals := tool.approval_mode.get("always_require_approval"): + mcp["require_approval"] = {"always": {"tool_names": list(always_require_approvals)}} + if never_require_approvals := tool.approval_mode.get("never_require_approval"): + mcp["require_approval"] = {"never": {"tool_names": list(never_require_approvals)}} + + return mcp + + async def prepare_options( + self, messages: MutableSequence[ChatMessage], chat_options: ChatOptions + ) -> dict[str, Any]: """Take ChatOptions and create the specific options for Responses API.""" - options_dict: dict[str, Any] = chat_options.to_dict( + run_options: dict[str, Any] = chat_options.to_dict( exclude={ "type", - "response_format", # handled in inner get methods "presence_penalty", # not supported "frequency_penalty", # not supported "logit_bias", # not supported @@ -320,6 +329,10 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient): "instructions", # already added as system message } ) + + if chat_options.response_format: + run_options["response_format"] = chat_options.response_format + translations = { "model_id": "model", "allow_multiple_tool_calls": "parallel_tool_calls", @@ -327,35 +340,37 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient): "max_tokens": "max_output_tokens", } for old_key, new_key in translations.items(): - if old_key in options_dict and old_key != new_key: - options_dict[new_key] = options_dict.pop(old_key) + if old_key in run_options and old_key != new_key: + run_options[new_key] = run_options.pop(old_key) # tools if chat_options.tools is None: - options_dict.pop("parallel_tool_calls", None) + run_options.pop("parallel_tool_calls", None) else: - options_dict["tools"] = self._tools_to_response_tools(chat_options.tools) + run_options["tools"] = self._tools_to_response_tools(chat_options.tools) # model id - if not options_dict.get("model"): - options_dict["model"] = self.model_id + if not run_options.get("model"): + if not self.model_id: + raise ValueError("model_id must be a non-empty string") + run_options["model"] = self.model_id # messages request_input = self._prepare_chat_messages_for_request(messages) if not request_input: raise ServiceInvalidRequestError("Messages are required for chat completions") - options_dict["input"] = request_input + run_options["input"] = request_input # additional provider specific settings - if additional_properties := options_dict.pop("additional_properties", None): + if additional_properties := run_options.pop("additional_properties", None): for key, value in additional_properties.items(): if value is not None: - options_dict[key] = value - if "store" not in options_dict: - options_dict["store"] = False - if (tool_choice := options_dict.get("tool_choice")) and len(tool_choice.keys()) == 1: - options_dict["tool_choice"] = tool_choice["mode"] - return options_dict + run_options[key] = value + if "store" not in run_options: + run_options["store"] = False + if (tool_choice := run_options.get("tool_choice")) and len(tool_choice.keys()) == 1: + run_options["tool_choice"] = tool_choice["mode"] + return run_options def _prepare_chat_messages_for_request(self, chat_messages: Sequence[ChatMessage]) -> list[dict[str, Any]]: """Prepare the chat messages for a request. @@ -504,7 +519,6 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient): # call_id for the result needs to be the same as the call_id for the function call args: dict[str, Any] = { "call_id": content.call_id, - "id": call_id_to_id.get(content.call_id), "type": "function_call_output", } if content.result: @@ -734,7 +748,7 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient): "raw_representation": response, } if chat_options.store: - args["conversation_id"] = response.id + args["conversation_id"] = self.get_conversation_id(response, chat_options.store) if response.usage and (usage_details := self._usage_details_from_openai(response.usage)): args["usage_details"] = usage_details if structured_response: @@ -834,7 +848,7 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient): contents.append(TextReasoningContent(text=event.text, raw_representation=event)) metadata.update(self._get_metadata_from_response(event)) case "response.completed": - conversation_id = event.response.id if chat_options.store is True else None + conversation_id = self.get_conversation_id(event.response, chat_options.store) model = event.response.model if event.response.usage: usage = self._usage_details_from_openai(event.response.usage) diff --git a/python/packages/core/agent_framework/openai/_shared.py b/python/packages/core/agent_framework/openai/_shared.py index bea58786c8..20c719e09e 100644 --- a/python/packages/core/agent_framework/openai/_shared.py +++ b/python/packages/core/agent_framework/openai/_shared.py @@ -127,18 +127,18 @@ class OpenAIBase(SerializationMixin): INJECTABLE: ClassVar[set[str]] = {"client"} - def __init__(self, *, client: AsyncOpenAI, model_id: str, **kwargs: Any) -> None: + def __init__(self, *, model_id: str | None = None, client: AsyncOpenAI | None = None, **kwargs: Any) -> None: """Initialize OpenAIBase. Keyword Args: client: The AsyncOpenAI client instance. - model_id: The AI model ID to use (non-empty, whitespace stripped). + model_id: The AI model ID to use. **kwargs: Additional keyword arguments. """ - if not model_id or not model_id.strip(): - raise ValueError("model_id must be a non-empty string") self.client = client - self.model_id = model_id.strip() + self.model_id = None + if model_id: + self.model_id = model_id.strip() # Call super().__init__() to continue MRO chain (e.g., BaseChatClient) # Extract known kwargs that belong to other base classes @@ -162,6 +162,21 @@ class OpenAIBase(SerializationMixin): for key, value in kwargs.items(): setattr(self, key, value) + async def initialize_client(self) -> None: + """Initialize OpenAI client asynchronously. + + Override in subclasses to initialize the OpenAI client asynchronously. + """ + pass + + async def ensure_client(self) -> AsyncOpenAI: + """Ensure OpenAI client is initialized.""" + await self.initialize_client() + if self.client is None: + raise ServiceInitializationError("OpenAI client is not initialized") + + return self.client + def _get_api_key( self, api_key: str | SecretStr | Callable[[], str | Awaitable[str]] | None ) -> str | Callable[[], str | Awaitable[str]] | None: diff --git a/python/packages/core/pyproject.toml b/python/packages/core/pyproject.toml index 0dc26386c2..9a8b1051ad 100644 --- a/python/packages/core/pyproject.toml +++ b/python/packages/core/pyproject.toml @@ -4,7 +4,7 @@ description = "Microsoft Agent Framework for building AI Agents with Python. Thi authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b251111" +version = "1.0.0b251112.post1" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" diff --git a/python/packages/core/tests/core/test_mcp.py b/python/packages/core/tests/core/test_mcp.py index e6dd1fd8a7..865e2ef484 100644 --- a/python/packages/core/tests/core/test_mcp.py +++ b/python/packages/core/tests/core/test_mcp.py @@ -38,9 +38,11 @@ from agent_framework.exceptions import ToolException, ToolExecutionException # Integration test skip condition skip_if_mcp_integration_tests_disabled = pytest.mark.skipif( os.getenv("RUN_INTEGRATION_TESTS", "false").lower() != "true" or os.getenv("LOCAL_MCP_URL", "") == "", - reason="No LOCAL_MCP_URL provided; skipping integration tests." - if os.getenv("RUN_INTEGRATION_TESTS", "false").lower() == "true" - else "Integration tests are disabled.", + reason=( + "No LOCAL_MCP_URL provided; skipping integration tests." + if os.getenv("RUN_INTEGRATION_TESTS", "false").lower() == "true" + else "Integration tests are disabled." + ), ) @@ -137,7 +139,9 @@ def test_mcp_content_types_to_ai_content_resource_link(): def test_mcp_content_types_to_ai_content_embedded_resource_text(): """Test conversion of MCP embedded text resource to AI content.""" text_resource = types.TextResourceContents( - uri=AnyUrl("file://test.txt"), mimeType="text/plain", text="Embedded text content" + uri=AnyUrl("file://test.txt"), + mimeType="text/plain", + text="Embedded text content", ) mcp_content = types.EmbeddedResource(type="resource", resource=text_resource) ai_content = _mcp_type_to_ai_content(mcp_content) @@ -198,7 +202,10 @@ def test_ai_content_to_mcp_content_types_data_audio(): def test_ai_content_to_mcp_content_types_data_binary(): """Test conversion of AI data content to MCP content.""" - ai_content = DataContent(uri="data:application/octet-stream;base64,xyz", media_type="application/octet-stream") + ai_content = DataContent( + uri="data:application/octet-stream;base64,xyz", + media_type="application/octet-stream", + ) mcp_content = _ai_content_to_mcp_types(ai_content) assert isinstance(mcp_content, types.EmbeddedResource) @@ -221,7 +228,10 @@ def test_ai_content_to_mcp_content_types_uri(): def test_chat_message_to_mcp_types(): message = ChatMessage( role="user", - contents=[TextContent(text="test"), DataContent(uri="data:image/png;base64,xyz", media_type="image/png")], + contents=[ + TextContent(text="test"), + DataContent(uri="data:image/png;base64,xyz", media_type="image/png"), + ], ) mcp_contents = _chat_message_to_mcp_types(message) assert len(mcp_contents) == 2 @@ -583,7 +593,10 @@ async def test_local_mcp_server_prompt_execution(): return_value=types.GetPromptResult( description="Generated prompt", messages=[ - types.PromptMessage(role="user", content=types.TextContent(type="text", text="Test message")) + types.PromptMessage( + role="user", + content=types.TextContent(type="text", text="Test message"), + ) ], ) ) @@ -607,10 +620,16 @@ async def test_local_mcp_server_prompt_execution(): @pytest.mark.parametrize( "approval_mode,expected_approvals", [ - ("always_require", {"tool_one": "always_require", "tool_two": "always_require"}), + ( + "always_require", + {"tool_one": "always_require", "tool_two": "always_require"}, + ), ("never_require", {"tool_one": "never_require", "tool_two": "never_require"}), ( - {"always_require_approval": ["tool_one"], "never_require_approval": ["tool_two"]}, + { + "always_require_approval": ["tool_one"], + "never_require_approval": ["tool_two"], + }, {"tool_one": "always_require", "tool_two": "never_require"}, ), ], @@ -664,9 +683,17 @@ async def test_mcp_tool_approval_mode(approval_mode, expected_approvals): @pytest.mark.parametrize( "allowed_tools,expected_count,expected_names", [ - (None, 3, ["tool_one", "tool_two", "tool_three"]), # None means all tools are allowed + ( + None, + 3, + ["tool_one", "tool_two", "tool_three"], + ), # None means all tools are allowed (["tool_one"], 1, ["tool_one"]), # Only tool_one is allowed - (["tool_one", "tool_three"], 2, ["tool_one", "tool_three"]), # Two tools allowed + ( + ["tool_one", "tool_three"], + 2, + ["tool_one", "tool_three"], + ), # Two tools allowed (["nonexistent_tool"], 0, []), # No matching tools ], ) @@ -884,7 +911,12 @@ async def test_mcp_tool_sampling_callback_no_valid_content(): mock_response.messages = [ ChatMessage( role=Role.ASSISTANT, - contents=[DataContent(uri="data:application/json;base64,e30K", media_type="application/json")], + contents=[ + DataContent( + uri="data:application/json;base64,e30K", + media_type="application/json", + ) + ], ) ] mock_response.model_id = "test-model" @@ -1011,14 +1043,24 @@ async def test_connect_cleanup_on_initialization_failure(): def test_mcp_stdio_tool_get_mcp_client_with_env_and_kwargs(): """Test MCPStdioTool.get_mcp_client() with environment variables and client kwargs.""" env_vars = {"PATH": "/usr/bin", "DEBUG": "1"} - tool = MCPStdioTool(name="test", command="test-command", env=env_vars, custom_param="value1", another_param=42) + tool = MCPStdioTool( + name="test", + command="test-command", + env=env_vars, + custom_param="value1", + another_param=42, + ) with patch("agent_framework._mcp.stdio_client"), patch("agent_framework._mcp.StdioServerParameters") as mock_params: tool.get_mcp_client() # Verify all parameters including custom kwargs were passed mock_params.assert_called_once_with( - command="test-command", args=[], env=env_vars, custom_param="value1", another_param=42 + command="test-command", + args=[], + env=env_vars, + custom_param="value1", + another_param=42, ) @@ -1051,7 +1093,11 @@ def test_mcp_streamable_http_tool_get_mcp_client_all_params(): def test_mcp_websocket_tool_get_mcp_client_with_kwargs(): """Test MCPWebsocketTool.get_mcp_client() with client kwargs.""" tool = MCPWebsocketTool( - name="test", url="wss://example.com", max_size=1024, ping_interval=30, compression="deflate" + name="test", + url="wss://example.com", + max_size=1024, + ping_interval=30, + compression="deflate", ) with patch("agent_framework._mcp.websocket_client") as mock_ws_client: @@ -1059,5 +1105,147 @@ def test_mcp_websocket_tool_get_mcp_client_with_kwargs(): # Verify all kwargs were passed mock_ws_client.assert_called_once_with( - url="wss://example.com", max_size=1024, ping_interval=30, compression="deflate" + url="wss://example.com", + max_size=1024, + ping_interval=30, + compression="deflate", ) + + +@pytest.mark.asyncio +async def test_mcp_tool_deduplication(): + """Test that MCP tools are not duplicated in MCPTool""" + from agent_framework._mcp import MCPTool + from agent_framework._tools import AIFunction + + # Create MCPStreamableHTTPTool instance + tool = MCPTool(name="test_mcp_tool") + + # Manually set up functions list + tool._functions = [] + + # Add initial functions + func1 = AIFunction( + func=lambda x: f"Result: {x}", + name="analyze_content", + description="Analyzes content", + ) + func2 = AIFunction( + func=lambda x: f"Extract: {x}", + name="extract_info", + description="Extracts information", + ) + + tool._functions.append(func1) + tool._functions.append(func2) + + # Verify initial state + assert len(tool._functions) == 2 + assert len({f.name for f in tool._functions}) == 2 + + # Simulate deduplication logic + existing_names = {func.name for func in tool._functions} + + # Attempt to add duplicates + test_tools = [ + ("analyze_content", "Duplicate"), + ("extract_info", "Duplicate"), + ("new_function", "New"), + ] + + added_count = 0 + for tool_name, description in test_tools: + if tool_name in existing_names: + continue # Skip duplicates + + new_func = AIFunction(func=lambda x: f"Process: {x}", name=tool_name, description=description) + tool._functions.append(new_func) + existing_names.add(tool_name) + added_count += 1 + + # Verify results + final_names = [f.name for f in tool._functions] + unique_names = set(final_names) + + # Should have exactly 3 functions (2 original + 1 new) + assert len(tool._functions) == 3 + assert len(unique_names) == 3 + assert len(final_names) == len(unique_names) # No duplicates + assert added_count == 1 # Only 1 new function added + + +@pytest.mark.asyncio +async def test_load_tools_prevents_multiple_calls(): + """Test that connect() prevents calling load_tools() multiple times""" + from unittest.mock import AsyncMock, MagicMock + + from agent_framework._mcp import MCPTool + + tool = MCPTool(name="test_tool") + + # Verify initial state + assert tool._tools_loaded is False + + # Mock the session and list_tools + mock_session = AsyncMock() + mock_tool_list = MagicMock() + mock_tool_list.tools = [] + mock_session.list_tools = AsyncMock(return_value=mock_tool_list) + mock_session.initialize = AsyncMock() + + tool.session = mock_session + tool.load_tools_flag = True + tool.load_prompts_flag = False + + # Simulate connect() behavior + if tool.load_tools_flag and not tool._tools_loaded: + await tool.load_tools() + tool._tools_loaded = True + + assert tool._tools_loaded is True + assert mock_session.list_tools.call_count == 1 + + # Second call to connect should be skipped + if tool.load_tools_flag and not tool._tools_loaded: + await tool.load_tools() + tool._tools_loaded = True + + assert mock_session.list_tools.call_count == 1 # Still 1, not incremented + + +@pytest.mark.asyncio +async def test_load_prompts_prevents_multiple_calls(): + """Test that connect() prevents calling load_prompts() multiple times""" + from unittest.mock import AsyncMock, MagicMock + + from agent_framework._mcp import MCPTool + + tool = MCPTool(name="test_tool") + + # Verify initial state + assert tool._prompts_loaded is False + + # Mock the session and list_prompts + mock_session = AsyncMock() + mock_prompt_list = MagicMock() + mock_prompt_list.prompts = [] + mock_session.list_prompts = AsyncMock(return_value=mock_prompt_list) + + tool.session = mock_session + tool.load_tools_flag = False + tool.load_prompts_flag = True + + # Simulate connect() behavior + if tool.load_prompts_flag and not tool._prompts_loaded: + await tool.load_prompts() + tool._prompts_loaded = True + + assert tool._prompts_loaded is True + assert mock_session.list_prompts.call_count == 1 + + # Second call to connect should be skipped + if tool.load_prompts_flag and not tool._prompts_loaded: + await tool.load_prompts() + tool._prompts_loaded = True + + assert mock_session.list_prompts.call_count == 1 # Still 1, not incremented diff --git a/python/packages/core/tests/openai/test_openai_responses_client.py b/python/packages/core/tests/openai/test_openai_responses_client.py index 5ff4bb3de3..4700950439 100644 --- a/python/packages/core/tests/openai/test_openai_responses_client.py +++ b/python/packages/core/tests/openai/test_openai_responses_client.py @@ -1407,27 +1407,27 @@ def test_create_response_content_image_generation_fallback(): assert f"data:image/png;base64,{unrecognized_base64}" == content.uri -def test_prepare_options_store_parameter_handling() -> None: +async def test_prepare_options_store_parameter_handling() -> None: client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") messages = [ChatMessage(role="user", text="Test message")] test_conversation_id = "test-conversation-123" chat_options = ChatOptions(store=True, conversation_id=test_conversation_id) - options = client._prepare_options(messages, chat_options) # type: ignore + options = await client.prepare_options(messages, chat_options) assert options["store"] is True assert options["previous_response_id"] == test_conversation_id chat_options = ChatOptions(store=False, conversation_id="") - options = client._prepare_options(messages, chat_options) # type: ignore + options = await client.prepare_options(messages, chat_options) assert options["store"] is False chat_options = ChatOptions(store=None, conversation_id=None) - options = client._prepare_options(messages, chat_options) # type: ignore + options = await client.prepare_options(messages, chat_options) assert options["store"] is False assert "previous_response_id" not in options chat_options = ChatOptions() - options = client._prepare_options(messages, chat_options) # type: ignore + options = await client.prepare_options(messages, chat_options) assert options["store"] is False assert "previous_response_id" not in options diff --git a/python/packages/devui/agent_framework_devui/_executor.py b/python/packages/devui/agent_framework_devui/_executor.py index f1ca1c6a62..3ce0bbe41e 100644 --- a/python/packages/devui/agent_framework_devui/_executor.py +++ b/python/packages/devui/agent_framework_devui/_executor.py @@ -781,6 +781,27 @@ class AgentFrameworkExecutor: Returns: Dict of {request_id: response_value} if found, None otherwise """ + # Handle case where input_data might be a JSON string (from streamWorkflowExecutionOpenAI) + # The input field type is: str | list[Any] | dict[str, Any] + if isinstance(input_data, str): + try: + parsed = json.loads(input_data) + # Only use parsed value if it's a list (ResponseInputParam format expected for HIL) + if isinstance(parsed, list): + input_data = parsed + else: + # Parsed to dict, string, or primitive - not HIL response format + return None + except (json.JSONDecodeError, TypeError): + # Plain text string, not valid JSON - not HIL format + return None + + # At this point, input_data should be a list or dict + # HIL responses are always in list format (ResponseInputParam) + if isinstance(input_data, dict): + # This is structured workflow input (dict), not HIL responses + return None + if not isinstance(input_data, list): return None diff --git a/python/packages/devui/agent_framework_devui/ui/assets/index.js b/python/packages/devui/agent_framework_devui/ui/assets/index.js index 3744c1e10d..317e9e7349 100644 --- a/python/packages/devui/agent_framework_devui/ui/assets/index.js +++ b/python/packages/devui/agent_framework_devui/ui/assets/index.js @@ -14,7 +14,7 @@ function gE(e,n){for(var s=0;s>>1,T=A[P];if(0>>1;Pl(Z,$))rel(de,Z)?(A[P]=de,A[re]=$,P=re):(A[P]=Z,A[W]=$,P=W);else if(rel(de,$))A[P]=de,A[re]=$,P=re;else break e}}return I}function l(A,I){var $=A.sortIndex-I.sortIndex;return $!==0?$:A.id-I.id}if(e.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var c=performance;e.unstable_now=function(){return c.now()}}else{var d=Date,f=d.now();e.unstable_now=function(){return d.now()-f}}var m=[],p=[],g=1,v=null,y=3,b=!1,S=!1,N=!1,_=!1,E=typeof setTimeout=="function"?setTimeout:null,M=typeof clearTimeout=="function"?clearTimeout:null,j=typeof setImmediate<"u"?setImmediate:null;function k(A){for(var I=s(p);I!==null;){if(I.callback===null)o(p);else if(I.startTime<=A)o(p),I.sortIndex=I.expirationTime,n(m,I);else break;I=s(p)}}function R(A){if(N=!1,k(A),!S)if(s(m)!==null)S=!0,D||(D=!0,G());else{var I=s(p);I!==null&&V(R,I.startTime-A)}}var D=!1,z=-1,H=5,U=-1;function F(){return _?!0:!(e.unstable_now()-UA&&F());){var P=v.callback;if(typeof P=="function"){v.callback=null,y=v.priorityLevel;var T=P(v.expirationTime<=A);if(A=e.unstable_now(),typeof T=="function"){v.callback=T,k(A),I=!0;break t}v===s(m)&&o(m),k(A)}else o(m);v=s(m)}if(v!==null)I=!0;else{var B=s(p);B!==null&&V(R,B.startTime-A),I=!1}}break e}finally{v=null,y=$,b=!1}I=void 0}}finally{I?G():D=!1}}}var G;if(typeof j=="function")G=function(){j(K)};else if(typeof MessageChannel<"u"){var ne=new MessageChannel,L=ne.port2;ne.port1.onmessage=K,G=function(){L.postMessage(null)}}else G=function(){E(K,0)};function V(A,I){z=E(function(){A(e.unstable_now())},I)}e.unstable_IdlePriority=5,e.unstable_ImmediatePriority=1,e.unstable_LowPriority=4,e.unstable_NormalPriority=3,e.unstable_Profiling=null,e.unstable_UserBlockingPriority=2,e.unstable_cancelCallback=function(A){A.callback=null},e.unstable_forceFrameRate=function(A){0>A||125P?(A.sortIndex=$,n(p,A),s(m)===null&&A===s(p)&&(N?(M(z),z=-1):N=!0,V(R,$-P))):(A.sortIndex=T,n(m,A),S||b||(S=!0,D||(D=!0,G()))),A},e.unstable_shouldYield=F,e.unstable_wrapCallback=function(A){var I=y;return function(){var $=y;y=I;try{return A.apply(this,arguments)}finally{y=$}}}})(Vm)),Vm}var Wy;function wE(){return Wy||(Wy=1,Um.exports=bE()),Um.exports}var qm={exports:{}},Yt={};/** + */var Zy;function bE(){return Zy||(Zy=1,(function(e){function n(A,I){var $=A.length;A.push(I);e:for(;0<$;){var P=$-1>>>1,T=A[P];if(0>>1;Pl(Z,$))rel(de,Z)?(A[P]=de,A[re]=$,P=re):(A[P]=Z,A[W]=$,P=W);else if(rel(de,$))A[P]=de,A[re]=$,P=re;else break e}}return I}function l(A,I){var $=A.sortIndex-I.sortIndex;return $!==0?$:A.id-I.id}if(e.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var c=performance;e.unstable_now=function(){return c.now()}}else{var d=Date,f=d.now();e.unstable_now=function(){return d.now()-f}}var m=[],p=[],g=1,v=null,y=3,b=!1,S=!1,N=!1,j=!1,E=typeof setTimeout=="function"?setTimeout:null,M=typeof clearTimeout=="function"?clearTimeout:null,_=typeof setImmediate<"u"?setImmediate:null;function k(A){for(var I=s(p);I!==null;){if(I.callback===null)o(p);else if(I.startTime<=A)o(p),I.sortIndex=I.expirationTime,n(m,I);else break;I=s(p)}}function R(A){if(N=!1,k(A),!S)if(s(m)!==null)S=!0,D||(D=!0,G());else{var I=s(p);I!==null&&V(R,I.startTime-A)}}var D=!1,z=-1,H=5,U=-1;function F(){return j?!0:!(e.unstable_now()-UA&&F());){var P=v.callback;if(typeof P=="function"){v.callback=null,y=v.priorityLevel;var T=P(v.expirationTime<=A);if(A=e.unstable_now(),typeof T=="function"){v.callback=T,k(A),I=!0;break t}v===s(m)&&o(m),k(A)}else o(m);v=s(m)}if(v!==null)I=!0;else{var B=s(p);B!==null&&V(R,B.startTime-A),I=!1}}break e}finally{v=null,y=$,b=!1}I=void 0}}finally{I?G():D=!1}}}var G;if(typeof _=="function")G=function(){_(K)};else if(typeof MessageChannel<"u"){var ne=new MessageChannel,L=ne.port2;ne.port1.onmessage=K,G=function(){L.postMessage(null)}}else G=function(){E(K,0)};function V(A,I){z=E(function(){A(e.unstable_now())},I)}e.unstable_IdlePriority=5,e.unstable_ImmediatePriority=1,e.unstable_LowPriority=4,e.unstable_NormalPriority=3,e.unstable_Profiling=null,e.unstable_UserBlockingPriority=2,e.unstable_cancelCallback=function(A){A.callback=null},e.unstable_forceFrameRate=function(A){0>A||125P?(A.sortIndex=$,n(p,A),s(m)===null&&A===s(p)&&(N?(M(z),z=-1):N=!0,V(R,$-P))):(A.sortIndex=T,n(m,A),S||b||(S=!0,D||(D=!0,G()))),A},e.unstable_shouldYield=F,e.unstable_wrapCallback=function(A){var I=y;return function(){var $=y;y=I;try{return A.apply(this,arguments)}finally{y=$}}}})(Vm)),Vm}var Wy;function wE(){return Wy||(Wy=1,Um.exports=bE()),Um.exports}var qm={exports:{}},Yt={};/** * @license React * react-dom.production.js * @@ -38,15 +38,15 @@ function gE(e,n){for(var s=0;sT||(t.current=P[T],P[T]=null,T--)}function Z(t,r){T++,P[T]=t.current,t.current=r}var re=B(null),de=B(null),ge=B(null),J=B(null);function le(t,r){switch(Z(ge,r),Z(de,t),Z(re,null),r.nodeType){case 9:case 11:t=(t=r.documentElement)&&(t=t.namespaceURI)?vy(t):0;break;default:if(t=r.tagName,r=r.namespaceURI)r=vy(r),t=by(r,t);else switch(t){case"svg":t=1;break;case"math":t=2;break;default:t=0}}W(re),Z(re,t)}function ve(){W(re),W(de),W(ge)}function Ne(t){t.memoizedState!==null&&Z(J,t);var r=re.current,i=by(r,t.type);r!==i&&(Z(de,t),Z(re,i))}function _e(t){de.current===t&&(W(re),W(de)),J.current===t&&(W(J),Ai._currentValue=$)}var be=Object.prototype.hasOwnProperty,Re=e.unstable_scheduleCallback,te=e.unstable_cancelCallback,Ee=e.unstable_shouldYield,Ve=e.unstable_requestPaint,Qe=e.unstable_now,It=e.unstable_getCurrentPriorityLevel,Zt=e.unstable_ImmediatePriority,ht=e.unstable_UserBlockingPriority,We=e.unstable_NormalPriority,dt=e.unstable_LowPriority,wn=e.unstable_IdlePriority,ae=e.log,ie=e.unstable_setDisableYieldValue,ue=null,me=null;function ye(t){if(typeof ae=="function"&&ie(t),me&&typeof me.setStrictMode=="function")try{me.setStrictMode(ue,t)}catch{}}var ce=Math.clz32?Math.clz32:Ke,Se=Math.log,De=Math.LN2;function Ke(t){return t>>>=0,t===0?32:31-(Se(t)/De|0)|0}var Ut=256,we=4194304;function He(t){var r=t&42;if(r!==0)return r;switch(t&-t){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t&4194048;case 4194304:case 8388608:case 16777216:case 33554432:return t&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return t}}function je(t,r,i){var u=t.pendingLanes;if(u===0)return 0;var h=0,x=t.suspendedLanes,C=t.pingedLanes;t=t.warmLanes;var O=u&134217727;return O!==0?(u=O&~x,u!==0?h=He(u):(C&=O,C!==0?h=He(C):i||(i=O&~t,i!==0&&(h=He(i))))):(O=u&~x,O!==0?h=He(O):C!==0?h=He(C):i||(i=u&~t,i!==0&&(h=He(i)))),h===0?0:r!==0&&r!==h&&(r&x)===0&&(x=h&-h,i=r&-r,x>=i||x===32&&(i&4194048)!==0)?r:h}function rt(t,r){return(t.pendingLanes&~(t.suspendedLanes&~t.pingedLanes)&r)===0}function ft(t,r){switch(t){case 1:case 2:case 4:case 8:case 64:return r+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return r+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function Vt(){var t=Ut;return Ut<<=1,(Ut&4194048)===0&&(Ut=256),t}function Fn(){var t=we;return we<<=1,(we&62914560)===0&&(we=4194304),t}function Ma(t){for(var r=[],i=0;31>i;i++)r.push(t);return r}function Ms(t,r){t.pendingLanes|=r,r!==268435456&&(t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0)}function kd(t,r,i,u,h,x){var C=t.pendingLanes;t.pendingLanes=i,t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0,t.expiredLanes&=i,t.entangledLanes&=i,t.errorRecoveryDisabledLanes&=i,t.shellSuspendCounter=0;var O=t.entanglements,q=t.expirationTimes,ee=t.hiddenUpdates;for(i=C&~i;0T||(t.current=P[T],P[T]=null,T--)}function Z(t,r){T++,P[T]=t.current,t.current=r}var re=B(null),de=B(null),ge=B(null),J=B(null);function le(t,r){switch(Z(ge,r),Z(de,t),Z(re,null),r.nodeType){case 9:case 11:t=(t=r.documentElement)&&(t=t.namespaceURI)?vy(t):0;break;default:if(t=r.tagName,r=r.namespaceURI)r=vy(r),t=by(r,t);else switch(t){case"svg":t=1;break;case"math":t=2;break;default:t=0}}W(re),Z(re,t)}function ve(){W(re),W(de),W(ge)}function Ne(t){t.memoizedState!==null&&Z(J,t);var r=re.current,i=by(r,t.type);r!==i&&(Z(de,t),Z(re,i))}function je(t){de.current===t&&(W(re),W(de)),J.current===t&&(W(J),Ai._currentValue=$)}var be=Object.prototype.hasOwnProperty,Re=e.unstable_scheduleCallback,te=e.unstable_cancelCallback,Ee=e.unstable_shouldYield,Ve=e.unstable_requestPaint,Qe=e.unstable_now,It=e.unstable_getCurrentPriorityLevel,Zt=e.unstable_ImmediatePriority,ht=e.unstable_UserBlockingPriority,We=e.unstable_NormalPriority,dt=e.unstable_LowPriority,wn=e.unstable_IdlePriority,ae=e.log,ie=e.unstable_setDisableYieldValue,ue=null,me=null;function ye(t){if(typeof ae=="function"&&ie(t),me&&typeof me.setStrictMode=="function")try{me.setStrictMode(ue,t)}catch{}}var ce=Math.clz32?Math.clz32:Ke,Se=Math.log,De=Math.LN2;function Ke(t){return t>>>=0,t===0?32:31-(Se(t)/De|0)|0}var Ut=256,we=4194304;function He(t){var r=t&42;if(r!==0)return r;switch(t&-t){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t&4194048;case 4194304:case 8388608:case 16777216:case 33554432:return t&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return t}}function _e(t,r,i){var u=t.pendingLanes;if(u===0)return 0;var h=0,x=t.suspendedLanes,C=t.pingedLanes;t=t.warmLanes;var O=u&134217727;return O!==0?(u=O&~x,u!==0?h=He(u):(C&=O,C!==0?h=He(C):i||(i=O&~t,i!==0&&(h=He(i))))):(O=u&~x,O!==0?h=He(O):C!==0?h=He(C):i||(i=u&~t,i!==0&&(h=He(i)))),h===0?0:r!==0&&r!==h&&(r&x)===0&&(x=h&-h,i=r&-r,x>=i||x===32&&(i&4194048)!==0)?r:h}function rt(t,r){return(t.pendingLanes&~(t.suspendedLanes&~t.pingedLanes)&r)===0}function ft(t,r){switch(t){case 1:case 2:case 4:case 8:case 64:return r+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return r+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function Vt(){var t=Ut;return Ut<<=1,(Ut&4194048)===0&&(Ut=256),t}function Fn(){var t=we;return we<<=1,(we&62914560)===0&&(we=4194304),t}function Ma(t){for(var r=[],i=0;31>i;i++)r.push(t);return r}function Ms(t,r){t.pendingLanes|=r,r!==268435456&&(t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0)}function kd(t,r,i,u,h,x){var C=t.pendingLanes;t.pendingLanes=i,t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0,t.expiredLanes&=i,t.entangledLanes&=i,t.errorRecoveryDisabledLanes&=i,t.shellSuspendCounter=0;var O=t.entanglements,q=t.expirationTimes,ee=t.hiddenUpdates;for(i=C&~i;0)":-1h||q[u]!==ee[h]){var fe=` `+q[u].replace(" at new "," at ");return t.displayName&&fe.includes("")&&(fe=fe.replace("",t.displayName)),fe}while(1<=u&&0<=h);break}}}finally{Ha=!1,Error.prepareStackTrace=i}return(i=t?t.displayName||t.name:"")?hr(i):""}function Od(t){switch(t.tag){case 26:case 27:case 5:return hr(t.type);case 16:return hr("Lazy");case 13:return hr("Suspense");case 19:return hr("SuspenseList");case 0:case 15:return $a(t.type,!1);case 11:return $a(t.type.render,!1);case 1:return $a(t.type,!0);case 31:return hr("Activity");default:return""}}function Rl(t){try{var r="";do r+=Od(t),t=t.return;while(t);return r}catch(i){return` Error generating stack: `+i.message+` -`+i.stack}}function en(t){switch(typeof t){case"bigint":case"boolean":case"number":case"string":case"undefined":return t;case"object":return t;default:return""}}function Dl(t){var r=t.type;return(t=t.nodeName)&&t.toLowerCase()==="input"&&(r==="checkbox"||r==="radio")}function zd(t){var r=Dl(t)?"checked":"value",i=Object.getOwnPropertyDescriptor(t.constructor.prototype,r),u=""+t[r];if(!t.hasOwnProperty(r)&&typeof i<"u"&&typeof i.get=="function"&&typeof i.set=="function"){var h=i.get,x=i.set;return Object.defineProperty(t,r,{configurable:!0,get:function(){return h.call(this)},set:function(C){u=""+C,x.call(this,C)}}),Object.defineProperty(t,r,{enumerable:i.enumerable}),{getValue:function(){return u},setValue:function(C){u=""+C},stopTracking:function(){t._valueTracker=null,delete t[r]}}}}function vo(t){t._valueTracker||(t._valueTracker=zd(t))}function Ba(t){if(!t)return!1;var r=t._valueTracker;if(!r)return!0;var i=r.getValue(),u="";return t&&(u=Dl(t)?t.checked?"true":"false":t.value),t=u,t!==i?(r.setValue(t),!0):!1}function bo(t){if(t=t||(typeof document<"u"?document:void 0),typeof t>"u")return null;try{return t.activeElement||t.body}catch{return t.body}}var Id=/[\n"\\]/g;function tn(t){return t.replace(Id,function(r){return"\\"+r.charCodeAt(0).toString(16)+" "})}function Rs(t,r,i,u,h,x,C,O){t.name="",C!=null&&typeof C!="function"&&typeof C!="symbol"&&typeof C!="boolean"?t.type=C:t.removeAttribute("type"),r!=null?C==="number"?(r===0&&t.value===""||t.value!=r)&&(t.value=""+en(r)):t.value!==""+en(r)&&(t.value=""+en(r)):C!=="submit"&&C!=="reset"||t.removeAttribute("value"),r!=null?Pa(t,C,en(r)):i!=null?Pa(t,C,en(i)):u!=null&&t.removeAttribute("value"),h==null&&x!=null&&(t.defaultChecked=!!x),h!=null&&(t.checked=h&&typeof h!="function"&&typeof h!="symbol"),O!=null&&typeof O!="function"&&typeof O!="symbol"&&typeof O!="boolean"?t.name=""+en(O):t.removeAttribute("name")}function Ol(t,r,i,u,h,x,C,O){if(x!=null&&typeof x!="function"&&typeof x!="symbol"&&typeof x!="boolean"&&(t.type=x),r!=null||i!=null){if(!(x!=="submit"&&x!=="reset"||r!=null))return;i=i!=null?""+en(i):"",r=r!=null?""+en(r):i,O||r===t.value||(t.value=r),t.defaultValue=r}u=u??h,u=typeof u!="function"&&typeof u!="symbol"&&!!u,t.checked=O?t.checked:!!u,t.defaultChecked=!!u,C!=null&&typeof C!="function"&&typeof C!="symbol"&&typeof C!="boolean"&&(t.name=C)}function Pa(t,r,i){r==="number"&&bo(t.ownerDocument)===t||t.defaultValue===""+i||(t.defaultValue=""+i)}function pr(t,r,i,u){if(t=t.options,r){r={};for(var h=0;h"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),Pd=!1;if(gr)try{var Va={};Object.defineProperty(Va,"passive",{get:function(){Pd=!0}}),window.addEventListener("test",Va,Va),window.removeEventListener("test",Va,Va)}catch{Pd=!1}var Vr=null,Ud=null,Il=null;function Sg(){if(Il)return Il;var t,r=Ud,i=r.length,u,h="value"in Vr?Vr.value:Vr.textContent,x=h.length;for(t=0;t=Ya),Ag=" ",Mg=!1;function Tg(t,r){switch(t){case"keyup":return Bj.indexOf(r.keyCode)!==-1;case"keydown":return r.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function Rg(t){return t=t.detail,typeof t=="object"&&"data"in t?t.data:null}var jo=!1;function Uj(t,r){switch(t){case"compositionend":return Rg(r);case"keypress":return r.which!==32?null:(Mg=!0,Ag);case"textInput":return t=r.data,t===Ag&&Mg?null:t;default:return null}}function Vj(t,r){if(jo)return t==="compositionend"||!Gd&&Tg(t,r)?(t=Sg(),Il=Ud=Vr=null,jo=!1,t):null;switch(t){case"paste":return null;case"keypress":if(!(r.ctrlKey||r.altKey||r.metaKey)||r.ctrlKey&&r.altKey){if(r.char&&1=r)return{node:i,offset:r-t};t=u}e:{for(;i;){if(i.nextSibling){i=i.nextSibling;break e}i=i.parentNode}i=void 0}i=Bg(i)}}function Ug(t,r){return t&&r?t===r?!0:t&&t.nodeType===3?!1:r&&r.nodeType===3?Ug(t,r.parentNode):"contains"in t?t.contains(r):t.compareDocumentPosition?!!(t.compareDocumentPosition(r)&16):!1:!1}function Vg(t){t=t!=null&&t.ownerDocument!=null&&t.ownerDocument.defaultView!=null?t.ownerDocument.defaultView:window;for(var r=bo(t.document);r instanceof t.HTMLIFrameElement;){try{var i=typeof r.contentWindow.location.href=="string"}catch{i=!1}if(i)t=r.contentWindow;else break;r=bo(t.document)}return r}function Wd(t){var r=t&&t.nodeName&&t.nodeName.toLowerCase();return r&&(r==="input"&&(t.type==="text"||t.type==="search"||t.type==="tel"||t.type==="url"||t.type==="password")||r==="textarea"||t.contentEditable==="true")}var Kj=gr&&"documentMode"in document&&11>=document.documentMode,_o=null,Kd=null,Wa=null,Qd=!1;function qg(t,r,i){var u=i.window===i?i.document:i.nodeType===9?i:i.ownerDocument;Qd||_o==null||_o!==bo(u)||(u=_o,"selectionStart"in u&&Wd(u)?u={start:u.selectionStart,end:u.selectionEnd}:(u=(u.ownerDocument&&u.ownerDocument.defaultView||window).getSelection(),u={anchorNode:u.anchorNode,anchorOffset:u.anchorOffset,focusNode:u.focusNode,focusOffset:u.focusOffset}),Wa&&Za(Wa,u)||(Wa=u,u=Ec(Kd,"onSelect"),0>=C,h-=C,yr=1<<32-ce(r)+h|i<x?x:8;var C=A.T,O={};A.T=O,Hf(t,!1,r,i);try{var q=h(),ee=A.S;if(ee!==null&&ee(O,q),q!==null&&typeof q=="object"&&typeof q.then=="function"){var fe=a_(q,u);di(t,r,fe,mn(t))}else di(t,r,u,mn(t))}catch(xe){di(t,r,{then:function(){},status:"rejected",reason:xe},mn())}finally{I.p=x,A.T=C}}function d_(){}function If(t,r,i,u){if(t.tag!==5)throw Error(o(476));var h=Fx(t).queue;qx(t,h,r,$,i===null?d_:function(){return Yx(t),i(u)})}function Fx(t){var r=t.memoizedState;if(r!==null)return r;r={memoizedState:$,baseState:$,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Nr,lastRenderedState:$},next:null};var i={};return r.next={memoizedState:i,baseState:i,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Nr,lastRenderedState:i},next:null},t.memoizedState=r,t=t.alternate,t!==null&&(t.memoizedState=r),r}function Yx(t){var r=Fx(t).next.queue;di(t,r,{},mn())}function Lf(){return Ft(Ai)}function Gx(){return Ct().memoizedState}function Xx(){return Ct().memoizedState}function f_(t){for(var r=t.return;r!==null;){switch(r.tag){case 24:case 3:var i=mn();t=Yr(i);var u=Gr(r,t,i);u!==null&&(hn(u,r,i),oi(u,r,i)),r={cache:mf()},t.payload=r;return}r=r.return}}function m_(t,r,i){var u=mn();i={lane:u,revertLane:0,action:i,hasEagerState:!1,eagerState:null,next:null},ac(t)?Wx(r,i):(i=nf(t,r,i,u),i!==null&&(hn(i,t,u),Kx(i,r,u)))}function Zx(t,r,i){var u=mn();di(t,r,i,u)}function di(t,r,i,u){var h={lane:u,revertLane:0,action:i,hasEagerState:!1,eagerState:null,next:null};if(ac(t))Wx(r,h);else{var x=t.alternate;if(t.lanes===0&&(x===null||x.lanes===0)&&(x=r.lastRenderedReducer,x!==null))try{var C=r.lastRenderedState,O=x(C,i);if(h.hasEagerState=!0,h.eagerState=O,ln(O,C))return Vl(t,r,h,0),pt===null&&Ul(),!1}catch{}finally{}if(i=nf(t,r,h,u),i!==null)return hn(i,t,u),Kx(i,r,u),!0}return!1}function Hf(t,r,i,u){if(u={lane:2,revertLane:gm(),action:u,hasEagerState:!1,eagerState:null,next:null},ac(t)){if(r)throw Error(o(479))}else r=nf(t,i,u,2),r!==null&&hn(r,t,2)}function ac(t){var r=t.alternate;return t===qe||r!==null&&r===qe}function Wx(t,r){zo=ec=!0;var i=t.pending;i===null?r.next=r:(r.next=i.next,i.next=r),t.pending=r}function Kx(t,r,i){if((i&4194048)!==0){var u=r.lanes;u&=t.pendingLanes,i|=u,r.lanes=i,Ta(t,i)}}var ic={readContext:Ft,use:nc,useCallback:St,useContext:St,useEffect:St,useImperativeHandle:St,useLayoutEffect:St,useInsertionEffect:St,useMemo:St,useReducer:St,useRef:St,useState:St,useDebugValue:St,useDeferredValue:St,useTransition:St,useSyncExternalStore:St,useId:St,useHostTransitionStatus:St,useFormState:St,useActionState:St,useOptimistic:St,useMemoCache:St,useCacheRefresh:St},Qx={readContext:Ft,use:nc,useCallback:function(t,r){return rn().memoizedState=[t,r===void 0?null:r],t},useContext:Ft,useEffect:zx,useImperativeHandle:function(t,r,i){i=i!=null?i.concat([t]):null,oc(4194308,4,$x.bind(null,r,t),i)},useLayoutEffect:function(t,r){return oc(4194308,4,t,r)},useInsertionEffect:function(t,r){oc(4,2,t,r)},useMemo:function(t,r){var i=rn();r=r===void 0?null:r;var u=t();if(qs){ye(!0);try{t()}finally{ye(!1)}}return i.memoizedState=[u,r],u},useReducer:function(t,r,i){var u=rn();if(i!==void 0){var h=i(r);if(qs){ye(!0);try{i(r)}finally{ye(!1)}}}else h=r;return u.memoizedState=u.baseState=h,t={pending:null,lanes:0,dispatch:null,lastRenderedReducer:t,lastRenderedState:h},u.queue=t,t=t.dispatch=m_.bind(null,qe,t),[u.memoizedState,t]},useRef:function(t){var r=rn();return t={current:t},r.memoizedState=t},useState:function(t){t=Rf(t);var r=t.queue,i=Zx.bind(null,qe,r);return r.dispatch=i,[t.memoizedState,i]},useDebugValue:Of,useDeferredValue:function(t,r){var i=rn();return zf(i,t,r)},useTransition:function(){var t=Rf(!1);return t=qx.bind(null,qe,t.queue,!0,!1),rn().memoizedState=t,[!1,t]},useSyncExternalStore:function(t,r,i){var u=qe,h=rn();if(ot){if(i===void 0)throw Error(o(407));i=i()}else{if(i=r(),pt===null)throw Error(o(349));(et&124)!==0||vx(u,r,i)}h.memoizedState=i;var x={value:i,getSnapshot:r};return h.queue=x,zx(wx.bind(null,u,x,t),[t]),u.flags|=2048,Lo(9,sc(),bx.bind(null,u,x,i,r),null),i},useId:function(){var t=rn(),r=pt.identifierPrefix;if(ot){var i=vr,u=yr;i=(u&~(1<<32-ce(u)-1)).toString(32)+i,r="«"+r+"R"+i,i=tc++,0$e?(zt=ze,ze=null):zt=ze.sibling;var st=se(X,ze,Q[$e],he);if(st===null){ze===null&&(ze=zt);break}t&&ze&&st.alternate===null&&r(X,ze),Y=x(st,Y,$e),Ye===null?ke=st:Ye.sibling=st,Ye=st,ze=zt}if($e===Q.length)return i(X,ze),ot&&Hs(X,$e),ke;if(ze===null){for(;$e$e?(zt=ze,ze=null):zt=ze.sibling;var us=se(X,ze,st.value,he);if(us===null){ze===null&&(ze=zt);break}t&&ze&&us.alternate===null&&r(X,ze),Y=x(us,Y,$e),Ye===null?ke=us:Ye.sibling=us,Ye=us,ze=zt}if(st.done)return i(X,ze),ot&&Hs(X,$e),ke;if(ze===null){for(;!st.done;$e++,st=Q.next())st=xe(X,st.value,he),st!==null&&(Y=x(st,Y,$e),Ye===null?ke=st:Ye.sibling=st,Ye=st);return ot&&Hs(X,$e),ke}for(ze=u(ze);!st.done;$e++,st=Q.next())st=oe(ze,X,$e,st.value,he),st!==null&&(t&&st.alternate!==null&&ze.delete(st.key===null?$e:st.key),Y=x(st,Y,$e),Ye===null?ke=st:Ye.sibling=st,Ye=st);return t&&ze.forEach(function(pE){return r(X,pE)}),ot&&Hs(X,$e),ke}function ut(X,Y,Q,he){if(typeof Q=="object"&&Q!==null&&Q.type===S&&Q.key===null&&(Q=Q.props.children),typeof Q=="object"&&Q!==null){switch(Q.$$typeof){case y:e:{for(var ke=Q.key;Y!==null;){if(Y.key===ke){if(ke=Q.type,ke===S){if(Y.tag===7){i(X,Y.sibling),he=h(Y,Q.props.children),he.return=X,X=he;break e}}else if(Y.elementType===ke||typeof ke=="object"&&ke!==null&&ke.$$typeof===H&&e0(ke)===Y.type){i(X,Y.sibling),he=h(Y,Q.props),mi(he,Q),he.return=X,X=he;break e}i(X,Y);break}else r(X,Y);Y=Y.sibling}Q.type===S?(he=Is(Q.props.children,X.mode,he,Q.key),he.return=X,X=he):(he=Fl(Q.type,Q.key,Q.props,null,X.mode,he),mi(he,Q),he.return=X,X=he)}return C(X);case b:e:{for(ke=Q.key;Y!==null;){if(Y.key===ke)if(Y.tag===4&&Y.stateNode.containerInfo===Q.containerInfo&&Y.stateNode.implementation===Q.implementation){i(X,Y.sibling),he=h(Y,Q.children||[]),he.return=X,X=he;break e}else{i(X,Y);break}else r(X,Y);Y=Y.sibling}he=of(Q,X.mode,he),he.return=X,X=he}return C(X);case H:return ke=Q._init,Q=ke(Q._payload),ut(X,Y,Q,he)}if(V(Q))return Be(X,Y,Q,he);if(G(Q)){if(ke=G(Q),typeof ke!="function")throw Error(o(150));return Q=ke.call(Q),Le(X,Y,Q,he)}if(typeof Q.then=="function")return ut(X,Y,lc(Q),he);if(Q.$$typeof===j)return ut(X,Y,Zl(X,Q),he);cc(X,Q)}return typeof Q=="string"&&Q!==""||typeof Q=="number"||typeof Q=="bigint"?(Q=""+Q,Y!==null&&Y.tag===6?(i(X,Y.sibling),he=h(Y,Q),he.return=X,X=he):(i(X,Y),he=sf(Q,X.mode,he),he.return=X,X=he),C(X)):i(X,Y)}return function(X,Y,Q,he){try{fi=0;var ke=ut(X,Y,Q,he);return Ho=null,ke}catch(ze){if(ze===ri||ze===Kl)throw ze;var Ye=cn(29,ze,null,X.mode);return Ye.lanes=he,Ye.return=X,Ye}finally{}}}var $o=t0(!0),n0=t0(!1),En=B(null),Xn=null;function Zr(t){var r=t.alternate;Z(Mt,Mt.current&1),Z(En,t),Xn===null&&(r===null||Oo.current!==null||r.memoizedState!==null)&&(Xn=t)}function r0(t){if(t.tag===22){if(Z(Mt,Mt.current),Z(En,t),Xn===null){var r=t.alternate;r!==null&&r.memoizedState!==null&&(Xn=t)}}else Wr()}function Wr(){Z(Mt,Mt.current),Z(En,En.current)}function Sr(t){W(En),Xn===t&&(Xn=null),W(Mt)}var Mt=B(0);function uc(t){for(var r=t;r!==null;){if(r.tag===13){var i=r.memoizedState;if(i!==null&&(i=i.dehydrated,i===null||i.data==="$?"||km(i)))return r}else if(r.tag===19&&r.memoizedProps.revealOrder!==void 0){if((r.flags&128)!==0)return r}else if(r.child!==null){r.child.return=r,r=r.child;continue}if(r===t)break;for(;r.sibling===null;){if(r.return===null||r.return===t)return null;r=r.return}r.sibling.return=r.return,r=r.sibling}return null}function $f(t,r,i,u){r=t.memoizedState,i=i(u,r),i=i==null?r:g({},r,i),t.memoizedState=i,t.lanes===0&&(t.updateQueue.baseState=i)}var Bf={enqueueSetState:function(t,r,i){t=t._reactInternals;var u=mn(),h=Yr(u);h.payload=r,i!=null&&(h.callback=i),r=Gr(t,h,u),r!==null&&(hn(r,t,u),oi(r,t,u))},enqueueReplaceState:function(t,r,i){t=t._reactInternals;var u=mn(),h=Yr(u);h.tag=1,h.payload=r,i!=null&&(h.callback=i),r=Gr(t,h,u),r!==null&&(hn(r,t,u),oi(r,t,u))},enqueueForceUpdate:function(t,r){t=t._reactInternals;var i=mn(),u=Yr(i);u.tag=2,r!=null&&(u.callback=r),r=Gr(t,u,i),r!==null&&(hn(r,t,i),oi(r,t,i))}};function s0(t,r,i,u,h,x,C){return t=t.stateNode,typeof t.shouldComponentUpdate=="function"?t.shouldComponentUpdate(u,x,C):r.prototype&&r.prototype.isPureReactComponent?!Za(i,u)||!Za(h,x):!0}function o0(t,r,i,u){t=r.state,typeof r.componentWillReceiveProps=="function"&&r.componentWillReceiveProps(i,u),typeof r.UNSAFE_componentWillReceiveProps=="function"&&r.UNSAFE_componentWillReceiveProps(i,u),r.state!==t&&Bf.enqueueReplaceState(r,r.state,null)}function Fs(t,r){var i=r;if("ref"in r){i={};for(var u in r)u!=="ref"&&(i[u]=r[u])}if(t=t.defaultProps){i===r&&(i=g({},i));for(var h in t)i[h]===void 0&&(i[h]=t[h])}return i}var dc=typeof reportError=="function"?reportError:function(t){if(typeof window=="object"&&typeof window.ErrorEvent=="function"){var r=new window.ErrorEvent("error",{bubbles:!0,cancelable:!0,message:typeof t=="object"&&t!==null&&typeof t.message=="string"?String(t.message):String(t),error:t});if(!window.dispatchEvent(r))return}else if(typeof process=="object"&&typeof process.emit=="function"){process.emit("uncaughtException",t);return}console.error(t)};function a0(t){dc(t)}function i0(t){console.error(t)}function l0(t){dc(t)}function fc(t,r){try{var i=t.onUncaughtError;i(r.value,{componentStack:r.stack})}catch(u){setTimeout(function(){throw u})}}function c0(t,r,i){try{var u=t.onCaughtError;u(i.value,{componentStack:i.stack,errorBoundary:r.tag===1?r.stateNode:null})}catch(h){setTimeout(function(){throw h})}}function Pf(t,r,i){return i=Yr(i),i.tag=3,i.payload={element:null},i.callback=function(){fc(t,r)},i}function u0(t){return t=Yr(t),t.tag=3,t}function d0(t,r,i,u){var h=i.type.getDerivedStateFromError;if(typeof h=="function"){var x=u.value;t.payload=function(){return h(x)},t.callback=function(){c0(r,i,u)}}var C=i.stateNode;C!==null&&typeof C.componentDidCatch=="function"&&(t.callback=function(){c0(r,i,u),typeof h!="function"&&(ns===null?ns=new Set([this]):ns.add(this));var O=u.stack;this.componentDidCatch(u.value,{componentStack:O!==null?O:""})})}function p_(t,r,i,u,h){if(i.flags|=32768,u!==null&&typeof u=="object"&&typeof u.then=="function"){if(r=i.alternate,r!==null&&ei(r,i,h,!0),i=En.current,i!==null){switch(i.tag){case 13:return Xn===null?dm():i.alternate===null&&Nt===0&&(Nt=3),i.flags&=-257,i.flags|=65536,i.lanes=h,u===gf?i.flags|=16384:(r=i.updateQueue,r===null?i.updateQueue=new Set([u]):r.add(u),mm(t,u,h)),!1;case 22:return i.flags|=65536,u===gf?i.flags|=16384:(r=i.updateQueue,r===null?(r={transitions:null,markerInstances:null,retryQueue:new Set([u])},i.updateQueue=r):(i=r.retryQueue,i===null?r.retryQueue=new Set([u]):i.add(u)),mm(t,u,h)),!1}throw Error(o(435,i.tag))}return mm(t,u,h),dm(),!1}if(ot)return r=En.current,r!==null?((r.flags&65536)===0&&(r.flags|=256),r.flags|=65536,r.lanes=h,u!==cf&&(t=Error(o(422),{cause:u}),Ja(Nn(t,i)))):(u!==cf&&(r=Error(o(423),{cause:u}),Ja(Nn(r,i))),t=t.current.alternate,t.flags|=65536,h&=-h,t.lanes|=h,u=Nn(u,i),h=Pf(t.stateNode,u,h),vf(t,h),Nt!==4&&(Nt=2)),!1;var x=Error(o(520),{cause:u});if(x=Nn(x,i),bi===null?bi=[x]:bi.push(x),Nt!==4&&(Nt=2),r===null)return!0;u=Nn(u,i),i=r;do{switch(i.tag){case 3:return i.flags|=65536,t=h&-h,i.lanes|=t,t=Pf(i.stateNode,u,t),vf(i,t),!1;case 1:if(r=i.type,x=i.stateNode,(i.flags&128)===0&&(typeof r.getDerivedStateFromError=="function"||x!==null&&typeof x.componentDidCatch=="function"&&(ns===null||!ns.has(x))))return i.flags|=65536,h&=-h,i.lanes|=h,h=u0(h),d0(h,t,i,u),vf(i,h),!1}i=i.return}while(i!==null);return!1}var f0=Error(o(461)),Dt=!1;function Lt(t,r,i,u){r.child=t===null?n0(r,null,i,u):$o(r,t.child,i,u)}function m0(t,r,i,u,h){i=i.render;var x=r.ref;if("ref"in u){var C={};for(var O in u)O!=="ref"&&(C[O]=u[O])}else C=u;return Us(r),u=jf(t,r,i,C,x,h),O=_f(),t!==null&&!Dt?(Ef(t,r,h),jr(t,r,h)):(ot&&O&&af(r),r.flags|=1,Lt(t,r,u,h),r.child)}function h0(t,r,i,u,h){if(t===null){var x=i.type;return typeof x=="function"&&!rf(x)&&x.defaultProps===void 0&&i.compare===null?(r.tag=15,r.type=x,p0(t,r,x,u,h)):(t=Fl(i.type,null,u,r,r.mode,h),t.ref=r.ref,t.return=r,r.child=t)}if(x=t.child,!Zf(t,h)){var C=x.memoizedProps;if(i=i.compare,i=i!==null?i:Za,i(C,u)&&t.ref===r.ref)return jr(t,r,h)}return r.flags|=1,t=xr(x,u),t.ref=r.ref,t.return=r,r.child=t}function p0(t,r,i,u,h){if(t!==null){var x=t.memoizedProps;if(Za(x,u)&&t.ref===r.ref)if(Dt=!1,r.pendingProps=u=x,Zf(t,h))(t.flags&131072)!==0&&(Dt=!0);else return r.lanes=t.lanes,jr(t,r,h)}return Uf(t,r,i,u,h)}function g0(t,r,i){var u=r.pendingProps,h=u.children,x=t!==null?t.memoizedState:null;if(u.mode==="hidden"){if((r.flags&128)!==0){if(u=x!==null?x.baseLanes|i:i,t!==null){for(h=r.child=t.child,x=0;h!==null;)x=x|h.lanes|h.childLanes,h=h.sibling;r.childLanes=x&~u}else r.childLanes=0,r.child=null;return x0(t,r,u,i)}if((i&536870912)!==0)r.memoizedState={baseLanes:0,cachePool:null},t!==null&&Wl(r,x!==null?x.cachePool:null),x!==null?px(r,x):wf(),r0(r);else return r.lanes=r.childLanes=536870912,x0(t,r,x!==null?x.baseLanes|i:i,i)}else x!==null?(Wl(r,x.cachePool),px(r,x),Wr(),r.memoizedState=null):(t!==null&&Wl(r,null),wf(),Wr());return Lt(t,r,h,i),r.child}function x0(t,r,i,u){var h=pf();return h=h===null?null:{parent:At._currentValue,pool:h},r.memoizedState={baseLanes:i,cachePool:h},t!==null&&Wl(r,null),wf(),r0(r),t!==null&&ei(t,r,u,!0),null}function mc(t,r){var i=r.ref;if(i===null)t!==null&&t.ref!==null&&(r.flags|=4194816);else{if(typeof i!="function"&&typeof i!="object")throw Error(o(284));(t===null||t.ref!==i)&&(r.flags|=4194816)}}function Uf(t,r,i,u,h){return Us(r),i=jf(t,r,i,u,void 0,h),u=_f(),t!==null&&!Dt?(Ef(t,r,h),jr(t,r,h)):(ot&&u&&af(r),r.flags|=1,Lt(t,r,i,h),r.child)}function y0(t,r,i,u,h,x){return Us(r),r.updateQueue=null,i=xx(r,u,i,h),gx(t),u=_f(),t!==null&&!Dt?(Ef(t,r,x),jr(t,r,x)):(ot&&u&&af(r),r.flags|=1,Lt(t,r,i,x),r.child)}function v0(t,r,i,u,h){if(Us(r),r.stateNode===null){var x=Ao,C=i.contextType;typeof C=="object"&&C!==null&&(x=Ft(C)),x=new i(u,x),r.memoizedState=x.state!==null&&x.state!==void 0?x.state:null,x.updater=Bf,r.stateNode=x,x._reactInternals=r,x=r.stateNode,x.props=u,x.state=r.memoizedState,x.refs={},xf(r),C=i.contextType,x.context=typeof C=="object"&&C!==null?Ft(C):Ao,x.state=r.memoizedState,C=i.getDerivedStateFromProps,typeof C=="function"&&($f(r,i,C,u),x.state=r.memoizedState),typeof i.getDerivedStateFromProps=="function"||typeof x.getSnapshotBeforeUpdate=="function"||typeof x.UNSAFE_componentWillMount!="function"&&typeof x.componentWillMount!="function"||(C=x.state,typeof x.componentWillMount=="function"&&x.componentWillMount(),typeof x.UNSAFE_componentWillMount=="function"&&x.UNSAFE_componentWillMount(),C!==x.state&&Bf.enqueueReplaceState(x,x.state,null),ii(r,u,x,h),ai(),x.state=r.memoizedState),typeof x.componentDidMount=="function"&&(r.flags|=4194308),u=!0}else if(t===null){x=r.stateNode;var O=r.memoizedProps,q=Fs(i,O);x.props=q;var ee=x.context,fe=i.contextType;C=Ao,typeof fe=="object"&&fe!==null&&(C=Ft(fe));var xe=i.getDerivedStateFromProps;fe=typeof xe=="function"||typeof x.getSnapshotBeforeUpdate=="function",O=r.pendingProps!==O,fe||typeof x.UNSAFE_componentWillReceiveProps!="function"&&typeof x.componentWillReceiveProps!="function"||(O||ee!==C)&&o0(r,x,u,C),Fr=!1;var se=r.memoizedState;x.state=se,ii(r,u,x,h),ai(),ee=r.memoizedState,O||se!==ee||Fr?(typeof xe=="function"&&($f(r,i,xe,u),ee=r.memoizedState),(q=Fr||s0(r,i,q,u,se,ee,C))?(fe||typeof x.UNSAFE_componentWillMount!="function"&&typeof x.componentWillMount!="function"||(typeof x.componentWillMount=="function"&&x.componentWillMount(),typeof x.UNSAFE_componentWillMount=="function"&&x.UNSAFE_componentWillMount()),typeof x.componentDidMount=="function"&&(r.flags|=4194308)):(typeof x.componentDidMount=="function"&&(r.flags|=4194308),r.memoizedProps=u,r.memoizedState=ee),x.props=u,x.state=ee,x.context=C,u=q):(typeof x.componentDidMount=="function"&&(r.flags|=4194308),u=!1)}else{x=r.stateNode,yf(t,r),C=r.memoizedProps,fe=Fs(i,C),x.props=fe,xe=r.pendingProps,se=x.context,ee=i.contextType,q=Ao,typeof ee=="object"&&ee!==null&&(q=Ft(ee)),O=i.getDerivedStateFromProps,(ee=typeof O=="function"||typeof x.getSnapshotBeforeUpdate=="function")||typeof x.UNSAFE_componentWillReceiveProps!="function"&&typeof x.componentWillReceiveProps!="function"||(C!==xe||se!==q)&&o0(r,x,u,q),Fr=!1,se=r.memoizedState,x.state=se,ii(r,u,x,h),ai();var oe=r.memoizedState;C!==xe||se!==oe||Fr||t!==null&&t.dependencies!==null&&Xl(t.dependencies)?(typeof O=="function"&&($f(r,i,O,u),oe=r.memoizedState),(fe=Fr||s0(r,i,fe,u,se,oe,q)||t!==null&&t.dependencies!==null&&Xl(t.dependencies))?(ee||typeof x.UNSAFE_componentWillUpdate!="function"&&typeof x.componentWillUpdate!="function"||(typeof x.componentWillUpdate=="function"&&x.componentWillUpdate(u,oe,q),typeof x.UNSAFE_componentWillUpdate=="function"&&x.UNSAFE_componentWillUpdate(u,oe,q)),typeof x.componentDidUpdate=="function"&&(r.flags|=4),typeof x.getSnapshotBeforeUpdate=="function"&&(r.flags|=1024)):(typeof x.componentDidUpdate!="function"||C===t.memoizedProps&&se===t.memoizedState||(r.flags|=4),typeof x.getSnapshotBeforeUpdate!="function"||C===t.memoizedProps&&se===t.memoizedState||(r.flags|=1024),r.memoizedProps=u,r.memoizedState=oe),x.props=u,x.state=oe,x.context=q,u=fe):(typeof x.componentDidUpdate!="function"||C===t.memoizedProps&&se===t.memoizedState||(r.flags|=4),typeof x.getSnapshotBeforeUpdate!="function"||C===t.memoizedProps&&se===t.memoizedState||(r.flags|=1024),u=!1)}return x=u,mc(t,r),u=(r.flags&128)!==0,x||u?(x=r.stateNode,i=u&&typeof i.getDerivedStateFromError!="function"?null:x.render(),r.flags|=1,t!==null&&u?(r.child=$o(r,t.child,null,h),r.child=$o(r,null,i,h)):Lt(t,r,i,h),r.memoizedState=x.state,t=r.child):t=jr(t,r,h),t}function b0(t,r,i,u){return Qa(),r.flags|=256,Lt(t,r,i,u),r.child}var Vf={dehydrated:null,treeContext:null,retryLane:0,hydrationErrors:null};function qf(t){return{baseLanes:t,cachePool:ix()}}function Ff(t,r,i){return t=t!==null?t.childLanes&~i:0,r&&(t|=Cn),t}function w0(t,r,i){var u=r.pendingProps,h=!1,x=(r.flags&128)!==0,C;if((C=x)||(C=t!==null&&t.memoizedState===null?!1:(Mt.current&2)!==0),C&&(h=!0,r.flags&=-129),C=(r.flags&32)!==0,r.flags&=-33,t===null){if(ot){if(h?Zr(r):Wr(),ot){var O=wt,q;if(q=O){e:{for(q=O,O=Gn;q.nodeType!==8;){if(!O){O=null;break e}if(q=zn(q.nextSibling),q===null){O=null;break e}}O=q}O!==null?(r.memoizedState={dehydrated:O,treeContext:Ls!==null?{id:yr,overflow:vr}:null,retryLane:536870912,hydrationErrors:null},q=cn(18,null,null,0),q.stateNode=O,q.return=r,r.child=q,Wt=r,wt=null,q=!0):q=!1}q||Bs(r)}if(O=r.memoizedState,O!==null&&(O=O.dehydrated,O!==null))return km(O)?r.lanes=32:r.lanes=536870912,null;Sr(r)}return O=u.children,u=u.fallback,h?(Wr(),h=r.mode,O=hc({mode:"hidden",children:O},h),u=Is(u,h,i,null),O.return=r,u.return=r,O.sibling=u,r.child=O,h=r.child,h.memoizedState=qf(i),h.childLanes=Ff(t,C,i),r.memoizedState=Vf,u):(Zr(r),Yf(r,O))}if(q=t.memoizedState,q!==null&&(O=q.dehydrated,O!==null)){if(x)r.flags&256?(Zr(r),r.flags&=-257,r=Gf(t,r,i)):r.memoizedState!==null?(Wr(),r.child=t.child,r.flags|=128,r=null):(Wr(),h=u.fallback,O=r.mode,u=hc({mode:"visible",children:u.children},O),h=Is(h,O,i,null),h.flags|=2,u.return=r,h.return=r,u.sibling=h,r.child=u,$o(r,t.child,null,i),u=r.child,u.memoizedState=qf(i),u.childLanes=Ff(t,C,i),r.memoizedState=Vf,r=h);else if(Zr(r),km(O)){if(C=O.nextSibling&&O.nextSibling.dataset,C)var ee=C.dgst;C=ee,u=Error(o(419)),u.stack="",u.digest=C,Ja({value:u,source:null,stack:null}),r=Gf(t,r,i)}else if(Dt||ei(t,r,i,!1),C=(i&t.childLanes)!==0,Dt||C){if(C=pt,C!==null&&(u=i&-i,u=(u&42)!==0?1:Ra(u),u=(u&(C.suspendedLanes|i))!==0?0:u,u!==0&&u!==q.retryLane))throw q.retryLane=u,ko(t,u),hn(C,t,u),f0;O.data==="$?"||dm(),r=Gf(t,r,i)}else O.data==="$?"?(r.flags|=192,r.child=t.child,r=null):(t=q.treeContext,wt=zn(O.nextSibling),Wt=r,ot=!0,$s=null,Gn=!1,t!==null&&(jn[_n++]=yr,jn[_n++]=vr,jn[_n++]=Ls,yr=t.id,vr=t.overflow,Ls=r),r=Yf(r,u.children),r.flags|=4096);return r}return h?(Wr(),h=u.fallback,O=r.mode,q=t.child,ee=q.sibling,u=xr(q,{mode:"hidden",children:u.children}),u.subtreeFlags=q.subtreeFlags&65011712,ee!==null?h=xr(ee,h):(h=Is(h,O,i,null),h.flags|=2),h.return=r,u.return=r,u.sibling=h,r.child=u,u=h,h=r.child,O=t.child.memoizedState,O===null?O=qf(i):(q=O.cachePool,q!==null?(ee=At._currentValue,q=q.parent!==ee?{parent:ee,pool:ee}:q):q=ix(),O={baseLanes:O.baseLanes|i,cachePool:q}),h.memoizedState=O,h.childLanes=Ff(t,C,i),r.memoizedState=Vf,u):(Zr(r),i=t.child,t=i.sibling,i=xr(i,{mode:"visible",children:u.children}),i.return=r,i.sibling=null,t!==null&&(C=r.deletions,C===null?(r.deletions=[t],r.flags|=16):C.push(t)),r.child=i,r.memoizedState=null,i)}function Yf(t,r){return r=hc({mode:"visible",children:r},t.mode),r.return=t,t.child=r}function hc(t,r){return t=cn(22,t,null,r),t.lanes=0,t.stateNode={_visibility:1,_pendingMarkers:null,_retryCache:null,_transitions:null},t}function Gf(t,r,i){return $o(r,t.child,null,i),t=Yf(r,r.pendingProps.children),t.flags|=2,r.memoizedState=null,t}function N0(t,r,i){t.lanes|=r;var u=t.alternate;u!==null&&(u.lanes|=r),df(t.return,r,i)}function Xf(t,r,i,u,h){var x=t.memoizedState;x===null?t.memoizedState={isBackwards:r,rendering:null,renderingStartTime:0,last:u,tail:i,tailMode:h}:(x.isBackwards=r,x.rendering=null,x.renderingStartTime=0,x.last=u,x.tail=i,x.tailMode=h)}function S0(t,r,i){var u=r.pendingProps,h=u.revealOrder,x=u.tail;if(Lt(t,r,u.children,i),u=Mt.current,(u&2)!==0)u=u&1|2,r.flags|=128;else{if(t!==null&&(t.flags&128)!==0)e:for(t=r.child;t!==null;){if(t.tag===13)t.memoizedState!==null&&N0(t,i,r);else if(t.tag===19)N0(t,i,r);else if(t.child!==null){t.child.return=t,t=t.child;continue}if(t===r)break e;for(;t.sibling===null;){if(t.return===null||t.return===r)break e;t=t.return}t.sibling.return=t.return,t=t.sibling}u&=1}switch(Z(Mt,u),h){case"forwards":for(i=r.child,h=null;i!==null;)t=i.alternate,t!==null&&uc(t)===null&&(h=i),i=i.sibling;i=h,i===null?(h=r.child,r.child=null):(h=i.sibling,i.sibling=null),Xf(r,!1,h,i,x);break;case"backwards":for(i=null,h=r.child,r.child=null;h!==null;){if(t=h.alternate,t!==null&&uc(t)===null){r.child=h;break}t=h.sibling,h.sibling=i,i=h,h=t}Xf(r,!0,i,null,x);break;case"together":Xf(r,!1,null,null,void 0);break;default:r.memoizedState=null}return r.child}function jr(t,r,i){if(t!==null&&(r.dependencies=t.dependencies),ts|=r.lanes,(i&r.childLanes)===0)if(t!==null){if(ei(t,r,i,!1),(i&r.childLanes)===0)return null}else return null;if(t!==null&&r.child!==t.child)throw Error(o(153));if(r.child!==null){for(t=r.child,i=xr(t,t.pendingProps),r.child=i,i.return=r;t.sibling!==null;)t=t.sibling,i=i.sibling=xr(t,t.pendingProps),i.return=r;i.sibling=null}return r.child}function Zf(t,r){return(t.lanes&r)!==0?!0:(t=t.dependencies,!!(t!==null&&Xl(t)))}function g_(t,r,i){switch(r.tag){case 3:le(r,r.stateNode.containerInfo),qr(r,At,t.memoizedState.cache),Qa();break;case 27:case 5:Ne(r);break;case 4:le(r,r.stateNode.containerInfo);break;case 10:qr(r,r.type,r.memoizedProps.value);break;case 13:var u=r.memoizedState;if(u!==null)return u.dehydrated!==null?(Zr(r),r.flags|=128,null):(i&r.child.childLanes)!==0?w0(t,r,i):(Zr(r),t=jr(t,r,i),t!==null?t.sibling:null);Zr(r);break;case 19:var h=(t.flags&128)!==0;if(u=(i&r.childLanes)!==0,u||(ei(t,r,i,!1),u=(i&r.childLanes)!==0),h){if(u)return S0(t,r,i);r.flags|=128}if(h=r.memoizedState,h!==null&&(h.rendering=null,h.tail=null,h.lastEffect=null),Z(Mt,Mt.current),u)break;return null;case 22:case 23:return r.lanes=0,g0(t,r,i);case 24:qr(r,At,t.memoizedState.cache)}return jr(t,r,i)}function j0(t,r,i){if(t!==null)if(t.memoizedProps!==r.pendingProps)Dt=!0;else{if(!Zf(t,i)&&(r.flags&128)===0)return Dt=!1,g_(t,r,i);Dt=(t.flags&131072)!==0}else Dt=!1,ot&&(r.flags&1048576)!==0&&ex(r,Gl,r.index);switch(r.lanes=0,r.tag){case 16:e:{t=r.pendingProps;var u=r.elementType,h=u._init;if(u=h(u._payload),r.type=u,typeof u=="function")rf(u)?(t=Fs(u,t),r.tag=1,r=v0(null,r,u,t,i)):(r.tag=0,r=Uf(null,r,u,t,i));else{if(u!=null){if(h=u.$$typeof,h===k){r.tag=11,r=m0(null,r,u,t,i);break e}else if(h===z){r.tag=14,r=h0(null,r,u,t,i);break e}}throw r=L(u)||u,Error(o(306,r,""))}}return r;case 0:return Uf(t,r,r.type,r.pendingProps,i);case 1:return u=r.type,h=Fs(u,r.pendingProps),v0(t,r,u,h,i);case 3:e:{if(le(r,r.stateNode.containerInfo),t===null)throw Error(o(387));u=r.pendingProps;var x=r.memoizedState;h=x.element,yf(t,r),ii(r,u,null,i);var C=r.memoizedState;if(u=C.cache,qr(r,At,u),u!==x.cache&&ff(r,[At],i,!0),ai(),u=C.element,x.isDehydrated)if(x={element:u,isDehydrated:!1,cache:C.cache},r.updateQueue.baseState=x,r.memoizedState=x,r.flags&256){r=b0(t,r,u,i);break e}else if(u!==h){h=Nn(Error(o(424)),r),Ja(h),r=b0(t,r,u,i);break e}else{switch(t=r.stateNode.containerInfo,t.nodeType){case 9:t=t.body;break;default:t=t.nodeName==="HTML"?t.ownerDocument.body:t}for(wt=zn(t.firstChild),Wt=r,ot=!0,$s=null,Gn=!0,i=n0(r,null,u,i),r.child=i;i;)i.flags=i.flags&-3|4096,i=i.sibling}else{if(Qa(),u===h){r=jr(t,r,i);break e}Lt(t,r,u,i)}r=r.child}return r;case 26:return mc(t,r),t===null?(i=ky(r.type,null,r.pendingProps,null))?r.memoizedState=i:ot||(i=r.type,t=r.pendingProps,u=kc(ge.current).createElement(i),u[Rt]=r,u[qt]=t,$t(u,i,t),_t(u),r.stateNode=u):r.memoizedState=ky(r.type,t.memoizedProps,r.pendingProps,t.memoizedState),null;case 27:return Ne(r),t===null&&ot&&(u=r.stateNode=_y(r.type,r.pendingProps,ge.current),Wt=r,Gn=!0,h=wt,os(r.type)?(Am=h,wt=zn(u.firstChild)):wt=h),Lt(t,r,r.pendingProps.children,i),mc(t,r),t===null&&(r.flags|=4194304),r.child;case 5:return t===null&&ot&&((h=u=wt)&&(u=q_(u,r.type,r.pendingProps,Gn),u!==null?(r.stateNode=u,Wt=r,wt=zn(u.firstChild),Gn=!1,h=!0):h=!1),h||Bs(r)),Ne(r),h=r.type,x=r.pendingProps,C=t!==null?t.memoizedProps:null,u=x.children,_m(h,x)?u=null:C!==null&&_m(h,C)&&(r.flags|=32),r.memoizedState!==null&&(h=jf(t,r,l_,null,null,i),Ai._currentValue=h),mc(t,r),Lt(t,r,u,i),r.child;case 6:return t===null&&ot&&((t=i=wt)&&(i=F_(i,r.pendingProps,Gn),i!==null?(r.stateNode=i,Wt=r,wt=null,t=!0):t=!1),t||Bs(r)),null;case 13:return w0(t,r,i);case 4:return le(r,r.stateNode.containerInfo),u=r.pendingProps,t===null?r.child=$o(r,null,u,i):Lt(t,r,u,i),r.child;case 11:return m0(t,r,r.type,r.pendingProps,i);case 7:return Lt(t,r,r.pendingProps,i),r.child;case 8:return Lt(t,r,r.pendingProps.children,i),r.child;case 12:return Lt(t,r,r.pendingProps.children,i),r.child;case 10:return u=r.pendingProps,qr(r,r.type,u.value),Lt(t,r,u.children,i),r.child;case 9:return h=r.type._context,u=r.pendingProps.children,Us(r),h=Ft(h),u=u(h),r.flags|=1,Lt(t,r,u,i),r.child;case 14:return h0(t,r,r.type,r.pendingProps,i);case 15:return p0(t,r,r.type,r.pendingProps,i);case 19:return S0(t,r,i);case 31:return u=r.pendingProps,i=r.mode,u={mode:u.mode,children:u.children},t===null?(i=hc(u,i),i.ref=r.ref,r.child=i,i.return=r,r=i):(i=xr(t.child,u),i.ref=r.ref,r.child=i,i.return=r,r=i),r;case 22:return g0(t,r,i);case 24:return Us(r),u=Ft(At),t===null?(h=pf(),h===null&&(h=pt,x=mf(),h.pooledCache=x,x.refCount++,x!==null&&(h.pooledCacheLanes|=i),h=x),r.memoizedState={parent:u,cache:h},xf(r),qr(r,At,h)):((t.lanes&i)!==0&&(yf(t,r),ii(r,null,null,i),ai()),h=t.memoizedState,x=r.memoizedState,h.parent!==u?(h={parent:u,cache:u},r.memoizedState=h,r.lanes===0&&(r.memoizedState=r.updateQueue.baseState=h),qr(r,At,u)):(u=x.cache,qr(r,At,u),u!==h.cache&&ff(r,[At],i,!0))),Lt(t,r,r.pendingProps.children,i),r.child;case 29:throw r.pendingProps}throw Error(o(156,r.tag))}function _r(t){t.flags|=4}function _0(t,r){if(r.type!=="stylesheet"||(r.state.loading&4)!==0)t.flags&=-16777217;else if(t.flags|=16777216,!Dy(r)){if(r=En.current,r!==null&&((et&4194048)===et?Xn!==null:(et&62914560)!==et&&(et&536870912)===0||r!==Xn))throw si=gf,lx;t.flags|=8192}}function pc(t,r){r!==null&&(t.flags|=4),t.flags&16384&&(r=t.tag!==22?Fn():536870912,t.lanes|=r,Vo|=r)}function hi(t,r){if(!ot)switch(t.tailMode){case"hidden":r=t.tail;for(var i=null;r!==null;)r.alternate!==null&&(i=r),r=r.sibling;i===null?t.tail=null:i.sibling=null;break;case"collapsed":i=t.tail;for(var u=null;i!==null;)i.alternate!==null&&(u=i),i=i.sibling;u===null?r||t.tail===null?t.tail=null:t.tail.sibling=null:u.sibling=null}}function vt(t){var r=t.alternate!==null&&t.alternate.child===t.child,i=0,u=0;if(r)for(var h=t.child;h!==null;)i|=h.lanes|h.childLanes,u|=h.subtreeFlags&65011712,u|=h.flags&65011712,h.return=t,h=h.sibling;else for(h=t.child;h!==null;)i|=h.lanes|h.childLanes,u|=h.subtreeFlags,u|=h.flags,h.return=t,h=h.sibling;return t.subtreeFlags|=u,t.childLanes=i,r}function x_(t,r,i){var u=r.pendingProps;switch(lf(r),r.tag){case 31:case 16:case 15:case 0:case 11:case 7:case 8:case 12:case 9:case 14:return vt(r),null;case 1:return vt(r),null;case 3:return i=r.stateNode,u=null,t!==null&&(u=t.memoizedState.cache),r.memoizedState.cache!==u&&(r.flags|=2048),wr(At),ve(),i.pendingContext&&(i.context=i.pendingContext,i.pendingContext=null),(t===null||t.child===null)&&(Ka(r)?_r(r):t===null||t.memoizedState.isDehydrated&&(r.flags&256)===0||(r.flags|=1024,rx())),vt(r),null;case 26:return i=r.memoizedState,t===null?(_r(r),i!==null?(vt(r),_0(r,i)):(vt(r),r.flags&=-16777217)):i?i!==t.memoizedState?(_r(r),vt(r),_0(r,i)):(vt(r),r.flags&=-16777217):(t.memoizedProps!==u&&_r(r),vt(r),r.flags&=-16777217),null;case 27:_e(r),i=ge.current;var h=r.type;if(t!==null&&r.stateNode!=null)t.memoizedProps!==u&&_r(r);else{if(!u){if(r.stateNode===null)throw Error(o(166));return vt(r),null}t=re.current,Ka(r)?tx(r):(t=_y(h,u,i),r.stateNode=t,_r(r))}return vt(r),null;case 5:if(_e(r),i=r.type,t!==null&&r.stateNode!=null)t.memoizedProps!==u&&_r(r);else{if(!u){if(r.stateNode===null)throw Error(o(166));return vt(r),null}if(t=re.current,Ka(r))tx(r);else{switch(h=kc(ge.current),t){case 1:t=h.createElementNS("http://www.w3.org/2000/svg",i);break;case 2:t=h.createElementNS("http://www.w3.org/1998/Math/MathML",i);break;default:switch(i){case"svg":t=h.createElementNS("http://www.w3.org/2000/svg",i);break;case"math":t=h.createElementNS("http://www.w3.org/1998/Math/MathML",i);break;case"script":t=h.createElement("div"),t.innerHTML="