diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 1af23c3c629..3a417bfff80 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,7 +1,5 @@ # 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 -python/packages/durabletask/ @microsoft/agentframework-durabletask-developers python/samples/getting_started/azure_functions/ @microsoft/agentframework-durabletask-developers python/samples/getting_started/durabletask/ @microsoft/agentframework-durabletask-developers diff --git a/.github/actions/azure-functions-integration-setup/action.yml b/.github/actions/azure-functions-integration-setup/action.yml deleted file mode 100644 index 28c1c6cd1d9..00000000000 --- a/.github/actions/azure-functions-integration-setup/action.yml +++ /dev/null @@ -1,48 +0,0 @@ -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 -e DTS_USE_DYNAMIC_TASK_HUBS=true 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: Start Redis - shell: bash - run: | - if [ "$(docker ps -aq -f name=redis)" ]; then - echo "Stopping and removing existing Redis" - docker rm -f redis - fi - echo "Starting Redis" - docker run -d --name redis -p 6379:6379 redis:latest - echo "Waiting for Redis to be ready" - timeout 30 bash -c 'until docker exec redis redis-cli ping | grep -q PONG; do sleep 1; done' - echo "Redis 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/instructions/durabletask-dotnet.instructions.md b/.github/instructions/durabletask-dotnet.instructions.md deleted file mode 100644 index 84aeb542eae..00000000000 --- a/.github/instructions/durabletask-dotnet.instructions.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -applyTo: "dotnet/src/Microsoft.Agents.AI.DurableTask/**,dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/**" ---- - -# Durable Task area code instructions - -The following guidelines apply to pull requests that modify files under -`dotnet/src/Microsoft.Agents.AI.DurableTask/**` or -`dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/**`: - -## CHANGELOG.md - -- Each pull request that modifies code should add just one bulleted entry to the `CHANGELOG.md` file containing a change title (usually the PR title) and a link to the PR itself. -- New PRs should be added to the top of the `CHANGELOG.md` file under a "## [Unreleased]" heading. -- If the PR is the first since the last release, the existing "## [Unreleased]" heading should be replaced with a "## v[X.Y.Z]" heading and the PRs since the last release should be added to the new "## [Unreleased]" heading. -- The style of new `CHANGELOG.md` entries should match the style of the other entries in the file. -- If the PR introduces a breaking change, the changelog entry should be prefixed with "[BREAKING]". diff --git a/.github/workflows/dotnet-build-and-test.yml b/.github/workflows/dotnet-build-and-test.yml index f428e53ca28..b5623c6fd1d 100644 --- a/.github/workflows/dotnet-build-and-test.yml +++ b/.github/workflows/dotnet-build-and-test.yml @@ -38,7 +38,6 @@ jobs: dotnetChanges: ${{ steps.filter.outputs.dotnet }} cosmosDbChanges: ${{ steps.filter.outputs.cosmosdb }} foundryHostingChanges: ${{ steps.filter.outputs.foundryHosting }} - functionsChanged: ${{ steps.filter.outputs.functions }} coreChanged: ${{ steps.filter.outputs.core }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 @@ -66,13 +65,6 @@ jobs: - 'dotnet/Directory.Packages.props' - 'dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-build-image.ps1' - '.github/workflows/dotnet-build-and-test.yml' - functions: - - 'dotnet/src/Microsoft.Agents.AI.DurableTask/**' - - 'dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/**' - - 'dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/**' - - 'dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/**' - - '.github/actions/azure-functions-integration-setup/**' - - '.github/workflows/dotnet-build-and-test.yml' core: - 'dotnet/src/Microsoft.Agents.AI/**' - 'dotnet/src/Microsoft.Agents.AI.Abstractions/**' @@ -242,7 +234,6 @@ jobs: -OutputPath dotnet/filtered-unit.slnx ./dotnet/eng/scripts/New-FilteredSolution.ps1 @commonArgs ` -TestProjectNameIncludeFilter "*IntegrationTests*" ` - -TestProjectNameExcludeFilter "*DurableTask.IntegrationTests*","*AzureFunctions.IntegrationTests*" ` -OutputPath dotnet/filtered-integration.slnx - name: Run Unit Tests @@ -440,113 +431,11 @@ jobs: AZURE_SEARCH_INDEX_NAME: ${{ secrets.AZURE_SEARCH_INDEX_NAME }} # IT_HOSTED_AGENT_IMAGE was exported into $GITHUB_ENV by the previous step. - # DurableTask and AzureFunctions integration tests (ubuntu/net10.0 only). - # Split from main dotnet-test job for path-based filtering and parallelism. - dotnet-test-functions: - needs: [paths-filter] - if: > - github.event_name != 'pull_request' && - (needs.paths-filter.outputs.functionsChanged == 'true' || - needs.paths-filter.outputs.coreChanged == 'true' || - github.event_name == 'schedule' || - github.event_name == 'workflow_dispatch') - runs-on: ubuntu-latest - environment: integration - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - persist-credentials: false - sparse-checkout: | - . - .github - dotnet - python - declarative-agents - - - name: Free runner disk space - uses: ./.github/actions/free-runner-disk-space - - - name: Setup dotnet - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 - with: - global-json-file: ${{ github.workspace }}/dotnet/global.json - - - name: Build functions integration test projects - shell: bash - working-directory: dotnet - run: | - dotnet build ./tests/Microsoft.Agents.AI.DurableTask.IntegrationTests -c Release -f net10.0 --warnaserror - dotnet build ./tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests -c Release -f net10.0 --warnaserror - - - name: Azure CLI Login - uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2 - with: - client-id: ${{ secrets.AZURE_CLIENT_ID }} - tenant-id: ${{ secrets.AZURE_TENANT_ID }} - subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - - - name: Set up Durable Task and Azure Functions Integration Test Emulators - uses: ./.github/actions/azure-functions-integration-setup - id: azure-functions-setup - - - name: Run Functions Integration Tests - shell: pwsh - working-directory: dotnet - run: | - # Run DurableTask integration tests - dotnet test ` - --project ./tests/Microsoft.Agents.AI.DurableTask.IntegrationTests ` - -f net10.0 ` - -c Release ` - --no-build -v Normal ` - --report-xunit-trx ` - --report-junit ` - --results-directory ../IntegrationTestResults/ ` - --ignore-exit-code 8 ` - --filter-not-trait "Category=IntegrationDisabled" ` - --parallel-algorithm aggressive ` - --max-threads 2.0x - - # Run AzureFunctions integration tests - dotnet test ` - --project ./tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests ` - -f net10.0 ` - -c Release ` - --no-build -v Normal ` - --report-xunit-trx ` - --report-junit ` - --results-directory ../IntegrationTestResults/ ` - --ignore-exit-code 8 ` - --filter-not-trait "Category=IntegrationDisabled" ` - --parallel-algorithm aggressive ` - --max-threads 2.0x - env: - # OpenAI Models - OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - OPENAI_CHAT_MODEL_NAME: ${{ vars.OPENAI_CHAT_MODEL_NAME }} - OPENAI_REASONING_MODEL_NAME: ${{ vars.OPENAI_REASONING_MODEL_NAME }} - # Azure OpenAI Models - AZURE_OPENAI_DEPLOYMENT_NAME: ${{ vars.AZURE_OPENAI_DEPLOYMENT_NAME }} - AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZURE_OPENAI_DEPLOYMENT_NAME }} - AZURE_OPENAI_ENDPOINT: ${{ vars.AZURE_OPENAI_ENDPOINT }} - # Microsoft Foundry - AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }} - AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZURE_AI_MODEL_DEPLOYMENT_NAME }} - AZURE_AI_BING_CONNECTION_ID: ${{ vars.AZURE_AI_BING_CONNECTION_ID }} - - - name: Upload functions test results - if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: dotnet-test-results-functions-net10.0-ubuntu-latest - path: IntegrationTestResults/**/*.junit - if-no-files-found: ignore - # This final job is required to satisfy the merge queue. It must only run (or succeed) if no tests failed dotnet-build-and-test-check: if: always() runs-on: ubuntu-latest - needs: [dotnet-build, dotnet-test, dotnet-foundry-hosted-it, dotnet-test-functions] + needs: [dotnet-build, dotnet-test, dotnet-foundry-hosted-it] steps: - name: Get Date shell: bash @@ -593,7 +482,7 @@ jobs: github.event_name != 'pull_request' && (contains(join(needs.*.result, ','), 'success') || contains(join(needs.*.result, ','), 'failure')) - needs: [dotnet-test, dotnet-test-functions] + needs: [dotnet-test] runs-on: ubuntu-latest defaults: run: diff --git a/.github/workflows/dotnet-integration-tests.yml b/.github/workflows/dotnet-integration-tests.yml index fad2b7a671b..0ec65bdd81c 100644 --- a/.github/workflows/dotnet-integration-tests.yml +++ b/.github/workflows/dotnet-integration-tests.yml @@ -83,10 +83,6 @@ jobs: tenant-id: ${{ secrets.AZURE_TENANT_ID }} subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - - name: Set up Durable Task and Azure Functions Integration Test Emulators - if: matrix.os == 'ubuntu-latest' - uses: ./.github/actions/azure-functions-integration-setup - - name: Run Integration Tests shell: bash run: | diff --git a/.github/workflows/python-integration-tests.yml b/.github/workflows/python-integration-tests.yml index 3308f294dc2..2007583cdb0 100644 --- a/.github/workflows/python-integration-tests.yml +++ b/.github/workflows/python-integration-tests.yml @@ -273,73 +273,6 @@ jobs: done kill -KILL -- "-$server_pid" 2>/dev/null || kill -KILL "$server_pid" 2>/dev/null || true - # Azure Functions + Durable Task integration tests - python-tests-functions: - name: Python Integration Tests - Functions - permissions: - contents: read - id-token: write - runs-on: ubuntu-latest - environment: integration - timeout-minutes: 60 - env: - UV_PYTHON: "3.11" - OPENAI_CHAT_COMPLETION_MODEL: ${{ vars.OPENAI__CHATMODELID }} - OPENAI_CHAT_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }} - OPENAI_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }} - OPENAI_EMBEDDING_MODEL: ${{ vars.OPENAI_EMBEDDING_MODEL_ID }} - OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }} - AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }} - AZURE_OPENAI_MODEL: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} - AZURE_OPENAI_CHAT_MODEL: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} - AZURE_OPENAI_CHAT_COMPLETION_MODEL: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }} - FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }} - FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }} - 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 - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - ref: ${{ inputs.checkout-ref }} - persist-credentials: false - - name: Set up python and install the project - id: python-setup - uses: ./.github/actions/python-setup - with: - python-version: ${{ env.UV_PYTHON }} - os: ${{ runner.os }} - - name: Azure CLI Login - uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2 - with: - 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 (Functions + Durable Task integration) - run: > - uv run pytest --import-mode=importlib - packages/azurefunctions/tests/integration_tests - packages/durabletask/tests/integration_tests - -m integration - -n logical --dist worksteal - -x - --timeout=480 --session-timeout=900 --timeout_method thread - --retries 2 --retry-delay 5 - --junitxml=pytest.xml - - name: Upload test results - if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: test-results-functions - path: ./python/pytest.xml - if-no-files-found: ignore - # Foundry integration tests python-tests-foundry: name: Python Integration Tests - Foundry @@ -553,7 +486,6 @@ jobs: python-tests-openai, python-tests-azure-openai, python-tests-misc-integration, - python-tests-functions, python-tests-foundry, python-tests-foundry-hosting, python-tests-cosmos, @@ -618,7 +550,6 @@ jobs: python-tests-openai, python-tests-azure-openai, python-tests-misc-integration, - python-tests-functions, python-tests-foundry, python-tests-foundry-hosting, python-tests-cosmos, diff --git a/.github/workflows/python-merge-tests.yml b/.github/workflows/python-merge-tests.yml index 7db1c4a641d..88ca8f642d8 100644 --- a/.github/workflows/python-merge-tests.yml +++ b/.github/workflows/python-merge-tests.yml @@ -36,7 +36,6 @@ jobs: openaiChanged: ${{ steps.filter.outputs.openai }} azureChanged: ${{ steps.filter.outputs.azure }} miscChanged: ${{ steps.filter.outputs.misc }} - functionsChanged: ${{ steps.filter.outputs.functions }} foundryChanged: ${{ steps.filter.outputs.foundry }} foundryHostingChanged: ${{ steps.filter.outputs.foundry_hosting }} cosmosChanged: ${{ steps.filter.outputs.cosmos }} @@ -76,9 +75,6 @@ jobs: - '.github/actions/setup-local-mcp-server/**' - '.github/workflows/python-merge-tests.yml' - '.github/workflows/python-integration-tests.yml' - functions: - - 'python/packages/azurefunctions/**' - - 'python/packages/durabletask/**' foundry: - 'python/packages/foundry/**' - 'python/samples/**/providers/foundry/**' @@ -390,84 +386,6 @@ jobs: path: ./python/pytest.xml if-no-files-found: ignore - # Azure Functions + Durable Task integration tests - python-tests-functions: - name: Python Tests - Functions Integration - needs: paths-filter - if: > - github.event_name != 'pull_request' && - needs.paths-filter.outputs.pythonChanges == 'true' && - (github.event_name != 'merge_group' || - needs.paths-filter.outputs.functionsChanged == 'true' || - needs.paths-filter.outputs.coreChanged == 'true') - runs-on: ubuntu-latest - environment: integration - env: - UV_PYTHON: "3.11" - OPENAI_CHAT_COMPLETION_MODEL: ${{ vars.OPENAI__CHATMODELID }} - OPENAI_CHAT_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }} - OPENAI_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }} - OPENAI_EMBEDDING_MODEL: ${{ vars.OPENAI_EMBEDDING_MODEL_ID }} - OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }} - AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }} - AZURE_OPENAI_MODEL: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} - AZURE_OPENAI_CHAT_MODEL: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} - AZURE_OPENAI_CHAT_COMPLETION_MODEL: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }} - FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }} - FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }} - 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 - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - name: Set up python and install the project - id: python-setup - uses: ./.github/actions/python-setup - with: - python-version: ${{ env.UV_PYTHON }} - os: ${{ runner.os }} - - name: Azure CLI Login - if: github.event_name != 'pull_request' - uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2 - with: - 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 (Functions + Durable Task integration) - run: > - uv run pytest --import-mode=importlib - packages/azurefunctions/tests/integration_tests - packages/durabletask/tests/integration_tests - -m integration - -n logical --dist worksteal - -x - --timeout=480 --session-timeout=900 --timeout_method thread - --retries 2 --retry-delay 5 - --junitxml=pytest.xml - working-directory: ./python - - name: Surface failing tests - if: always() - uses: pmeier/pytest-results-action@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2 - with: - path: ./python/pytest.xml - summary: true - display-options: fEX - fail-on-empty: false - title: Functions integration test results - - name: Upload test results - if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: test-results-functions - path: ./python/pytest.xml - if-no-files-found: ignore - python-tests-foundry: name: Python Integration Tests - Foundry needs: paths-filter @@ -730,7 +648,6 @@ jobs: python-tests-openai, python-tests-azure-openai, python-tests-misc-integration, - python-tests-functions, python-tests-foundry, python-tests-foundry-hosting, python-tests-cosmos, @@ -792,7 +709,6 @@ jobs: python-tests-openai, python-tests-azure-openai, python-tests-misc-integration, - python-tests-functions, python-tests-foundry, python-tests-foundry-hosting, python-tests-cosmos, diff --git a/README.md b/README.md index 1127f3c245c..c26c8c90dff 100644 --- a/README.md +++ b/README.md @@ -161,19 +161,19 @@ Console.WriteLine(await agent.RunAsync("Write a haiku about Microsoft Agent Fram ### Python -- [Getting Started](./python/samples/01-get-started): progressive tutorial from hello-world to hosting +- [Getting Started](./python/samples/01-get-started): progressive tutorial from hello-world to workflows - [Agent Concepts](./python/samples/02-agents): deep-dive samples by topic (tools, middleware, providers, etc.) - [Workflows](./python/samples/03-workflows): workflow creation and integration with agents -- [Hosting](./python/samples/04-hosting): A2A, Azure Functions, Durable Task hosting +- [Hosting](./python/samples/04-hosting): A2A, self-hosted protocol helpers, and Foundry hosted agents. Durable Task and Azure Functions samples are in the [Durable Agent Framework extension](https://github.com/microsoft/agent-framework-durable-extension/tree/main/python/samples). - [End-to-End](./python/samples/05-end-to-end): full applications, evaluation, and demos ### .NET -- [Getting Started](./dotnet/samples/01-get-started): progressive tutorial from hello agent to hosting +- [Getting Started](./dotnet/samples/01-get-started): progressive tutorial from hello agent to workflows - [Agent Concepts](./dotnet/samples/02-agents/Agents): basic agent creation and tool usage - [Agent Providers](./dotnet/samples/02-agents/AgentProviders): samples showing different agent providers - [Workflows](./dotnet/samples/03-workflows): advanced multi-agent patterns and workflow orchestration -- [Hosting](./dotnet/samples/04-hosting): A2A, Durable Agents, Durable Workflows +- [Hosting](./dotnet/samples/04-hosting): A2A and Foundry hosted agents. Durable agent and workflow samples are in the [Durable Agent Framework extension](https://github.com/microsoft/agent-framework-durable-extension/tree/main/dotnet/samples). - [End-to-End](./dotnet/samples/05-end-to-end): full applications and demos ## Community & Feedback diff --git a/docs/features/durable-agents/AGENTS.md b/docs/features/durable-agents/AGENTS.md deleted file mode 100644 index db6c06df73c..00000000000 --- a/docs/features/durable-agents/AGENTS.md +++ /dev/null @@ -1,48 +0,0 @@ -# AGENTS.md - -Instructions for AI coding agents working on durable agents documentation. - -## Scope - -This directory contains feature documentation for the durable agents integration. The source code and samples live elsewhere: - -- .NET implementation: `dotnet/src/Microsoft.Agents.AI.DurableTask/` and `dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/` -- Python implementation: `python/packages/durabletask/` and `python/packages/azurefunctions/` (package `agent-framework-azurefunctions`) -- .NET samples: `dotnet/samples/04-hosting/DurableAgents/` -- Python samples: `python/samples/04-hosting/durabletask/` -- Official docs (Microsoft Learn): - -## Document structure - -| File | Purpose | -| --- | --- | -| `README.md` | Main technical overview: architecture, hosting models, orchestration patterns, and links to samples. | -| `durable-agents-ttl.md` | Deep-dive on session Time-To-Live (TTL) configuration and behavior. | - -Add new sibling documents when a topic is too detailed for the README (e.g., a new feature like reliable streaming or MCP tool exposure). Keep the README focused on orientation and link out to siblings for depth. - -## Writing guidelines - -- **Audience**: Developers already familiar with the Microsoft Agent Framework who want to understand what durability adds and how to use it. -- **Host-agnostic first**: Durable agents work in console apps, Azure Functions, and any Durable Task–compatible host. Show host-agnostic patterns (plain orchestration functions, `IServiceCollection` registration) before Azure Functions–specific patterns. Avoid giving the impression that Azure Functions is the only hosting option. -- **Both languages**: Always include C# and Python examples side by side. Keep them equivalent in functionality. -- **Callout syntax**: Use GitHub-flavored callouts (`> [!NOTE]`, `> [!IMPORTANT]`, `> [!WARNING]`) rather than bold-text callouts (`> **Note:** ...`). -- **Line length**: Do not wrap long lines. Rely on text viewers / renderers for line wrapping. -- **Tables**: Use spaces around pipes in separator rows (`| --- |` not `|---|`). -- **Code snippets**: Keep them minimal and self-contained. Omit boilerplate (using statements, environment variable reads) unless the snippet is specifically about setup. -- **Cross-references**: Link to Microsoft Learn for conceptual background (Durable Entities, Durable Task Scheduler, Azure Functions). Link to sibling docs within this directory for feature deep-dives. - -## Linting - -Run markdownlint on all documents before committing, with line-length checks disabled: - -```bash -markdownlint docs/features/durable-agents/ --disable MD013 -``` - -## When to update these docs - -- A new durable agent feature is added (e.g., a new orchestration pattern, hosting model, or configuration option). -- The public API surface changes in a way that affects how developers use durable agents. -- New sample directories are added — update the sample links in README.md. -- The official Microsoft Learn documentation is restructured — update external links. diff --git a/docs/features/durable-agents/README.md b/docs/features/durable-agents/README.md index 525c447ebcb..3b9093bc94a 100644 --- a/docs/features/durable-agents/README.md +++ b/docs/features/durable-agents/README.md @@ -1,239 +1,9 @@ -# Durable agents +# Durable Agents Have Moved -## Overview +Durable Task and Azure Functions integrations for Microsoft Agent Framework are now maintained in the [Durable Agent Framework extension repository](https://github.com/microsoft/agent-framework-durable-extension). -Durable agents extend the standard Microsoft Agent Framework with **durable state management** powered by the Durable Task framework. An ordinary Agent Framework agent runs in-process: its conversation history lives in memory and is lost when the process ends. A durable agent persists conversation history and execution state in external storage so that sessions survive process restarts, failures, and scale-out events. - -| Capability | Ordinary agent | Durable agent | -| --- | --- | --- | -| Conversation history | In-memory only | Durably persisted | -| Failure recovery | State lost on crash | Automatically resumed | -| Multi-instance scale-out | Not supported | Any worker can resume a session | -| Multi-agent orchestrations | Manual coordination | Deterministic, checkpointed workflows | -| Human-in-the-loop | Must keep process alive | Can wait days/weeks with zero compute | -| Hosting | Any process | Console app, Azure Functions, or any Durable Task–compatible host | - -> [!NOTE] -> For a step-by-step tutorial and deployment guidance, see [Azure Functions (Durable)](https://learn.microsoft.com/agent-framework/integrations/azure-functions) on Microsoft Learn. - -## How durable agents work - -Durable agents are implemented on top of [Durable Entities](https://learn.microsoft.com/azure/azure-functions/durable/durable-functions-entities) (also called "virtual actors"). Each **agent session** maps to one entity instance whose state contains the full conversation history. When you send a message to a durable agent, the following happens: - -1. The message is dispatched to the entity identified by an `AgentSessionId` (a composite of the agent name and a unique session key). -2. The entity loads its persisted `DurableAgentState`, which includes the complete conversation history. -3. The entity invokes the underlying `AIAgent` with the full conversation history, collects the response, and appends both the request and the response to the state. -4. The updated state is persisted back to durable storage automatically. - -Because the entity framework serializes access to each entity instance, concurrent messages to the same session are processed one at a time, eliminating race conditions. - -### Agent session identity - -Every durable agent session is identified by an `AgentSessionId`, which has two components: - -- **Name** – the registered name of the agent (case-insensitive). -- **Key** – a unique session key (case-sensitive), typically a GUID. - -The session ID is mapped to an underlying Durable Task entity ID with a `dafx-` prefix (e.g., `dafx-joker`). This naming convention is consistent across both .NET and Python implementations. - -## Architecture - -### .NET - -The .NET implementation consists of two NuGet packages: - -| Package | Purpose | -| --- | --- | -| `Microsoft.Agents.AI.DurableTask` | Core durable agent types: `DurableAIAgent`, `AgentEntity`, `DurableAgentSession`, `AgentSessionId`, `DurableAgentsOptions`, and the state model. | -| `Microsoft.Agents.AI.Hosting.AzureFunctions` | Azure Functions hosting integration: auto-generated HTTP endpoints, MCP tool triggers, entity function triggers, and the `ConfigureDurableAgents` extension method on `FunctionsApplicationBuilder`. | - -Key types: - -- **`DurableAIAgent`** – A subclass of `AIAgent` used *inside orchestrations*. Obtained via `context.GetAgent("agentName")`, it routes `RunAsync` calls through the orchestration's entity APIs so that each call is checkpointed. -- **`DurableAIAgentProxy`** – A subclass of `AIAgent` used *outside orchestrations* (e.g., from HTTP triggers or console apps). It signals the entity via `DurableTaskClient` and polls for the response. -- **`AgentEntity`** – The `TaskEntity` that hosts the real agent. It loads the registered `AIAgent` by name, wraps it in an `EntityAgentWrapper`, feeds it the full conversation history, and persists the result. -- **`DurableAgentSession`** – An `AgentSession` subclass that carries the `AgentSessionId`. -- **`DurableAgentsOptions`** – Builder for registering agents and configuring TTL. - -### Python - -The core Python implementation is in the `agent-framework-durabletask` package (`python/packages/durabletask`). Azure Functions hosting (including `AgentFunctionApp`) is in the separate `agent-framework-azurefunctions` package (`python/packages/azurefunctions`). - -Key types: - -- **`DurableAIAgent`** – A generic proxy (`DurableAIAgent[TaskT]`) implementing `SupportsAgentRun`. Returns a `TaskT` from `run()` — either an `AgentResponse` (client context) or a `DurableAgentTask` (orchestration context, must be `yield`ed). -- **`DurableAIAgentWorker`** – Wraps a `TaskHubGrpcWorker` and registers agents as durable entities via `add_agent()`. -- **`DurableAIAgentClient`** – Wraps a `TaskHubGrpcClient` for external callers. `get_agent()` returns a `DurableAIAgent[AgentResponse]`. -- **`DurableAIAgentOrchestrationContext`** – Wraps an `OrchestrationContext` for use inside orchestrations. `get_agent()` returns a `DurableAIAgent[DurableAgentTask]`. -- **`AgentEntity`** – Platform-agnostic agent execution logic that manages state, invokes the agent, handles streaming, and calls response callbacks. - -## Hosting models - -### Azure Functions - -The recommended production hosting model. A single call to `ConfigureDurableAgents` (C#) or `AgentFunctionApp` (Python) automatically: - -- Registers agent entities with the Durable Task worker. -- Generates HTTP endpoints at `/api/agents/{agentName}/run` for each registered agent. -- Supports `thread_id` query parameter / JSON field and the `x-ms-thread-id` response header for session continuity. -- Supports fire-and-forget via the `x-ms-wait-for-response: false` header (returns HTTP 202). -- Optionally exposes agents as MCP tools. - -**C# example:** - -```csharp -using IHost app = FunctionsApplication - .CreateBuilder(args) - .ConfigureFunctionsWebApplication() - .ConfigureDurableAgents(options => options.AddAIAgent(agent)) - .Build(); -app.Run(); -``` - -**Python example:** - -```python -app = AgentFunctionApp(agents=[agent]) -``` - -### Console apps / generic hosts - -For self-hosted or non-serverless scenarios, register durable agents via `IServiceCollection.ConfigureDurableAgents` (.NET) or `DurableAIAgentWorker` (Python) with explicit Durable Task worker and client configuration. - -**C# example:** - -```csharp -IHost host = Host.CreateDefaultBuilder(args) - .ConfigureServices(services => - { - services.ConfigureDurableAgents( - options => options.AddAIAgent(agent), - workerBuilder: b => b.UseDurableTaskScheduler(connectionString), - clientBuilder: b => b.UseDurableTaskScheduler(connectionString)); - }) - .Build(); -``` - -**Python example:** - -```python -worker = DurableAIAgentWorker(TaskHubGrpcWorker(host_address="localhost:4001")) -worker.add_agent(agent) -worker.start() -``` - -## Deterministic multi-agent orchestrations - -Durable agents can be composed into deterministic, checkpointed workflows using Durable Task orchestrations. The orchestration framework replays orchestrator code on failure, so completed agent calls are not re-executed. - -### Patterns - -| Pattern | Description | -| --- | --- | -| **Sequential (chaining)** | Call agents one after another, passing outputs forward. | -| **Parallel (fan-out/fan-in)** | Run multiple agents concurrently and aggregate results. | -| **Conditional** | Branch orchestration logic based on structured agent output. | -| **Human-in-the-loop** | Pause for external events (approvals, feedback) with optional timeouts. | - -### Using agents in orchestrations - -Inside an orchestration function, obtain a `DurableAIAgent` via the orchestration context. Each agent gets its own session (created with `CreateSessionAsync` / `create_session`), and you can call the same agent multiple times on the same session to maintain conversation context across sequential invocations. - -**C#:** - -```csharp -static async Task WritingOrchestration(TaskOrchestrationContext context) -{ - // Get a durable agent reference — works in any host (console app, Azure Functions, etc.) - DurableAIAgent writer = context.GetAgent("WriterAgent"); - - // Create a session to maintain conversation context across multiple calls - AgentSession session = await writer.CreateSessionAsync(); - - // First call: generate an initial draft - AgentResponse draft = await writer.RunAsync( - message: "Write a concise inspirational sentence about learning.", - session: session); - - // Second call: refine the draft — the agent sees the full conversation history - AgentResponse refined = await writer.RunAsync( - message: $"Improve this further while keeping it under 25 words: {draft.Result.Text}", - session: session); - - return refined.Result.Text; -} -``` - -**Python:** - -```python -def writing_orchestration(context, _): - agent_ctx = DurableAIAgentOrchestrationContext(context) - - # Get a durable agent reference — works in any host (standalone worker, Azure Functions, etc.) - writer = agent_ctx.get_agent("WriterAgent") - - # Create a session to maintain conversation context across multiple calls - session = writer.create_session() - - # First call: generate an initial draft - draft = yield writer.run( - messages="Write a concise inspirational sentence about learning.", - session=session, - ) - - # Second call: refine the draft — the agent sees the full conversation history - refined = yield writer.run( - messages=f"Improve this further while keeping it under 25 words: {draft.text}", - session=session, - ) - - return refined.text -``` - -> [!IMPORTANT] -> In .NET, `DurableAIAgent.RunAsync` deliberately avoids `ConfigureAwait(false)` because the Durable Task Framework uses a custom synchronization context — all continuations must run on the orchestration thread. - -## Streaming and response callbacks - -Durable agents do not support true end-to-end streaming because entity operations are request/response. However, **reliable streaming** is supported via response callbacks: - -- **`IAgentResponseHandler`** (.NET) or **`AgentResponseCallbackProtocol`** (Python) – Implement this interface to receive streaming updates as the underlying agent generates them (e.g., push tokens to a Redis Stream for client consumption). -- The entity still returns the complete `AgentResponse` after the stream is fully consumed. -- Clients can reconnect and resume reading from a cursor-based stream (e.g., Redis Streams) without losing messages. - -See the **Reliable Streaming** samples for a complete implementation using Redis Streams. - -## Session TTL (Time-To-Live) - -Durable agent sessions support automatic cleanup via configurable TTL. See [Session TTL](durable-agents-ttl.md) for details on configuration, behavior, and best practices. - -## Observability - -When using the [Durable Task Scheduler](https://learn.microsoft.com/azure/azure-functions/durable/durable-task-scheduler/durable-task-scheduler) as the durable backend, you get built-in observability through its dashboard: - -- **Conversation history** – View complete chat history for each agent session. -- **Orchestration visualization** – See multi-agent execution flows, including parallel branches and conditional logic. -- **Performance metrics** – Monitor agent response times, token usage, and orchestration duration. -- **Debugging** – Trace tool invocations and external event handling. - -## Samples - -- **.NET** – [Console app samples](../../../dotnet/samples/04-hosting/DurableAgents/ConsoleApps/) and [Azure Functions samples](../../../dotnet/samples/04-hosting/DurableAgents/AzureFunctions/) covering single-agent, chaining, concurrency, conditionals, human-in-the-loop, long-running tools, MCP tool exposure, and reliable streaming. -- **Python** – [Durable Task samples](../../../python/samples/04-hosting/durabletask/) covering single-agent, multi-agent, streaming, chaining, concurrency, conditionals, and human-in-the-loop. - -## Packages - -| Language | Package | Source | -| --- | --- | --- | -| .NET | `Microsoft.Agents.AI.DurableTask` | [`dotnet/src/Microsoft.Agents.AI.DurableTask`](../../../dotnet/src/Microsoft.Agents.AI.DurableTask) | -| .NET | `Microsoft.Agents.AI.Hosting.AzureFunctions` | [`dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions`](../../../dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions) | -| Python | `agent-framework-durabletask` | [`python/packages/durabletask`](../../../python/packages/durabletask) | -| Python | `agent-framework-azurefunctions` | [`python/packages/azurefunctions`](../../../python/packages/azurefunctions) | - -## Further reading - -- [Azure Functions (Durable) — Microsoft Learn](https://learn.microsoft.com/agent-framework/integrations/azure-functions) -- [Durable Task Scheduler](https://learn.microsoft.com/azure/azure-functions/durable/durable-task-scheduler/durable-task-scheduler) -- [Durable Entities](https://learn.microsoft.com/azure/azure-functions/durable/durable-functions-entities) -- [Session TTL](durable-agents-ttl.md) +- [.NET source](https://github.com/microsoft/agent-framework-durable-extension/tree/main/dotnet/src) +- [.NET samples](https://github.com/microsoft/agent-framework-durable-extension/tree/main/dotnet/samples) +- [Python source](https://github.com/microsoft/agent-framework-durable-extension/tree/main/python/packages) +- [Python samples](https://github.com/microsoft/agent-framework-durable-extension/tree/main/python/samples) +- [Durable agent documentation](https://github.com/microsoft/agent-framework-durable-extension/tree/main/docs/features/durable-agents) diff --git a/docs/features/durable-agents/durable-agents-ttl.md b/docs/features/durable-agents/durable-agents-ttl.md deleted file mode 100644 index 1a4a4e32d68..00000000000 --- a/docs/features/durable-agents/durable-agents-ttl.md +++ /dev/null @@ -1,147 +0,0 @@ -# Time-To-Live (TTL) for durable agent sessions - -## Overview - -The durable agents automatically maintain conversation history and state for each session. Without automatic cleanup, this state can accumulate indefinitely, consuming storage resources and increasing costs. The Time-To-Live (TTL) feature provides automatic cleanup of idle agent sessions, ensuring that sessions are automatically deleted after a period of inactivity. - -## What is TTL? - -Time-To-Live (TTL) is a configurable duration that determines how long an agent session state will be retained after its last interaction. When an agent session is idle (no messages sent to it) for longer than the TTL period, the session state is automatically deleted. Each new interaction with an agent resets the TTL timer, extending the session's lifetime. - -## Benefits - -- **Automatic cleanup**: No manual intervention required to clean up idle agent sessions -- **Cost optimization**: Reduces storage costs by automatically removing unused session state -- **Resource management**: Prevents unbounded growth of agent session state in storage -- **Configurable**: Set TTL globally or per-agent type to match your application's needs - -## Configuration - -TTL can be configured at two levels: - -1. **Global default TTL**: Applies to all agent sessions unless overridden -2. **Per-agent type TTL**: Overrides the global default for specific agent types - -Additionally, you can configure a **minimum deletion delay** that controls how frequently deletion operations are scheduled. The default value is 5 minutes, and the maximum allowed value is also 5 minutes. - -> [!NOTE] -> Reducing the minimum deletion delay below 5 minutes can be useful for testing or for ensuring rapid cleanup of short-lived agent sessions. However, this can also increase the load on the system and should be used with caution. - -### Default values - -- **Default TTL**: 14 days -- **Minimum TTL deletion delay**: 5 minutes (maximum allowed value, subject to change in future releases) - -### Configuration examples - -#### .NET - -```csharp -// Configure global default TTL and minimum signal delay -services.ConfigureDurableAgents( - options => - { - // Set global default TTL to 7 days - options.DefaultTimeToLive = TimeSpan.FromDays(7); - - // Add agents (will use global default TTL) - options.AddAIAgent(myAgent); - }); - -// Configure per-agent TTL -services.ConfigureDurableAgents( - options => - { - options.DefaultTimeToLive = TimeSpan.FromDays(14); // Global default - - // Agent with custom TTL of 1 day - options.AddAIAgent(shortLivedAgent, timeToLive: TimeSpan.FromDays(1)); - - // Agent with custom TTL of 90 days - options.AddAIAgent(longLivedAgent, timeToLive: TimeSpan.FromDays(90)); - - // Agent using global default (14 days) - options.AddAIAgent(defaultAgent); - }); - -// Disable TTL for specific agents by setting TTL to null -services.ConfigureDurableAgents( - options => - { - options.DefaultTimeToLive = TimeSpan.FromDays(14); - - // Agent with no TTL (never expires) - options.AddAIAgent(permanentAgent, timeToLive: null); - }); -``` - -## How TTL works - -The following sections describe how TTL works in detail. - -### Expiration tracking - -Each agent session maintains an expiration timestamp in its internally managed state that is updated whenever the session processes a message: - -1. When a message is sent to an agent session, the expiration time is set to `current time + TTL` -2. The runtime schedules a delete operation for the expiration time (subject to minimum delay constraints) -3. When the delete operation runs, if the current time is past the expiration time, the session state is deleted. Otherwise, the delete operation is rescheduled for the next expiration time. - -### State deletion - -When an agent session expires, its entire state is deleted, including: - -- Conversation history -- Any custom state data -- Expiration timestamps - -After deletion, if a message is sent to the same agent session, a new session is created with a fresh conversation history. - -## Behavior examples - -The following examples illustrate how TTL works in different scenarios. - -### Example 1: Agent session expires after TTL - -1. Agent configured with 30-day TTL -2. User sends message at Day 0 → agent session created, expiration set to Day 30 -3. No further messages sent -4. At Day 30 → Agent session is deleted -5. User sends message at Day 31 → New agent session created with fresh conversation history - -### Example 2: TTL reset on interaction - -1. Agent configured with 30-day TTL -2. User sends message at Day 0 → agent session created, expiration set to Day 30 -3. User sends message at Day 15 → Expiration reset to Day 45 -4. User sends message at Day 40 → Expiration reset to Day 70 -5. Agent session remains active as long as there are regular interactions - -## Logging - -The TTL feature includes comprehensive logging to track state changes: - -- **Expiration time updated**: Logged when TTL expiration time is set or updated -- **Deletion scheduled**: Logged when a deletion check signal is scheduled -- **Deletion check**: Logged when a deletion check operation runs -- **Session expired**: Logged when an agent session is deleted due to expiration -- **TTL rescheduled**: Logged when a deletion signal is rescheduled - -These logs help monitor TTL behavior and troubleshoot any issues. - -## Best practices - -1. **Choose appropriate TTL values**: Balance between storage costs and user experience. Too short TTLs may delete active sessions, while too long TTLs may accumulate unnecessary state. - -2. **Use per-agent TTLs**: Different agents may have different usage patterns. Configure TTLs per-agent based on expected session lifetimes. - -3. **Monitor expiration logs**: Review logs to understand TTL behavior and adjust configuration as needed. - -4. **Test with short TTLs**: During development, use short TTLs (e.g., minutes) to verify TTL behavior without waiting for long periods. - -## Limitations - -- TTL is based on wall-clock time, not activity time. The expiration timer starts from the last message timestamp. -- Deletion checks are durably scheduled operations and may have slight delays depending on system load. -- Once an agent session is deleted, its conversation history cannot be recovered. -- TTL deletion requires at least one worker to be available to process the deletion operation message. diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 0d4e07415d4..3ba3f3b13b5 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -134,22 +134,6 @@ - - - - - - - - - - - - - - - - diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 0b980a7510b..9442f98cb18 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -15,7 +15,6 @@ - @@ -73,24 +72,6 @@ - - - - - - - - - - - - - - - - - - @@ -401,29 +382,6 @@ - - - - - - - - - - - - - - - - - - - - - - - @@ -633,7 +591,6 @@ - @@ -642,7 +599,6 @@ - @@ -671,10 +627,8 @@ - - @@ -692,14 +646,12 @@ - - @@ -718,4 +670,3 @@ - diff --git a/dotnet/agent-framework-release.slnf b/dotnet/agent-framework-release.slnf index e884d61bd02..01f099e7819 100644 --- a/dotnet/agent-framework-release.slnf +++ b/dotnet/agent-framework-release.slnf @@ -14,13 +14,11 @@ "src\\Microsoft.Agents.AI.CosmosNoSql\\Microsoft.Agents.AI.CosmosNoSql.csproj", "src\\Microsoft.Agents.AI.Declarative\\Microsoft.Agents.AI.Declarative.csproj", "src\\Microsoft.Agents.AI.DevUI\\Microsoft.Agents.AI.DevUI.csproj", - "src\\Microsoft.Agents.AI.DurableTask\\Microsoft.Agents.AI.DurableTask.csproj", "src\\Microsoft.Agents.AI.Hosting.A2A.AspNetCore\\Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj", "src\\Microsoft.Agents.AI.Hosting.A2A\\Microsoft.Agents.AI.Hosting.A2A.csproj", "src\\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj", "src\\Microsoft.Agents.AI.Hosting.AspNetCore\\Microsoft.Agents.AI.Hosting.AspNetCore.csproj", - "src\\Microsoft.Agents.AI.Hosting.AzureFunctions\\Microsoft.Agents.AI.Hosting.AzureFunctions.csproj", "src\\Microsoft.Agents.AI.Hosting.OpenAI\\Microsoft.Agents.AI.Hosting.OpenAI.csproj", "src\\Microsoft.Agents.AI.Hosting\\Microsoft.Agents.AI.Hosting.csproj", "src\\Microsoft.Agents.AI.LocalCodeAct\\Microsoft.Agents.AI.LocalCodeAct.csproj", diff --git a/dotnet/eng/scripts/New-FilteredSolution.ps1 b/dotnet/eng/scripts/New-FilteredSolution.ps1 index 4dde9aaee5a..6cfae546ae2 100644 --- a/dotnet/eng/scripts/New-FilteredSolution.ps1 +++ b/dotnet/eng/scripts/New-FilteredSolution.ps1 @@ -26,7 +26,7 @@ When specified, only test projects whose filename matches this pattern are kept. .PARAMETER TestProjectNameExcludeFilter - Optional wildcard pattern(s) to exclude test projects by name (e.g., *DurableTask.IntegrationTests*). + Optional wildcard pattern(s) to exclude test projects by name (e.g., *Slow.IntegrationTests*). When specified, test projects whose filename matches any of these patterns are removed. Applied after TestProjectNameIncludeFilter. Can be a single string or an array of strings. @@ -50,8 +50,8 @@ dotnet test --solution (./dotnet/eng/scripts/New-FilteredSolution.ps1 -Solution dotnet/agent-framework-dotnet.slnx -TargetFramework net472) --no-build -f net472 .EXAMPLE - # Generate integration tests excluding DurableTask and AzureFunctions - ./dotnet/eng/scripts/New-FilteredSolution.ps1 -Solution dotnet/agent-framework-dotnet.slnx -TargetFramework net10.0 -TestProjectNameIncludeFilter "*IntegrationTests*" -TestProjectNameExcludeFilter "*DurableTask.IntegrationTests*","*AzureFunctions.IntegrationTests*" -OutputPath filtered-other-integration.slnx + # Generate integration tests while excluding a long-running test project + ./dotnet/eng/scripts/New-FilteredSolution.ps1 -Solution dotnet/agent-framework-dotnet.slnx -TargetFramework net10.0 -TestProjectNameIncludeFilter "*IntegrationTests*" -TestProjectNameExcludeFilter "*Slow.IntegrationTests*" -OutputPath filtered-integration.slnx #> [CmdletBinding()] diff --git a/dotnet/eng/verify-samples/GetStartedSamples.cs b/dotnet/eng/verify-samples/GetStartedSamples.cs index 33fb95eeac2..f269d220723 100644 --- a/dotnet/eng/verify-samples/GetStartedSamples.cs +++ b/dotnet/eng/verify-samples/GetStartedSamples.cs @@ -92,14 +92,5 @@ internal static class GetStartedSamples "The output should not contain error messages or stack traces.", ], }, - - new SampleDefinition - { - Name = "06_host_your_agent", - ProjectPath = "samples/01-get-started/06_host_your_agent", - RequiredEnvironmentVariables = ["FOUNDRY_PROJECT_ENDPOINT"], - OptionalEnvironmentVariables = ["FOUNDRY_MODEL"], - SkipReason = "Requires Azure Functions Core Tools runtime and starts a web server.", - }, ]; } diff --git a/dotnet/samples/01-get-started/06_host_your_agent/06_host_your_agent.csproj b/dotnet/samples/01-get-started/06_host_your_agent/06_host_your_agent.csproj deleted file mode 100644 index 8d3cbe5c64f..00000000000 --- a/dotnet/samples/01-get-started/06_host_your_agent/06_host_your_agent.csproj +++ /dev/null @@ -1,30 +0,0 @@ - - - Exe - net10.0 - v4 - enable - enable - - HostedAgent - HostedAgent - - - - - - - - - - - - - - - - - - - - diff --git a/dotnet/samples/01-get-started/06_host_your_agent/Program.cs b/dotnet/samples/01-get-started/06_host_your_agent/Program.cs deleted file mode 100644 index ea689f07310..00000000000 --- a/dotnet/samples/01-get-started/06_host_your_agent/Program.cs +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -// This sample shows how to host an AI agent with Azure Functions (DurableAgents). -// -// Prerequisites: -// - Azure Functions Core Tools -// - Foundry project endpoint and credentials -// -// Environment variables: -// FOUNDRY_PROJECT_ENDPOINT -// FOUNDRY_MODEL (defaults to "gpt-5.4-mini") -// -// Run with: func start -// Then call: POST http://localhost:7071/api/agents/HostedAgent/run - -using Azure.AI.Projects; -using Azure.Identity; -using Microsoft.Agents.AI; -using Microsoft.Agents.AI.Hosting.AzureFunctions; -using Microsoft.Azure.Functions.Worker.Builder; -using Microsoft.Extensions.Hosting; - -var endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") - ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); -var model = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini"; - -// Set up an AI agent following the standard Microsoft Agent Framework pattern. -// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. -// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid -// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. -AIAgent agent = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential()) - .AsAIAgent(model: model, instructions: "You are a helpful assistant hosted in Azure Functions.", name: "HostedAgent"); - -// Configure the function app to host the AI agent. -// This will automatically generate HTTP API endpoints for the agent. -using IHost app = FunctionsApplication - .CreateBuilder(args) - .ConfigureFunctionsWebApplication() - .ConfigureDurableAgents(options => options.AddAIAgent(agent, timeToLive: TimeSpan.FromHours(1))) - .Build(); -app.Run(); diff --git a/dotnet/samples/01-get-started/06_host_your_agent/README.md b/dotnet/samples/01-get-started/06_host_your_agent/README.md new file mode 100644 index 00000000000..c2c419b896b --- /dev/null +++ b/dotnet/samples/01-get-started/06_host_your_agent/README.md @@ -0,0 +1,3 @@ +# Azure Functions Hosting Sample Has Moved + +The Azure Functions hosting tutorial is now maintained as the [single-agent Durable Agent sample](https://github.com/microsoft/agent-framework-durable-extension/tree/main/dotnet/samples/DurableAgents/AzureFunctions/01_SingleAgent). diff --git a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/.editorconfig b/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/.editorconfig deleted file mode 100644 index b43bf5ebd07..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/.editorconfig +++ /dev/null @@ -1,10 +0,0 @@ -# .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/04-hosting/DurableAgents/AzureFunctions/01_SingleAgent/01_SingleAgent.csproj b/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/01_SingleAgent/01_SingleAgent.csproj deleted file mode 100644 index 0c0e4f7fe0f..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/01_SingleAgent/01_SingleAgent.csproj +++ /dev/null @@ -1,42 +0,0 @@ - - - net10.0 - v4 - Exe - enable - enable - - SingleAgent - SingleAgent - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/01_SingleAgent/Program.cs b/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/01_SingleAgent/Program.cs deleted file mode 100644 index 9ce6c1ee289..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/01_SingleAgent/Program.cs +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -#pragma warning disable IDE0002 // Simplify Member Access - -using Azure; -using Azure.AI.OpenAI; -using Azure.Identity; -using Microsoft.Agents.AI; -using Microsoft.Agents.AI.Hosting.AzureFunctions; -using Microsoft.Azure.Functions.Worker.Builder; -using Microsoft.Extensions.Hosting; -using OpenAI.Chat; - -// Get the Azure OpenAI endpoint and deployment name from environment variables. -string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") - ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") - ?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT_NAME is not set."); - -// Use Azure Key Credential if provided, otherwise use Azure CLI Credential. -string? azureOpenAiKey = System.Environment.GetEnvironmentVariable("AZURE_OPENAI_KEY"); -// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. -// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid -// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. -AzureOpenAIClient client = !string.IsNullOrEmpty(azureOpenAiKey) - ? new AzureOpenAIClient(new Uri(endpoint), new AzureKeyCredential(azureOpenAiKey)) - : new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()); - -// 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).AsAIAgent(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, timeToLive: TimeSpan.FromHours(1))) - .Build(); -app.Run(); diff --git a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/01_SingleAgent/README.md b/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/01_SingleAgent/README.md deleted file mode 100644 index d4ac968978b..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/01_SingleAgent/README.md +++ /dev/null @@ -1,89 +0,0 @@ -# Single Agent Sample - -This sample demonstrates how to use the Durable Agent Framework (DAFx) to create a simple Azure Functions app that hosts a single AI agent and provides direct HTTP API access for interactive conversations. - -## Key Concepts Demonstrated - -- Using the Microsoft Agent Framework to define a simple AI agent with a name and instructions. -- Registering agents with the Function app and running them using HTTP. -- Conversation management (via session IDs) for isolated interactions. - -## Environment Setup - -See the [README.md](../README.md) file in the parent directory for more information on how to configure the environment, including how to install and run common sample dependencies. - -## Running the Sample - -With the environment setup and function app running, you can test the sample by sending an HTTP request to the agent endpoint. - -You can use the `demo.http` file to send a message to the agent, or a command line tool like `curl` as shown below: - -Bash (Linux/macOS/WSL): - -```bash -curl -X POST http://localhost:7071/api/agents/Joker/run \ - -H "Content-Type: text/plain" \ - -d "Tell me a joke about a pirate." -``` - -PowerShell: - -```powershell -Invoke-RestMethod -Method Post ` - -Uri http://localhost:7071/api/agents/Joker/run ` - -ContentType text/plain ` - -Body "Tell me a joke about a pirate." -``` - -You can also send JSON requests: - -```bash -curl -X POST http://localhost:7071/api/agents/Joker/run \ - -H "Content-Type: application/json" \ - -H "Accept: application/json" \ - -d '{"message": "Tell me a joke about a pirate."}' -``` - -To continue a conversation, include the `thread_id` in the query string or JSON body: - -```bash -curl -X POST "http://localhost:7071/api/agents/Joker/run?thread_id=your-thread-id" \ - -H "Content-Type: application/json" \ - -H "Accept: application/json" \ - -d '{"message": "Tell me another one."}' -``` - -The response from the agent will be displayed in the terminal where you ran `func start`. The expected `text/plain` output will look something like: - -```text -Why don't pirates ever learn the alphabet? Because they always get stuck at "C"! -``` - -The expected `application/json` output will look something like: - -```json -{ - "status": 200, - "thread_id": "ee6e47a0-f24b-40b1-ade8-16fcebb9eb40", - "response": { - "Messages": [ - { - "AuthorName": "Joker", - "CreatedAt": "2025-11-11T12:00:00.0000000Z", - "Role": "assistant", - "Contents": [ - { - "Type": "text", - "Text": "Why don't pirates ever learn the alphabet? Because they always get stuck at 'C'!" - } - ] - } - ], - "Usage": { - "InputTokenCount": 78, - "OutputTokenCount": 36, - "TotalTokenCount": 114 - } - } -} -``` diff --git a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/01_SingleAgent/demo.http b/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/01_SingleAgent/demo.http deleted file mode 100644 index 3b741adf31d..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/01_SingleAgent/demo.http +++ /dev/null @@ -1,8 +0,0 @@ -# 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/04-hosting/DurableAgents/AzureFunctions/01_SingleAgent/host.json b/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/01_SingleAgent/host.json deleted file mode 100644 index 9384a0a583d..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/01_SingleAgent/host.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "version": "2.0", - "logging": { - "logLevel": { - "Microsoft.Agents.AI.DurableTask": "Information", - "Microsoft.Agents.AI.Hosting.AzureFunctions": "Information", - "DurableTask": "Information", - "Microsoft.DurableTask": "Information" - } - }, - "extensions": { - "durableTask": { - "hubName": "default", - "storageProvider": { - "type": "AzureManaged", - "connectionStringName": "DURABLE_TASK_SCHEDULER_CONNECTION_STRING" - } - } - } -} diff --git a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/02_AgentOrchestration_Chaining/02_AgentOrchestration_Chaining.csproj b/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/02_AgentOrchestration_Chaining/02_AgentOrchestration_Chaining.csproj deleted file mode 100644 index 83032dcfd0e..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/02_AgentOrchestration_Chaining/02_AgentOrchestration_Chaining.csproj +++ /dev/null @@ -1,42 +0,0 @@ - - - net10.0 - v4 - Exe - enable - enable - - AgentOrchestration_Chaining - AgentOrchestration_Chaining - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/02_AgentOrchestration_Chaining/FunctionTriggers.cs b/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/02_AgentOrchestration_Chaining/FunctionTriggers.cs deleted file mode 100644 index 7f67b8a6df3..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/02_AgentOrchestration_Chaining/FunctionTriggers.cs +++ /dev/null @@ -1,92 +0,0 @@ -// 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"); - AgentSession writerSession = await writer.CreateSessionAsync(); - - AgentResponse initial = await writer.RunAsync( - message: "Write a concise inspirational sentence about learning.", - session: writerSession); - - AgentResponse refined = await writer.RunAsync( - message: $"Improve this further while keeping it under 25 words: {initial.Result.Text}", - session: writerSession); - - 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/04-hosting/DurableAgents/AzureFunctions/02_AgentOrchestration_Chaining/Program.cs b/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/02_AgentOrchestration_Chaining/Program.cs deleted file mode 100644 index b682e638a72..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/02_AgentOrchestration_Chaining/Program.cs +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -#pragma warning disable IDE0002 // Simplify Member Access - -using Azure; -using Azure.AI.OpenAI; -using Azure.Identity; -using Microsoft.Agents.AI; -using Microsoft.Agents.AI.Hosting.AzureFunctions; -using Microsoft.Azure.Functions.Worker.Builder; -using Microsoft.Extensions.Hosting; -using OpenAI.Chat; - -// Get the Azure OpenAI endpoint and deployment name from environment variables. -string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") - ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") - ?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT_NAME is not set."); - -// Use Azure Key Credential if provided, otherwise use Azure CLI Credential. -string? azureOpenAiKey = System.Environment.GetEnvironmentVariable("AZURE_OPENAI_API_KEY"); -// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. -// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid -// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. -AzureOpenAIClient client = !string.IsNullOrEmpty(azureOpenAiKey) - ? new AzureOpenAIClient(new Uri(endpoint), new AzureKeyCredential(azureOpenAiKey)) - : new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()); - -// Single agent used by the orchestration to demonstrate sequential calls on the same session. -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).AsAIAgent(WriterInstructions, WriterName); - -using IHost app = FunctionsApplication - .CreateBuilder(args) - .ConfigureFunctionsWebApplication() - .ConfigureDurableAgents(options => options.AddAIAgent(writerAgent)) - .Build(); - -app.Run(); diff --git a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/02_AgentOrchestration_Chaining/README.md b/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/02_AgentOrchestration_Chaining/README.md deleted file mode 100644 index 8fb86a4f524..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/02_AgentOrchestration_Chaining/README.md +++ /dev/null @@ -1,59 +0,0 @@ -# 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 session for context continuity. - -## Key Concepts Demonstrated - -- Orchestrating multiple interactions with the same agent in a deterministic order -- Using the same `AgentSession` 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/04-hosting/DurableAgents/AzureFunctions/02_AgentOrchestration_Chaining/demo.http b/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/02_AgentOrchestration_Chaining/demo.http deleted file mode 100644 index aa4dcc4a169..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/02_AgentOrchestration_Chaining/demo.http +++ /dev/null @@ -1,3 +0,0 @@ -### Start the single-agent orchestration -POST http://localhost:7071/api/singleagent/run - diff --git a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/02_AgentOrchestration_Chaining/host.json b/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/02_AgentOrchestration_Chaining/host.json deleted file mode 100644 index 9384a0a583d..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/02_AgentOrchestration_Chaining/host.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "version": "2.0", - "logging": { - "logLevel": { - "Microsoft.Agents.AI.DurableTask": "Information", - "Microsoft.Agents.AI.Hosting.AzureFunctions": "Information", - "DurableTask": "Information", - "Microsoft.DurableTask": "Information" - } - }, - "extensions": { - "durableTask": { - "hubName": "default", - "storageProvider": { - "type": "AzureManaged", - "connectionStringName": "DURABLE_TASK_SCHEDULER_CONNECTION_STRING" - } - } - } -} diff --git a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/03_AgentOrchestration_Concurrency/03_AgentOrchestration_Concurrency.csproj b/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/03_AgentOrchestration_Concurrency/03_AgentOrchestration_Concurrency.csproj deleted file mode 100644 index ac13f4ef1f9..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/03_AgentOrchestration_Concurrency/03_AgentOrchestration_Concurrency.csproj +++ /dev/null @@ -1,42 +0,0 @@ - - - net10.0 - v4 - Exe - enable - enable - - AgentOrchestration_Concurrency - AgentOrchestration_Concurrency - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/03_AgentOrchestration_Concurrency/FunctionTriggers.cs b/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/03_AgentOrchestration_Concurrency/FunctionTriggers.cs deleted file mode 100644 index 241faf6df7c..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/03_AgentOrchestration_Concurrency/FunctionTriggers.cs +++ /dev/null @@ -1,116 +0,0 @@ -// 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/04-hosting/DurableAgents/AzureFunctions/03_AgentOrchestration_Concurrency/Program.cs b/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/03_AgentOrchestration_Concurrency/Program.cs deleted file mode 100644 index 11e2ec3f497..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/03_AgentOrchestration_Concurrency/Program.cs +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -#pragma warning disable IDE0002 // Simplify Member Access - -using Azure; -using Azure.AI.OpenAI; -using Azure.Identity; -using Microsoft.Agents.AI; -using Microsoft.Agents.AI.Hosting.AzureFunctions; -using Microsoft.Azure.Functions.Worker.Builder; -using Microsoft.Extensions.Hosting; -using OpenAI.Chat; - -// Get the Azure OpenAI endpoint and deployment name from environment variables. -string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") - ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") - ?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT_NAME is not set."); - -// Use Azure Key Credential if provided, otherwise use Azure CLI Credential. -string? azureOpenAiKey = System.Environment.GetEnvironmentVariable("AZURE_OPENAI_API_KEY"); -// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. -// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid -// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. -AzureOpenAIClient client = !string.IsNullOrEmpty(azureOpenAiKey) - ? new AzureOpenAIClient(new Uri(endpoint), new AzureKeyCredential(azureOpenAiKey)) - : new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()); - -// 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).AsAIAgent(PhysicistInstructions, PhysicistName); -AIAgent chemistAgent = client.GetChatClient(deploymentName).AsAIAgent(ChemistInstructions, ChemistName); - -using IHost app = FunctionsApplication - .CreateBuilder(args) - .ConfigureFunctionsWebApplication() - .ConfigureDurableAgents(options => - { - options - .AddAIAgent(physicistAgent) - .AddAIAgent(chemistAgent); - }) - .Build(); - -app.Run(); diff --git a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/03_AgentOrchestration_Concurrency/README.md b/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/03_AgentOrchestration_Concurrency/README.md deleted file mode 100644 index 974aa1f2d2f..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/03_AgentOrchestration_Concurrency/README.md +++ /dev/null @@ -1,65 +0,0 @@ -# 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/04-hosting/DurableAgents/AzureFunctions/03_AgentOrchestration_Concurrency/demo.http b/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/03_AgentOrchestration_Concurrency/demo.http deleted file mode 100644 index 8004e27e8e4..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/03_AgentOrchestration_Concurrency/demo.http +++ /dev/null @@ -1,5 +0,0 @@ -### 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/04-hosting/DurableAgents/AzureFunctions/03_AgentOrchestration_Concurrency/host.json b/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/03_AgentOrchestration_Concurrency/host.json deleted file mode 100644 index 9384a0a583d..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/03_AgentOrchestration_Concurrency/host.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "version": "2.0", - "logging": { - "logLevel": { - "Microsoft.Agents.AI.DurableTask": "Information", - "Microsoft.Agents.AI.Hosting.AzureFunctions": "Information", - "DurableTask": "Information", - "Microsoft.DurableTask": "Information" - } - }, - "extensions": { - "durableTask": { - "hubName": "default", - "storageProvider": { - "type": "AzureManaged", - "connectionStringName": "DURABLE_TASK_SCHEDULER_CONNECTION_STRING" - } - } - } -} diff --git a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/04_AgentOrchestration_Conditionals/04_AgentOrchestration_Conditionals.csproj b/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/04_AgentOrchestration_Conditionals/04_AgentOrchestration_Conditionals.csproj deleted file mode 100644 index 2a10c88a4f6..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/04_AgentOrchestration_Conditionals/04_AgentOrchestration_Conditionals.csproj +++ /dev/null @@ -1,42 +0,0 @@ - - - net10.0 - v4 - Exe - enable - enable - - AgentOrchestration_Conditionals - AgentOrchestration_Conditionals - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/04_AgentOrchestration_Conditionals/FunctionTriggers.cs b/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/04_AgentOrchestration_Conditionals/FunctionTriggers.cs deleted file mode 100644 index 868c2be487f..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/04_AgentOrchestration_Conditionals/FunctionTriggers.cs +++ /dev/null @@ -1,143 +0,0 @@ -// 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"); - AgentSession spamSession = await spamDetectionAgent.CreateSessionAsync(); - - // Step 1: Check if the email is spam - AgentResponse 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} - """, - session: spamSession); - 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"); - AgentSession emailSession = await emailAssistantAgent.CreateSessionAsync(); - - AgentResponse 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} - """, - session: emailSession); - - 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/04-hosting/DurableAgents/AzureFunctions/04_AgentOrchestration_Conditionals/Models.cs b/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/04_AgentOrchestration_Conditionals/Models.cs deleted file mode 100644 index a39695d7d07..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/04_AgentOrchestration_Conditionals/Models.cs +++ /dev/null @@ -1,38 +0,0 @@ -// 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/04-hosting/DurableAgents/AzureFunctions/04_AgentOrchestration_Conditionals/Program.cs b/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/04_AgentOrchestration_Conditionals/Program.cs deleted file mode 100644 index 114ec7073f8..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/04_AgentOrchestration_Conditionals/Program.cs +++ /dev/null @@ -1,53 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -#pragma warning disable IDE0002 // Simplify Member Access - -using Azure; -using Azure.AI.OpenAI; -using Azure.Identity; -using Microsoft.Agents.AI; -using Microsoft.Agents.AI.Hosting.AzureFunctions; -using Microsoft.Azure.Functions.Worker.Builder; -using Microsoft.Extensions.Hosting; -using OpenAI.Chat; - -// Get the Azure OpenAI endpoint and deployment name from environment variables. -string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") - ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") - ?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT_NAME is not set."); - -// Use Azure Key Credential if provided, otherwise use Azure CLI Credential. -string? azureOpenAiKey = System.Environment.GetEnvironmentVariable("AZURE_OPENAI_API_KEY"); -// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. -// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid -// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. -AzureOpenAIClient client = !string.IsNullOrEmpty(azureOpenAiKey) - ? new AzureOpenAIClient(new Uri(endpoint), new AzureKeyCredential(azureOpenAiKey)) - : new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()); - -// 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) - .AsAIAgent(SpamDetectionInstructions, SpamDetectionName); - -AIAgent emailAssistantAgent = client.GetChatClient(deploymentName) - .AsAIAgent(EmailAssistantInstructions, EmailAssistantName); - -using IHost app = FunctionsApplication - .CreateBuilder(args) - .ConfigureFunctionsWebApplication() - .ConfigureDurableAgents(options => - { - options - .AddAIAgent(spamDetectionAgent) - .AddAIAgent(emailAssistantAgent); - }) - .Build(); - -app.Run(); diff --git a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/04_AgentOrchestration_Conditionals/README.md b/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/04_AgentOrchestration_Conditionals/README.md deleted file mode 100644 index 97202b18a80..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/04_AgentOrchestration_Conditionals/README.md +++ /dev/null @@ -1,113 +0,0 @@ -# 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/04-hosting/DurableAgents/AzureFunctions/04_AgentOrchestration_Conditionals/demo.http b/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/04_AgentOrchestration_Conditionals/demo.http deleted file mode 100644 index 1120a7a1812..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/04_AgentOrchestration_Conditionals/demo.http +++ /dev/null @@ -1,18 +0,0 @@ -### 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/04-hosting/DurableAgents/AzureFunctions/04_AgentOrchestration_Conditionals/host.json b/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/04_AgentOrchestration_Conditionals/host.json deleted file mode 100644 index 9384a0a583d..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/04_AgentOrchestration_Conditionals/host.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "version": "2.0", - "logging": { - "logLevel": { - "Microsoft.Agents.AI.DurableTask": "Information", - "Microsoft.Agents.AI.Hosting.AzureFunctions": "Information", - "DurableTask": "Information", - "Microsoft.DurableTask": "Information" - } - }, - "extensions": { - "durableTask": { - "hubName": "default", - "storageProvider": { - "type": "AzureManaged", - "connectionStringName": "DURABLE_TASK_SCHEDULER_CONNECTION_STRING" - } - } - } -} diff --git a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/05_AgentOrchestration_HITL/05_AgentOrchestration_HITL.csproj b/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/05_AgentOrchestration_HITL/05_AgentOrchestration_HITL.csproj deleted file mode 100644 index e0737012528..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/05_AgentOrchestration_HITL/05_AgentOrchestration_HITL.csproj +++ /dev/null @@ -1,43 +0,0 @@ - - - net10.0 - v4 - Exe - enable - enable - - AgentOrchestration_HITL - AgentOrchestration_HITL - $(NoWarn);DURABLE0001;DURABLE0002;DURABLE0003;DURABLE0004;DURABLE0005;DURABLE0006 - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/05_AgentOrchestration_HITL/FunctionTriggers.cs b/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/05_AgentOrchestration_HITL/FunctionTriggers.cs deleted file mode 100644 index 46282eec59a..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/05_AgentOrchestration_HITL/FunctionTriggers.cs +++ /dev/null @@ -1,229 +0,0 @@ -// 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"); - AgentSession writerSession = await writerAgent.CreateSessionAsync(); - - // Set initial status - context.SetCustomStatus($"Starting content generation for topic: {input.Topic}"); - - // Step 1: Generate initial content - AgentResponse writerResponse = await writerAgent.RunAsync( - message: $"Write a short article about '{input.Topic}'.", - session: writerSession); - 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} - """, - session: writerSession); - - 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/04-hosting/DurableAgents/AzureFunctions/05_AgentOrchestration_HITL/Models.cs b/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/05_AgentOrchestration_HITL/Models.cs deleted file mode 100644 index 1eaf1407eb1..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/05_AgentOrchestration_HITL/Models.cs +++ /dev/null @@ -1,44 +0,0 @@ -// 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/04-hosting/DurableAgents/AzureFunctions/05_AgentOrchestration_HITL/Program.cs b/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/05_AgentOrchestration_HITL/Program.cs deleted file mode 100644 index 331f435f89b..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/05_AgentOrchestration_HITL/Program.cs +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -#pragma warning disable IDE0002 // Simplify Member Access - -using Azure; -using Azure.AI.OpenAI; -using Azure.Identity; -using Microsoft.Agents.AI; -using Microsoft.Agents.AI.Hosting.AzureFunctions; -using Microsoft.Azure.Functions.Worker.Builder; -using Microsoft.Extensions.Hosting; -using OpenAI.Chat; - -// Get the Azure OpenAI endpoint and deployment name from environment variables. -string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") - ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") - ?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT_NAME is not set."); - -// Use Azure Key Credential if provided, otherwise use Azure CLI Credential. -string? azureOpenAiKey = System.Environment.GetEnvironmentVariable("AZURE_OPENAI_API_KEY"); -// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. -// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid -// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. -AzureOpenAIClient client = !string.IsNullOrEmpty(azureOpenAiKey) - ? new AzureOpenAIClient(new Uri(endpoint), new AzureKeyCredential(azureOpenAiKey)) - : new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()); - -// 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).AsAIAgent(WriterInstructions, WriterName); - -using IHost app = FunctionsApplication - .CreateBuilder(args) - .ConfigureFunctionsWebApplication() - .ConfigureDurableAgents(options => options.AddAIAgent(writerAgent)) - .Build(); - -app.Run(); diff --git a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/05_AgentOrchestration_HITL/README.md b/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/05_AgentOrchestration_HITL/README.md deleted file mode 100644 index b6aa2f037ab..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/05_AgentOrchestration_HITL/README.md +++ /dev/null @@ -1,126 +0,0 @@ -# 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/04-hosting/DurableAgents/AzureFunctions/05_AgentOrchestration_HITL/demo.http b/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/05_AgentOrchestration_HITL/demo.http deleted file mode 100644 index 2ab2dc428a2..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/05_AgentOrchestration_HITL/demo.http +++ /dev/null @@ -1,44 +0,0 @@ -### 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/04-hosting/DurableAgents/AzureFunctions/05_AgentOrchestration_HITL/host.json b/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/05_AgentOrchestration_HITL/host.json deleted file mode 100644 index 9384a0a583d..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/05_AgentOrchestration_HITL/host.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "version": "2.0", - "logging": { - "logLevel": { - "Microsoft.Agents.AI.DurableTask": "Information", - "Microsoft.Agents.AI.Hosting.AzureFunctions": "Information", - "DurableTask": "Information", - "Microsoft.DurableTask": "Information" - } - }, - "extensions": { - "durableTask": { - "hubName": "default", - "storageProvider": { - "type": "AzureManaged", - "connectionStringName": "DURABLE_TASK_SCHEDULER_CONNECTION_STRING" - } - } - } -} diff --git a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/06_LongRunningTools/06_LongRunningTools.csproj b/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/06_LongRunningTools/06_LongRunningTools.csproj deleted file mode 100644 index 185a57bc529..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/06_LongRunningTools/06_LongRunningTools.csproj +++ /dev/null @@ -1,42 +0,0 @@ - - - net10.0 - v4 - Exe - enable - enable - - LongRunningTools - LongRunningTools - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/06_LongRunningTools/FunctionTriggers.cs b/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/06_LongRunningTools/FunctionTriggers.cs deleted file mode 100644 index b707240ba8a..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/06_LongRunningTools/FunctionTriggers.cs +++ /dev/null @@ -1,152 +0,0 @@ -// 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"); - AgentSession writerSession = await writerAgent.CreateSessionAsync(); - - // Set initial status - context.SetCustomStatus($"Starting content generation for topic: {input.Topic}"); - - // Step 1: Generate initial content - AgentResponse writerResponse = await writerAgent.RunAsync( - message: $"Write a short article about '{input.Topic}'.", - session: writerSession); - 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) - { - // NOTE: CustomStatus has a 16 KB UTF-16 limit in Durable Functions. - // Only include short metadata here - the full content is passed via activity inputs/outputs. - context.SetCustomStatus( - new - { - message = "Requesting human feedback.", - approvalTimeoutHours = input.ApprovalTimeoutHours, - iterationCount, - contentTitle = content.Title, - }); - - // 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, - }); - 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...", - contentTitle = content.Title, - }); - - // 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, - contentTitle = content.Title, - }); - return new { content = content.Content }; - } - - context.SetCustomStatus(new - { - message = "Content rejected by human reviewer. Incorporating feedback and regenerating...", - humanFeedback = humanResponse, - contentTitle = content.Title, - }); - - // 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} - """, - session: writerSession); - - 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/04-hosting/DurableAgents/AzureFunctions/06_LongRunningTools/Models.cs b/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/06_LongRunningTools/Models.cs deleted file mode 100644 index 771343694d1..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/06_LongRunningTools/Models.cs +++ /dev/null @@ -1,44 +0,0 @@ -// 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/04-hosting/DurableAgents/AzureFunctions/06_LongRunningTools/Program.cs b/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/06_LongRunningTools/Program.cs deleted file mode 100644 index f1ee3965655..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/06_LongRunningTools/Program.cs +++ /dev/null @@ -1,78 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -#pragma warning disable IDE0002 // Simplify Member Access - -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.Chat; - -// Get the Azure OpenAI endpoint and deployment name from environment variables. -string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") - ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") - ?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT_NAME is not set."); - -// Use Azure Key Credential if provided, otherwise use Azure CLI Credential. -string? azureOpenAiKey = System.Environment.GetEnvironmentVariable("AZURE_OPENAI_API_KEY"); -// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. -// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid -// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. -AzureOpenAIClient client = !string.IsNullOrEmpty(azureOpenAiKey) - ? new AzureOpenAIClient(new Uri(endpoint), new AzureKeyCredential(azureOpenAiKey)) - : new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()); - -// 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).AsAIAgent(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).AsAIAgent( - 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/04-hosting/DurableAgents/AzureFunctions/06_LongRunningTools/README.md b/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/06_LongRunningTools/README.md deleted file mode 100644 index 54ed85060bc..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/06_LongRunningTools/README.md +++ /dev/null @@ -1,129 +0,0 @@ -# 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: 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/04-hosting/DurableAgents/AzureFunctions/06_LongRunningTools/Tools.cs b/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/06_LongRunningTools/Tools.cs deleted file mode 100644 index 4352e1d8d6a..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/06_LongRunningTools/Tools.cs +++ /dev/null @@ -1,93 +0,0 @@ -// 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}", SanitizeLogValue(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}", - SanitizeLogValue(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}", SanitizeLogValue(instanceId)); - - // Get the current agent context using the session-static property - OrchestrationMetadata? status = await DurableAgentContext.Current.GetOrchestrationStatusAsync( - instanceId, - includeDetails); - - if (status is null) - { - this._logger.LogInformation("Workflow instance '{InstanceId}' not found.", SanitizeLogValue(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}", SanitizeLogValue(instanceId)); - await DurableAgentContext.Current.RaiseOrchestrationEventAsync(instanceId, "HumanApproval", feedback); - } - - /// - /// Sanitizes a user-provided value for safe inclusion in log entries - /// by removing control characters that could be used for log forging. - /// - private static string SanitizeLogValue(string value) => - value - .Replace("\r", string.Empty, StringComparison.Ordinal) - .Replace("\n", string.Empty, StringComparison.Ordinal); -} diff --git a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/06_LongRunningTools/demo.http b/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/06_LongRunningTools/demo.http deleted file mode 100644 index c0f13f19926..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/06_LongRunningTools/demo.http +++ /dev/null @@ -1,27 +0,0 @@ -### 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/04-hosting/DurableAgents/AzureFunctions/06_LongRunningTools/host.json b/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/06_LongRunningTools/host.json deleted file mode 100644 index 9384a0a583d..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/06_LongRunningTools/host.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "version": "2.0", - "logging": { - "logLevel": { - "Microsoft.Agents.AI.DurableTask": "Information", - "Microsoft.Agents.AI.Hosting.AzureFunctions": "Information", - "DurableTask": "Information", - "Microsoft.DurableTask": "Information" - } - }, - "extensions": { - "durableTask": { - "hubName": "default", - "storageProvider": { - "type": "AzureManaged", - "connectionStringName": "DURABLE_TASK_SCHEDULER_CONNECTION_STRING" - } - } - } -} diff --git a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/07_AgentAsMcpTool/07_AgentAsMcpTool.csproj b/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/07_AgentAsMcpTool/07_AgentAsMcpTool.csproj deleted file mode 100644 index bba1b3f51f1..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/07_AgentAsMcpTool/07_AgentAsMcpTool.csproj +++ /dev/null @@ -1,42 +0,0 @@ - - - net10.0 - v4 - Exe - enable - enable - - AgentAsMcpTool - AgentAsMcpTool - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/07_AgentAsMcpTool/Program.cs b/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/07_AgentAsMcpTool/Program.cs deleted file mode 100644 index b371fb3cdc1..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/07_AgentAsMcpTool/Program.cs +++ /dev/null @@ -1,58 +0,0 @@ -// 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. - -#pragma warning disable IDE0002 // Simplify Member Access - -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.Chat; - -// Get the Azure OpenAI endpoint and deployment name from environment variables. -string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") - ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") - ?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT_NAME is not set."); - -// Use Azure Key Credential if provided, otherwise use Azure CLI Credential. -string? azureOpenAiKey = System.Environment.GetEnvironmentVariable("AZURE_OPENAI_API_KEY"); -// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. -// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid -// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. -AzureOpenAIClient client = !string.IsNullOrEmpty(azureOpenAiKey) - ? new AzureOpenAIClient(new Uri(endpoint), new AzureKeyCredential(azureOpenAiKey)) - : new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()); - -// Define three AI agents we are going to use in this application. -AIAgent agent1 = client.GetChatClient(deploymentName).AsAIAgent("You are good at telling jokes.", "Joker"); - -AIAgent agent2 = client.GetChatClient(deploymentName) - .AsAIAgent("Check stock prices.", "StockAdvisor"); - -AIAgent agent3 = client.GetChatClient(deploymentName) - .AsAIAgent("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/04-hosting/DurableAgents/AzureFunctions/07_AgentAsMcpTool/README.md b/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/07_AgentAsMcpTool/README.md deleted file mode 100644 index 632e84d3e4c..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/07_AgentAsMcpTool/README.md +++ /dev/null @@ -1,87 +0,0 @@ -# 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_NAME": "your-deployment-name", - "AZURE_OPENAI_API_KEY": "your-api-key-if-not-using-rbac" - } -} -``` - -## Running the Sample - -1. **Start the Function App**: - - ```bash - cd dotnet/samples/04-hosting/DurableAgents/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/04-hosting/DurableAgents/AzureFunctions/07_AgentAsMcpTool/host.json b/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/07_AgentAsMcpTool/host.json deleted file mode 100644 index aa36d82912e..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/07_AgentAsMcpTool/host.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "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/04-hosting/DurableAgents/AzureFunctions/08_ReliableStreaming/08_ReliableStreaming.csproj b/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/08_ReliableStreaming/08_ReliableStreaming.csproj deleted file mode 100644 index 10887faeb7c..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/08_ReliableStreaming/08_ReliableStreaming.csproj +++ /dev/null @@ -1,47 +0,0 @@ - - - net10.0 - v4 - Exe - enable - enable - - ReliableStreaming - ReliableStreaming - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/08_ReliableStreaming/FunctionTriggers.cs b/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/08_ReliableStreaming/FunctionTriggers.cs deleted file mode 100644 index 97dc45795fa..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/08_ReliableStreaming/FunctionTriggers.cs +++ /dev/null @@ -1,335 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Text; -using Microsoft.Agents.AI; -using Microsoft.Agents.AI.DurableTask; -using Microsoft.Agents.AI.Hosting.AzureFunctions; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.Features; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Azure.Functions.Worker; -using Microsoft.DurableTask.Client; -using Microsoft.Extensions.Logging; - -namespace ReliableStreaming; - -/// -/// HTTP trigger functions for reliable streaming of durable agent responses. -/// -/// -/// This class exposes two endpoints: -/// -/// -/// Create -/// Starts an agent run and streams responses. The response format depends on the -/// Accept header: text/plain returns raw text (ideal for terminals), while -/// text/event-stream or any other value returns Server-Sent Events (SSE). -/// -/// -/// Stream -/// Resumes a stream from a cursor position, enabling reliable message delivery -/// -/// -/// -public sealed class FunctionTriggers -{ - private readonly RedisStreamResponseHandler _streamHandler; - private readonly ILogger _logger; - - /// - /// Initializes a new instance of the class. - /// - /// The Redis stream handler for reading/writing agent responses. - /// The logger instance. - public FunctionTriggers(RedisStreamResponseHandler streamHandler, ILogger logger) - { - this._streamHandler = streamHandler; - this._logger = logger; - } - - /// - /// Creates a new agent session, starts an agent run with the provided prompt, - /// and streams the response back to the client. - /// - /// - /// - /// The response format depends on the Accept header: - /// - /// text/plain: Returns raw text output, ideal for terminal display with curl - /// text/event-stream or other: Returns Server-Sent Events (SSE) with cursor support - /// - /// - /// - /// The response includes an x-conversation-id header containing the conversation ID. - /// For SSE responses, clients can use this conversation ID to resume the stream if disconnected - /// by calling the endpoint with the conversation ID and the last received cursor. - /// - /// - /// Each SSE event contains the following fields: - /// - /// id: The Redis stream entry ID (use as cursor for resumption) - /// event: Either "message" for content or "done" for stream completion - /// data: The text content of the response chunk - /// - /// - /// - /// The HTTP request containing the prompt in the body. - /// The Durable Task client for signaling agents. - /// The function invocation context. - /// Cancellation token. - /// A streaming response in the format specified by the Accept header. - [Function(nameof(CreateAsync))] - public async Task CreateAsync( - [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "agent/create")] HttpRequest request, - [DurableClient] DurableTaskClient durableClient, - FunctionContext context, - CancellationToken cancellationToken) - { - // Read the prompt from the request body - string prompt = await new StreamReader(request.Body).ReadToEndAsync(cancellationToken); - if (string.IsNullOrWhiteSpace(prompt)) - { - return new BadRequestObjectResult("Request body must contain a prompt."); - } - - AIAgent agentProxy = durableClient.AsDurableAgentProxy(context, "TravelPlanner"); - - // Create a new agent session - AgentSession session = await agentProxy.CreateSessionAsync(cancellationToken); - string agentSessionId = session.GetService().ToString(); - - this._logger.LogInformation("Creating new agent session: {AgentSessionId}", agentSessionId); - - // Run the agent in the background (fire-and-forget) - DurableAgentRunOptions options = new() { IsFireAndForget = true }; - await agentProxy.RunAsync(prompt, session, options, cancellationToken); - - this._logger.LogInformation("Agent run started for session: {AgentSessionId}", agentSessionId); - - // Check Accept header to determine response format - // text/plain = raw text output (ideal for terminals) - // text/event-stream or other = SSE format (supports resumption) - string? acceptHeader = request.Headers.Accept.FirstOrDefault(); - bool useSseFormat = acceptHeader?.Contains("text/plain", StringComparison.OrdinalIgnoreCase) != true; - - return await this.StreamToClientAsync( - conversationId: agentSessionId, cursor: null, useSseFormat, request.HttpContext, cancellationToken); - } - - /// - /// Resumes streaming from a specific cursor position for an existing session. - /// - /// - /// - /// Use this endpoint to resume a stream after disconnection. Pass the conversation ID - /// (from the x-conversation-id response header) and the last received cursor - /// (Redis stream entry ID) to continue from where you left off. - /// - /// - /// If no cursor is provided, streaming starts from the beginning of the stream. - /// This allows clients to replay the entire response if needed. - /// - /// - /// The response format depends on the Accept header: - /// - /// text/plain: Returns raw text output, ideal for terminal display with curl - /// text/event-stream or other: Returns Server-Sent Events (SSE) with cursor support - /// - /// - /// - /// The HTTP request. Use the cursor query parameter to specify the cursor position. - /// The conversation ID to stream from. - /// Cancellation token. - /// A streaming response in the format specified by the Accept header. - [Function(nameof(StreamAsync))] - public async Task StreamAsync( - [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "agent/stream/{conversationId}")] HttpRequest request, - string conversationId, - CancellationToken cancellationToken) - { - if (string.IsNullOrWhiteSpace(conversationId)) - { - return new BadRequestObjectResult("Conversation ID is required."); - } - - // Get the cursor from query string (optional) - string? cursor = request.Query["cursor"].FirstOrDefault(); - - this._logger.LogInformation( - "Resuming stream for conversation {ConversationId} from cursor: {Cursor}", - SanitizeLogValue(conversationId), - SanitizeLogValue(cursor) ?? "(beginning)"); - - // Check Accept header to determine response format - // text/plain = raw text output (ideal for terminals) - // text/event-stream or other = SSE format (supports cursor-based resumption) - string? acceptHeader = request.Headers.Accept.FirstOrDefault(); - bool useSseFormat = acceptHeader?.Contains("text/plain", StringComparison.OrdinalIgnoreCase) != true; - - return await this.StreamToClientAsync(conversationId, cursor, useSseFormat, request.HttpContext, cancellationToken); - } - - /// - /// Streams chunks from the Redis stream to the HTTP response. - /// - /// The conversation ID to stream from. - /// Optional cursor to resume from. If null, streams from the beginning. - /// True to use SSE format, false for plain text. - /// The HTTP context for writing the response. - /// Cancellation token. - /// An empty result after streaming completes. - private async Task StreamToClientAsync( - string conversationId, - string? cursor, - bool useSseFormat, - HttpContext httpContext, - CancellationToken cancellationToken) - { - // Set response headers based on format - httpContext.Response.Headers.ContentType = useSseFormat - ? "text/event-stream" - : "text/plain; charset=utf-8"; - httpContext.Response.Headers.CacheControl = "no-cache"; - httpContext.Response.Headers.Connection = "keep-alive"; - httpContext.Response.Headers["x-conversation-id"] = conversationId; - - // Disable response buffering if supported - httpContext.Features.Get()?.DisableBuffering(); - - try - { - await foreach (StreamChunk chunk in this._streamHandler.ReadStreamAsync( - conversationId, - cursor, - cancellationToken)) - { - if (chunk.Error != null) - { - this._logger.LogWarning("Stream error for conversation {ConversationId}: {Error}", SanitizeLogValue(conversationId), chunk.Error); - await WriteErrorAsync(httpContext.Response, chunk.Error, useSseFormat, cancellationToken); - break; - } - - if (chunk.IsDone) - { - await WriteEndOfStreamAsync(httpContext.Response, chunk.EntryId, useSseFormat, cancellationToken); - break; - } - - if (chunk.Text != null) - { - await WriteChunkAsync(httpContext.Response, chunk, useSseFormat, cancellationToken); - } - } - } - catch (OperationCanceledException) - { - this._logger.LogInformation("Client disconnected from stream {ConversationId}", SanitizeLogValue(conversationId)); - } - - return new EmptyResult(); - } - - /// - /// Writes a text chunk to the response. - /// - private static async Task WriteChunkAsync( - HttpResponse response, - StreamChunk chunk, - bool useSseFormat, - CancellationToken cancellationToken) - { - if (useSseFormat) - { - await WriteSSEEventAsync(response, "message", chunk.Text!, chunk.EntryId); - } - else - { - await response.WriteAsync(chunk.Text!, cancellationToken); - } - - await response.Body.FlushAsync(cancellationToken); - } - - /// - /// Writes an end-of-stream marker to the response. - /// - private static async Task WriteEndOfStreamAsync( - HttpResponse response, - string entryId, - bool useSseFormat, - CancellationToken cancellationToken) - { - if (useSseFormat) - { - await WriteSSEEventAsync(response, "done", "[DONE]", entryId); - } - else - { - await response.WriteAsync("\n", cancellationToken); - } - - await response.Body.FlushAsync(cancellationToken); - } - - /// - /// Writes an error message to the response. - /// - private static async Task WriteErrorAsync( - HttpResponse response, - string error, - bool useSseFormat, - CancellationToken cancellationToken) - { - if (useSseFormat) - { - await WriteSSEEventAsync(response, "error", error, null); - } - else - { - await response.WriteAsync($"\n[Error: {error}]\n", cancellationToken); - } - - await response.Body.FlushAsync(cancellationToken); - } - - /// - /// Writes a Server-Sent Event to the response stream. - /// - private static async Task WriteSSEEventAsync( - HttpResponse response, - string eventType, - string data, - string? id) - { - StringBuilder sb = new(); - - // Include the ID if provided (used as cursor for resumption) - if (!string.IsNullOrEmpty(id)) - { - sb.AppendLine($"id: {id}"); - } - - sb.AppendLine($"event: {eventType}"); - sb.AppendLine($"data: {data}"); - sb.AppendLine(); // Empty line marks end of event - - await response.WriteAsync(sb.ToString()); - } - - /// - /// Sanitizes a user-provided value for safe inclusion in log entries - /// by removing control characters that could be used for log forging. - /// - private static string? SanitizeLogValue(string? value) - { - if (value is null) - { - return null; - } - - return value - .Replace("\r", string.Empty, StringComparison.Ordinal) - .Replace("\n", string.Empty, StringComparison.Ordinal); - } -} diff --git a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/08_ReliableStreaming/Program.cs b/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/08_ReliableStreaming/Program.cs deleted file mode 100644 index feedf0eb054..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/08_ReliableStreaming/Program.cs +++ /dev/null @@ -1,105 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -// This sample demonstrates how to implement reliable streaming for durable agents using Redis Streams. -// It exposes two HTTP endpoints: -// 1. Create - Starts an agent run and streams responses back via Server-Sent Events (SSE) -// 2. Stream - Resumes a stream from a specific cursor position, enabling reliable message delivery -// -// This pattern is inspired by OpenAI's background mode for the Responses API, which allows clients -// to disconnect and reconnect to ongoing agent responses without losing messages. - -#pragma warning disable IDE0002 // Simplify Member Access - -using Azure; -using Azure.AI.OpenAI; -using Azure.Identity; -using Microsoft.Agents.AI.DurableTask; -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 OpenAI.Chat; -using ReliableStreaming; -using StackExchange.Redis; - -// Get the Azure OpenAI endpoint and deployment name from environment variables. -string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") - ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") - ?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT_NAME is not set."); - -// Get Redis connection string from environment variable. -string redisConnectionString = Environment.GetEnvironmentVariable("REDIS_CONNECTION_STRING") - ?? "localhost:6379"; - -// Get the Redis stream TTL from environment variable (default: 10 minutes). -int redisStreamTtlMinutes = int.TryParse( - Environment.GetEnvironmentVariable("REDIS_STREAM_TTL_MINUTES"), - out int ttlMinutes) ? ttlMinutes : 10; - -// Use Azure Key Credential if provided, otherwise use Azure CLI Credential. -string? azureOpenAiKey = System.Environment.GetEnvironmentVariable("AZURE_OPENAI_API_KEY"); -// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. -// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid -// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. -AzureOpenAIClient client = !string.IsNullOrEmpty(azureOpenAiKey) - ? new AzureOpenAIClient(new Uri(endpoint), new AzureKeyCredential(azureOpenAiKey)) - : new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()); - -// Travel Planner agent instructions - designed to produce longer responses for demonstrating streaming. -const string TravelPlannerName = "TravelPlanner"; -const string TravelPlannerInstructions = - """ - You are an expert travel planner who creates detailed, personalized travel itineraries. - When asked to plan a trip, you should: - 1. Create a comprehensive day-by-day itinerary - 2. Include specific recommendations for activities, restaurants, and attractions - 3. Provide practical tips for each destination - 4. Consider weather and local events when making recommendations - 5. Include estimated times and logistics between activities - - Always use the available tools to get current weather forecasts and local events - for the destination to make your recommendations more relevant and timely. - - Format your response with clear headings for each day and include emoji icons - to make the itinerary easy to scan and visually appealing. - """; - -// Configure the function app to host the AI agent. -FunctionsApplicationBuilder builder = FunctionsApplication - .CreateBuilder(args) - .ConfigureFunctionsWebApplication() - .ConfigureDurableAgents(options => - { - // Define the Travel Planner agent with tools for weather and events - options.AddAIAgentFactory(TravelPlannerName, sp => - { - return client.GetChatClient(deploymentName).AsAIAgent( - instructions: TravelPlannerInstructions, - name: TravelPlannerName, - services: sp, - tools: [ - AIFunctionFactory.Create(TravelTools.GetWeatherForecast), - AIFunctionFactory.Create(TravelTools.GetLocalEvents), - ]); - }); - }); - -// Register Redis connection as a singleton -builder.Services.AddSingleton(_ => - ConnectionMultiplexer.Connect(redisConnectionString)); - -// Register the Redis stream response handler - this captures agent responses -// and publishes them to Redis Streams for reliable delivery. -// Registered as both the concrete type (for FunctionTriggers) and the interface (for the agent framework). -builder.Services.AddSingleton(sp => - new RedisStreamResponseHandler( - sp.GetRequiredService(), - TimeSpan.FromMinutes(redisStreamTtlMinutes))); -builder.Services.AddSingleton(sp => - sp.GetRequiredService()); - -using IHost app = builder.Build(); - -app.Run(); diff --git a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/08_ReliableStreaming/README.md b/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/08_ReliableStreaming/README.md deleted file mode 100644 index a8607ce11ec..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/08_ReliableStreaming/README.md +++ /dev/null @@ -1,264 +0,0 @@ -# Reliable Streaming with Redis - -This sample demonstrates how to implement reliable streaming for durable agents using Redis Streams as a message broker. It enables clients to disconnect and reconnect to ongoing agent responses without losing messages, inspired by [OpenAI's background mode](https://platform.openai.com/docs/guides/background) for the Responses API. - -## Key Concepts Demonstrated - -- **Reliable message delivery**: Agent responses are persisted to Redis Streams, allowing clients to resume from any point -- **Content negotiation**: Use `Accept: text/plain` for raw terminal output, or `Accept: text/event-stream` for SSE format -- **Server-Sent Events (SSE)**: Standard streaming format that works with `curl`, browsers, and most HTTP clients -- **Cursor-based resumption**: Each SSE event includes an `id` field that can be used to resume the stream -- **Fire-and-forget agent invocation**: The agent runs in the background while the client streams from Redis via an HTTP trigger function - -## 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. - -### Additional Requirements: Redis - -This sample requires a Redis instance. Start a local Redis instance using Docker: - -```bash -docker run -d --name redis -p 6379:6379 redis:latest -``` - -To verify Redis is running: - -```bash -docker ps | grep redis -``` - -## Running the Sample - -Start the Azure Functions host: - -```bash -func start -``` - -### 1. Test Streaming with curl - -Open a new terminal and start a travel planning request. Use the `-i` flag to see response headers (including the conversation ID) and `Accept: text/plain` for raw text output: - -**Bash (Linux/macOS/WSL):** - -```bash -curl -i -N -X POST http://localhost:7071/api/agent/create \ - -H "Content-Type: text/plain" \ - -H "Accept: text/plain" \ - -d "Plan a 7-day trip to Tokyo, Japan for next month. Include daily activities, restaurant recommendations, and tips for getting around." -``` - -**PowerShell:** - -```powershell -curl -i -N -X POST http://localhost:7071/api/agent/create ` - -H "Content-Type: text/plain" ` - -H "Accept: text/plain" ` - -d "Plan a 7-day trip to Tokyo, Japan for next month. Include daily activities, restaurant recommendations, and tips for getting around." -``` - -You'll first see the response headers, including: - -```text -HTTP/1.1 200 OK -Content-Type: text/plain; charset=utf-8 -x-conversation-id: @dafx-travelplanner@a1b2c3d4e5f67890abcdef1234567890 -... -``` - -Then the agent's response will stream to your terminal in chunks, similar to a ChatGPT-style experience (though not character-by-character). - -> **Note:** The `-N` flag in curl disables output buffering, which is essential for seeing the stream in real-time. The `-i` flag includes the HTTP headers in the output. - -### 2. Demonstrate Stream Interruption and Resumption - -This is the key feature of reliable streaming! Follow these steps to see it in action: - -#### Step 1: Start a stream and note the conversation ID - -Run the curl command from step 1. Watch for the `x-conversation-id` header in the response - **copy this value**, you'll need it to resume. - -```text -x-conversation-id: @dafx-travelplanner@a1b2c3d4e5f67890abcdef1234567890 -``` - -#### Step 2: Interrupt the stream - -While the agent is still generating text, press **`Ctrl+C`** to interrupt the stream. The agent continues running in the background - your messages are being saved to Redis! - -#### Step 3: Resume the stream - -Use the conversation ID you copied to resume streaming from where you left off. Include the `Accept: text/plain` header to get raw text output: - -**Bash (Linux/macOS/WSL):** - -```bash -# Replace with your actual conversation ID from the x-conversation-id header -CONVERSATION_ID="@dafx-travelplanner@a1b2c3d4e5f67890abcdef1234567890" - -curl -N -H "Accept: text/plain" "http://localhost:7071/api/agent/stream/${CONVERSATION_ID}" -``` - -**PowerShell:** - -```powershell -# Replace with your actual conversation ID from the x-conversation-id header -$conversationId = "@dafx-travelplanner@a1b2c3d4e5f67890abcdef1234567890" - -curl -N -H "Accept: text/plain" "http://localhost:7071/api/agent/stream/$conversationId" -``` - -You'll see the **entire response replayed from the beginning**, including the parts you already received before interrupting. - -#### Step 4 (Advanced): Resume from a specific cursor - -If you're using SSE format, each event includes an `id` field that you can use as a cursor to resume from a specific point: - -```bash -# Resume from a specific cursor position -curl -N "http://localhost:7071/api/agent/stream/${CONVERSATION_ID}?cursor=1734567890123-0" -``` - -### 3. Alternative: SSE Format for Programmatic Clients - -If you need the full Server-Sent Events format with cursors for resumable streaming, use `Accept: text/event-stream` (or omit the Accept header): - -```bash -curl -i -N -X POST http://localhost:7071/api/agent/create \ - -H "Content-Type: text/plain" \ - -H "Accept: text/event-stream" \ - -d "Plan a 7-day trip to Tokyo, Japan." -``` - -This returns SSE-formatted events with `id`, `event`, and `data` fields: - -```text -id: 1734567890123-0 -event: message -data: # 7-Day Tokyo Adventure - -id: 1734567890124-0 -event: message -data: ## Day 1: Arrival and Exploration - -id: 1734567890999-0 -event: done -data: [DONE] -``` - -The `id` field is the Redis stream entry ID - use it as the `cursor` parameter to resume from that exact point. - -### Understanding the Response Headers - -| Header | Description | -|--------|-------------| -| `x-conversation-id` | The conversation ID (session key). Use this to resume the stream. | -| `Content-Type` | Either `text/plain` or `text/event-stream` depending on your `Accept` header. | -| `Cache-Control` | Set to `no-cache` to prevent caching of the stream. | - -## Architecture Overview - -```text -┌─────────────┐ POST /agent/create ┌─────────────────────┐ -│ Client │ (Accept: text/plain or SSE)│ Azure Functions │ -│ (curl) │ ──────────────────────────► │ (FunctionTriggers) │ -└─────────────┘ └──────────┬──────────┘ - ▲ │ - │ Text or SSE stream Signal Entity - │ │ - │ ▼ - │ ┌─────────────────────┐ - │ │ AgentEntity │ - │ │ (Durable Entity) │ - │ └──────────┬──────────┘ - │ │ - │ IAgentResponseHandler - │ │ - │ ▼ - │ ┌─────────────────────┐ - │ │ RedisStreamResponse │ - │ │ Handler │ - │ └──────────┬──────────┘ - │ │ - │ XADD (write) - │ │ - │ ▼ - │ ┌─────────────────────┐ - └─────────── XREAD (poll) ────────── │ Redis Streams │ - │ (Durable Log) │ - └─────────────────────┘ -``` - -### Data Flow - -1. **Client sends prompt**: The `Create` endpoint receives the prompt and generates a new agent thread. - -2. **Agent invoked**: The durable entity (`AgentEntity`) is signaled to run the travel planner agent. This is fire-and-forget from the HTTP request's perspective. - -3. **Responses captured**: As the agent generates responses, `RedisStreamResponseHandler` (implementing `IAgentResponseHandler`) extracts the text from each `AgentResponseUpdate` and publishes it to a Redis Stream keyed by session ID. - -4. **Client polls Redis**: The HTTP response streams events by polling the Redis Stream. For SSE format, each event includes the Redis entry ID as the `id` field. - -5. **Resumption**: If the client disconnects, it can call the `Stream` endpoint with the conversation ID (from the `x-conversation-id` header) and optionally the last received cursor to resume from that point. - -## Message Delivery Guarantees - -This sample provides **at-least-once delivery** with the following characteristics: - -- **Durability**: Messages are persisted to Redis Streams with configurable TTL (default: 10 minutes). -- **Ordering**: Messages are delivered in order within a session. -- **Resumption**: Clients can resume from any point using cursor-based pagination. -- **Replay**: Clients can replay the entire stream by omitting the cursor. - -### Important Considerations - -- **No exactly-once delivery**: If a client disconnects exactly when receiving a message, it may receive that message again upon resumption. Clients should handle duplicate messages idempotently. -- **TTL expiration**: Streams expire after the configured TTL. Clients cannot resume streams that have expired. -- **Redis guarantees**: Redis streams are backed by Redis persistence mechanisms (RDB/AOF). Ensure your Redis instance is configured for durability as needed. - -## When to Use These Patterns - -The patterns demonstrated in this sample are ideal for: - -- **Long-running agent tasks**: When agent responses take minutes to complete (e.g., deep research, complex planning) -- **Unreliable network connections**: Mobile apps, unstable WiFi, or connections that may drop -- **Resumable experiences**: Users should be able to close and reopen an app without losing context -- **Background processing**: When you want to fire off a task and check on it later - -These patterns may be overkill for: - -- **Simple, fast responses**: If responses complete in a few seconds, standard streaming is simpler -- **Stateless interactions**: If there's no need to resume or replay conversations -- **Very high throughput**: Redis adds latency; for maximum throughput, direct streaming may be better - -## Configuration - -| Environment Variable | Description | Default | -|---------------------|-------------|---------| -| `REDIS_CONNECTION_STRING` | Redis connection string | `localhost:6379` | -| `REDIS_STREAM_TTL_MINUTES` | How long streams are retained after last write | `10` | -| `AZURE_OPENAI_ENDPOINT` | Azure OpenAI endpoint URL | (required) | -| `AZURE_OPENAI_DEPLOYMENT_NAME` | Azure OpenAI deployment name | (required) | -| `AZURE_OPENAI_API_KEY` | API key (optional, uses Azure CLI auth if not set) | (optional) | - -## Cleanup - -To stop and remove the Redis Docker containers: - -```bash -docker stop redis -docker rm redis -``` - -## Disclaimer - -> ⚠️ **This sample is for illustration purposes only and is not intended to be production-ready.** -> -> A production implementation should consider: -> -> - Redis cluster configuration for high availability -> - Authentication and authorization for the streaming endpoints -> - Rate limiting and abuse prevention -> - Monitoring and alerting for stream health -> - Graceful handling of Redis failures diff --git a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/08_ReliableStreaming/RedisStreamResponseHandler.cs b/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/08_ReliableStreaming/RedisStreamResponseHandler.cs deleted file mode 100644 index eae0820cbf4..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/08_ReliableStreaming/RedisStreamResponseHandler.cs +++ /dev/null @@ -1,212 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Runtime.CompilerServices; -using Microsoft.Agents.AI; -using Microsoft.Agents.AI.DurableTask; -using StackExchange.Redis; - -namespace ReliableStreaming; - -/// -/// Represents a chunk of data read from a Redis stream. -/// -/// The Redis stream entry ID (can be used as a cursor for resumption). -/// The text content of the chunk, or null if this is a completion/error marker. -/// True if this chunk marks the end of the stream. -/// An error message if something went wrong, or null otherwise. -public readonly record struct StreamChunk(string EntryId, string? Text, bool IsDone, string? Error); - -/// -/// An implementation of that publishes agent response updates -/// to Redis Streams for reliable delivery. This enables clients to disconnect and reconnect -/// to ongoing agent responses without losing messages. -/// -/// -/// -/// Redis Streams provide a durable, append-only log that supports consumer groups and message -/// acknowledgment. This implementation uses auto-generated IDs (which are timestamp-based) -/// as sequence numbers, allowing clients to resume from any point in the stream. -/// -/// -/// Each agent session gets its own Redis Stream, keyed by session ID. The stream entries -/// contain text chunks extracted from objects. -/// -/// -public sealed class RedisStreamResponseHandler : IAgentResponseHandler -{ - private const int MaxEmptyReads = 300; // 5 minutes at 1 second intervals - private const int PollIntervalMs = 1000; - - private readonly IConnectionMultiplexer _redis; - private readonly TimeSpan _streamTtl; - - /// - /// Initializes a new instance of the class. - /// - /// The Redis connection multiplexer. - /// The time-to-live for stream entries. Streams will expire after this duration of inactivity. - public RedisStreamResponseHandler(IConnectionMultiplexer redis, TimeSpan streamTtl) - { - this._redis = redis; - this._streamTtl = streamTtl; - } - - /// - public async ValueTask OnStreamingResponseUpdateAsync( - IAsyncEnumerable messageStream, - CancellationToken cancellationToken) - { - // Get the current session ID from the DurableAgentContext - // This is set by the AgentEntity before invoking the response handler - DurableAgentContext? context = DurableAgentContext.Current; - if (context is null) - { - throw new InvalidOperationException( - "DurableAgentContext.Current is not set. This handler must be used within a durable agent context."); - } - - // Get session ID from the current session context, which is only available in the context of - // a durable agent execution. - string agentSessionId = context.CurrentSession.GetService().ToString(); - string streamKey = GetStreamKey(agentSessionId); - - IDatabase db = this._redis.GetDatabase(); - int sequenceNumber = 0; - - await foreach (AgentResponseUpdate update in messageStream.WithCancellation(cancellationToken)) - { - // Extract just the text content - this avoids serialization round-trip issues - string text = update.Text; - - // Only publish non-empty text chunks - if (!string.IsNullOrEmpty(text)) - { - // Create the stream entry with the text and metadata - NameValueEntry[] entries = - [ - new NameValueEntry("text", text), - new NameValueEntry("sequence", sequenceNumber++), - new NameValueEntry("timestamp", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()), - ]; - - // Add to the Redis Stream with auto-generated ID (timestamp-based) - await db.StreamAddAsync(streamKey, entries); - - // Refresh the TTL on each write to keep the stream alive during active streaming - await db.KeyExpireAsync(streamKey, this._streamTtl); - } - } - - // Add a sentinel entry to mark the end of the stream - NameValueEntry[] endEntries = - [ - new NameValueEntry("text", ""), - new NameValueEntry("sequence", sequenceNumber), - new NameValueEntry("timestamp", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()), - new NameValueEntry("done", "true"), - ]; - await db.StreamAddAsync(streamKey, endEntries); - - // Set final TTL - the stream will be cleaned up after this duration - await db.KeyExpireAsync(streamKey, this._streamTtl); - } - - /// - public ValueTask OnAgentResponseAsync(AgentResponse message, CancellationToken cancellationToken) - { - // This handler is optimized for streaming responses. - // For non-streaming responses, we don't need to store in Redis since - // the response is returned directly to the caller. - return ValueTask.CompletedTask; - } - - /// - /// Reads chunks from a Redis stream for the given session, yielding them as they become available. - /// - /// The conversation ID to read from. - /// Optional cursor to resume from. If null, reads from the beginning. - /// Cancellation token. - /// An async enumerable of stream chunks. - public async IAsyncEnumerable ReadStreamAsync( - string conversationId, - string? cursor, - [EnumeratorCancellation] CancellationToken cancellationToken) - { - string streamKey = GetStreamKey(conversationId); - - IDatabase db = this._redis.GetDatabase(); - string startId = string.IsNullOrEmpty(cursor) ? "0-0" : cursor; - - int emptyReadCount = 0; - bool hasSeenData = false; - - while (!cancellationToken.IsCancellationRequested) - { - StreamEntry[]? entries = null; - string? errorMessage = null; - - try - { - entries = await db.StreamReadAsync(streamKey, startId, count: 100); - } - catch (Exception ex) - { - errorMessage = ex.Message; - } - - if (errorMessage != null) - { - yield return new StreamChunk(startId, null, false, errorMessage); - yield break; - } - - // entries is guaranteed to be non-null if errorMessage is null - if (entries!.Length == 0) - { - if (!hasSeenData) - { - emptyReadCount++; - if (emptyReadCount >= MaxEmptyReads) - { - yield return new StreamChunk( - startId, - null, - false, - $"Stream not found or timed out after {MaxEmptyReads * PollIntervalMs / 1000} seconds"); - yield break; - } - } - - await Task.Delay(PollIntervalMs, cancellationToken); - continue; - } - - hasSeenData = true; - - foreach (StreamEntry entry in entries) - { - startId = entry.Id.ToString(); - string? text = entry["text"]; - string? done = entry["done"]; - - if (done == "true") - { - yield return new StreamChunk(startId, null, true, null); - yield break; - } - - if (!string.IsNullOrEmpty(text)) - { - yield return new StreamChunk(startId, text, false, null); - } - } - } - } - - /// - /// Gets the Redis Stream key for a given conversation ID. - /// - /// The conversation ID. - /// The Redis Stream key. - internal static string GetStreamKey(string conversationId) => $"agent-stream:{conversationId}"; -} diff --git a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/08_ReliableStreaming/Tools.cs b/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/08_ReliableStreaming/Tools.cs deleted file mode 100644 index fce73bc378d..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/08_ReliableStreaming/Tools.cs +++ /dev/null @@ -1,161 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.ComponentModel; - -namespace ReliableStreaming; - -/// -/// Mock travel tools that return hardcoded data for demonstration purposes. -/// In a real application, these would call actual weather and events APIs. -/// -internal static class TravelTools -{ - /// - /// Gets a weather forecast for a destination on a specific date. - /// Returns mock weather data for demonstration purposes. - /// - /// The destination city or location. - /// The date for the forecast (e.g., "2025-01-15" or "next Monday"). - /// A weather forecast summary. - [Description("Gets the weather forecast for a destination on a specific date. Use this to provide weather-aware recommendations in the itinerary.")] - public static string GetWeatherForecast(string destination, string date) - { - // Mock weather data based on destination for realistic responses - Dictionary weatherByRegion = new(StringComparer.OrdinalIgnoreCase) - { - ["Tokyo"] = ("Partly cloudy with a chance of light rain", 58, 45), - ["Paris"] = ("Overcast with occasional drizzle", 52, 41), - ["New York"] = ("Clear and cold", 42, 28), - ["London"] = ("Foggy morning, clearing in afternoon", 48, 38), - ["Sydney"] = ("Sunny and warm", 82, 68), - ["Rome"] = ("Sunny with light breeze", 62, 48), - ["Barcelona"] = ("Partly sunny", 59, 47), - ["Amsterdam"] = ("Cloudy with light rain", 46, 38), - ["Dubai"] = ("Sunny and hot", 85, 72), - ["Singapore"] = ("Tropical thunderstorms in afternoon", 88, 77), - ["Bangkok"] = ("Hot and humid, afternoon showers", 91, 78), - ["Los Angeles"] = ("Sunny and pleasant", 72, 55), - ["San Francisco"] = ("Morning fog, afternoon sun", 62, 52), - ["Seattle"] = ("Rainy with breaks", 48, 40), - ["Miami"] = ("Warm and sunny", 78, 65), - ["Honolulu"] = ("Tropical paradise weather", 82, 72), - }; - - // Find a matching destination or use a default - (string condition, int highF, int lowF) forecast = ("Partly cloudy", 65, 50); - foreach (KeyValuePair entry in weatherByRegion) - { - if (destination.Contains(entry.Key, StringComparison.OrdinalIgnoreCase)) - { - forecast = entry.Value; - break; - } - } - - return $""" - Weather forecast for {destination} on {date}: - Conditions: {forecast.condition} - High: {forecast.highF}°F ({(forecast.highF - 32) * 5 / 9}°C) - Low: {forecast.lowF}°F ({(forecast.lowF - 32) * 5 / 9}°C) - - Recommendation: {GetWeatherRecommendation(forecast.condition)} - """; - } - - /// - /// Gets local events happening at a destination around a specific date. - /// Returns mock event data for demonstration purposes. - /// - /// The destination city or location. - /// The date to search for events (e.g., "2025-01-15" or "next week"). - /// A list of local events and activities. - [Description("Gets local events and activities happening at a destination around a specific date. Use this to suggest timely activities and experiences.")] - public static string GetLocalEvents(string destination, string date) - { - // Mock events data based on destination - Dictionary eventsByCity = new(StringComparer.OrdinalIgnoreCase) - { - ["Tokyo"] = [ - "🎭 Kabuki Theater Performance at Kabukiza Theatre - Traditional Japanese drama", - "🌸 Winter Illuminations at Yoyogi Park - Spectacular light displays", - "🍜 Ramen Festival at Tokyo Station - Sample ramen from across Japan", - "🎮 Gaming Expo at Tokyo Big Sight - Latest video games and technology", - ], - ["Paris"] = [ - "🎨 Impressionist Exhibition at Musée d'Orsay - Extended evening hours", - "🍷 Wine Tasting Tour in Le Marais - Local sommelier guided", - "🎵 Jazz Night at Le Caveau de la Huchette - Historic jazz club", - "🥐 French Pastry Workshop - Learn from master pâtissiers", - ], - ["New York"] = [ - "🎭 Broadway Show: Hamilton - Limited engagement performances", - "🏀 Knicks vs Lakers at Madison Square Garden", - "🎨 Modern Art Exhibit at MoMA - New installations", - "🍕 Pizza Walking Tour of Brooklyn - Artisan pizzerias", - ], - ["London"] = [ - "👑 Royal Collection Exhibition at Buckingham Palace", - "🎭 West End Musical: The Phantom of the Opera", - "🍺 Craft Beer Festival at Brick Lane", - "🎪 Winter Wonderland at Hyde Park - Rides and markets", - ], - ["Sydney"] = [ - "🏄 Pro Surfing Competition at Bondi Beach", - "🎵 Opera at Sydney Opera House - La Bohème", - "🦘 Wildlife Night Safari at Taronga Zoo", - "🍽️ Harbor Dinner Cruise with fireworks", - ], - ["Rome"] = [ - "🏛️ After-Hours Vatican Tour - Skip the crowds", - "🍝 Pasta Making Class in Trastevere", - "🎵 Classical Concert at Borghese Gallery", - "🍷 Wine Tasting in Roman Cellars", - ], - }; - - // Find events for the destination or use generic events - string[] events = [ - "🎭 Local theater performance", - "🍽️ Food and wine festival", - "🎨 Art gallery opening", - "🎵 Live music at local venues", - ]; - - foreach (KeyValuePair entry in eventsByCity) - { - if (destination.Contains(entry.Key, StringComparison.OrdinalIgnoreCase)) - { - events = entry.Value; - break; - } - } - - string eventList = string.Join("\n• ", events); - return $""" - Local events in {destination} around {date}: - - • {eventList} - - 💡 Tip: Book popular events in advance as they may sell out quickly! - """; - } - - private static string GetWeatherRecommendation(string condition) - { - // Use case-insensitive comparison instead of ToLowerInvariant() to satisfy CA1308 - return condition switch - { - string c when c.Contains("rain", StringComparison.OrdinalIgnoreCase) || c.Contains("drizzle", StringComparison.OrdinalIgnoreCase) => - "Bring an umbrella and waterproof jacket. Consider indoor activities for backup.", - string c when c.Contains("fog", StringComparison.OrdinalIgnoreCase) => - "Morning visibility may be limited. Plan outdoor sightseeing for afternoon.", - string c when c.Contains("cold", StringComparison.OrdinalIgnoreCase) => - "Layer up with warm clothing. Hot drinks and cozy cafés recommended.", - string c when c.Contains("hot", StringComparison.OrdinalIgnoreCase) || c.Contains("warm", StringComparison.OrdinalIgnoreCase) => - "Stay hydrated and use sunscreen. Plan strenuous activities for cooler morning hours.", - string c when c.Contains("thunder", StringComparison.OrdinalIgnoreCase) || c.Contains("storm", StringComparison.OrdinalIgnoreCase) => - "Keep an eye on weather updates. Have indoor alternatives ready.", - _ => "Pleasant conditions expected. Great day for outdoor exploration!" - }; - } -} diff --git a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/08_ReliableStreaming/host.json b/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/08_ReliableStreaming/host.json deleted file mode 100644 index 4247b37c973..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/08_ReliableStreaming/host.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "version": "2.0", - "logging": { - "logLevel": { - "Microsoft.Agents.AI.DurableTask": "Information", - "Microsoft.Agents.AI.Hosting.AzureFunctions": "Information", - "DurableTask": "Information", - "Microsoft.DurableTask": "Information", - "ReliableStreaming": "Information" - } - }, - "extensions": { - "durableTask": { - "hubName": "default", - "storageProvider": { - "type": "AzureManaged", - "connectionStringName": "DURABLE_TASK_SCHEDULER_CONNECTION_STRING" - } - } - } -} diff --git a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/README.md b/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/README.md index 2839c67587b..16454af51ee 100644 --- a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/README.md +++ b/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/README.md @@ -1,152 +1,3 @@ -# Azure Functions Samples +# Durable Agent Azure Functions Samples Have Moved -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. -- **[08_ReliableStreaming](08_ReliableStreaming)**: A sample that demonstrates how to implement reliable streaming for durable agents using Redis Streams, enabling clients to disconnect and reconnect without losing messages. - -## 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 10.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-5.4-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_API_KEY` environment variable. - -Bash (Linux/macOS/WSL): - -```bash -export AZURE_OPENAI_API_KEY="your-api-key" -``` - -PowerShell: - -```powershell -$env:AZURE_OPENAI_API_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_NAME": "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_NAME="your-deployment-name" -``` - -### PowerShell - -```powershell -$env:AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" -$env:AZURE_OPENAI_DEPLOYMENT_NAME="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/04-hosting/DurableAgents/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. +These samples are now maintained in the [Durable Agent Framework extension repository](https://github.com/microsoft/agent-framework-durable-extension/tree/main/dotnet/samples/DurableAgents/AzureFunctions). diff --git a/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/01_SingleAgent/01_SingleAgent.csproj b/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/01_SingleAgent/01_SingleAgent.csproj deleted file mode 100644 index c75457546e1..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/01_SingleAgent/01_SingleAgent.csproj +++ /dev/null @@ -1,30 +0,0 @@ - - - net10.0 - Exe - enable - enable - SingleAgent - SingleAgent - - - - - - - - - - - - - - - - - diff --git a/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/01_SingleAgent/Program.cs b/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/01_SingleAgent/Program.cs deleted file mode 100644 index c331d28ccce..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/01_SingleAgent/Program.cs +++ /dev/null @@ -1,106 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Azure; -using Azure.AI.OpenAI; -using Azure.Identity; -using Microsoft.Agents.AI; -using Microsoft.Agents.AI.DurableTask; -using Microsoft.DurableTask.Client.AzureManaged; -using Microsoft.DurableTask.Worker.AzureManaged; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using OpenAI.Chat; - -// Get the Azure OpenAI endpoint and deployment name from environment variables. -string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") - ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") - ?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT_NAME is not set."); - -// Get DTS connection string from environment variable -string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING") - ?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None"; - -// Use Azure Key Credential if provided, otherwise use Azure CLI Credential. -string? azureOpenAiKey = Environment.GetEnvironmentVariable("AZURE_OPENAI_API_KEY"); -// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. -// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid -// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. -AzureOpenAIClient client = !string.IsNullOrEmpty(azureOpenAiKey) - ? new AzureOpenAIClient(new Uri(endpoint), new AzureKeyCredential(azureOpenAiKey)) - : new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()); - -// 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).AsAIAgent(JokerInstructions, JokerName); - -// Configure the console app to host the AI agent. -IHost host = Host.CreateDefaultBuilder(args) - .ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Warning)) - .ConfigureServices(services => - { - services.ConfigureDurableAgents( - options => options.AddAIAgent(agent, timeToLive: TimeSpan.FromHours(1)), - workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString), - clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString)); - }) - .Build(); - -await host.StartAsync(); - -// Get the agent proxy from services -IServiceProvider services = host.Services; -AIAgent agentProxy = services.GetRequiredKeyedService(JokerName); - -// Console colors for better UX -Console.ForegroundColor = ConsoleColor.Cyan; -Console.WriteLine("=== Single Agent Console Sample ==="); -Console.ResetColor(); -Console.WriteLine("Enter a message for the Joker agent (or 'exit' to quit):"); -Console.WriteLine(); - -// Create a session for the conversation -AgentSession session = await agentProxy.CreateSessionAsync(); - -while (true) -{ - // Read input from stdin - Console.ForegroundColor = ConsoleColor.Yellow; - Console.Write("You: "); - Console.ResetColor(); - - string? input = Console.ReadLine(); - if (string.IsNullOrWhiteSpace(input) || input.Equals("exit", StringComparison.OrdinalIgnoreCase)) - { - break; - } - - // Run the agent - Console.ForegroundColor = ConsoleColor.Green; - Console.Write("Joker: "); - Console.ResetColor(); - - try - { - AgentResponse agentResponse = await agentProxy.RunAsync( - message: input, - session: session, - cancellationToken: CancellationToken.None); - - Console.WriteLine(agentResponse.Text); - Console.WriteLine(); - } - catch (Exception ex) - { - Console.ForegroundColor = ConsoleColor.Red; - Console.Error.WriteLine($"Error: {ex.Message}"); - Console.ResetColor(); - Console.WriteLine(); - } -} - -await host.StopAsync(); diff --git a/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/01_SingleAgent/README.md b/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/01_SingleAgent/README.md deleted file mode 100644 index 927cd80e0ae..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/01_SingleAgent/README.md +++ /dev/null @@ -1,56 +0,0 @@ -# Single Agent Sample - -This sample demonstrates how to use the durable agents extension to create a simple console app that hosts a single AI agent and provides interactive conversation via stdin/stdout. - -## Key Concepts Demonstrated - -- Using the Microsoft Agent Framework to define a simple AI agent with a name and instructions. -- Registering durable agents with the console app and running them interactively. -- Conversation management (via threads) 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, you can run the sample: - -```bash -cd dotnet/samples/04-hosting/DurableAgents/ConsoleApps/01_SingleAgent -dotnet run --framework net10.0 -``` - -The app will prompt you for input. You can interact with the Joker agent: - -```text -=== Single Agent Console Sample === -Enter a message for the Joker agent (or 'exit' to quit): - -You: Tell me a joke about a pirate. -Joker: Why don't pirates ever learn the alphabet? Because they always get stuck at "C"! - -You: Now explain the joke. -Joker: The joke plays on the word "sea" (C), which pirates are famously associated with... - -You: exit -``` - -## Scriptable Usage - -You can also pipe input to the app for scriptable usage: - -```bash -echo "Tell me a joke about a pirate." | dotnet run -``` - -The app will read from stdin, process the input, and write the response to stdout. - -## Viewing Agent State - -You can view the state of the agent in the Durable Task Scheduler dashboard: - -1. Open your browser and navigate to `http://localhost:8082` -2. In the dashboard, you can view the state of the Joker agent, including its conversation history and current state - -The agent maintains conversation state across multiple interactions, and you can inspect this state in the dashboard to understand how the durable agents extension manages conversation context. diff --git a/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/02_AgentOrchestration_Chaining/02_AgentOrchestration_Chaining.csproj b/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/02_AgentOrchestration_Chaining/02_AgentOrchestration_Chaining.csproj deleted file mode 100644 index 81a023818db..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/02_AgentOrchestration_Chaining/02_AgentOrchestration_Chaining.csproj +++ /dev/null @@ -1,30 +0,0 @@ - - - net10.0 - Exe - enable - enable - AgentOrchestration_Chaining - AgentOrchestration_Chaining - - - - - - - - - - - - - - - - - diff --git a/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/02_AgentOrchestration_Chaining/Models.cs b/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/02_AgentOrchestration_Chaining/Models.cs deleted file mode 100644 index 593b468457a..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/02_AgentOrchestration_Chaining/Models.cs +++ /dev/null @@ -1,6 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -namespace AgentOrchestration_Chaining; - -// Response model -public sealed record TextResponse(string Text); diff --git a/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/02_AgentOrchestration_Chaining/Program.cs b/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/02_AgentOrchestration_Chaining/Program.cs deleted file mode 100644 index 6af759a62bf..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/02_AgentOrchestration_Chaining/Program.cs +++ /dev/null @@ -1,151 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using AgentOrchestration_Chaining; -using Azure; -using Azure.AI.OpenAI; -using Azure.Identity; -using Microsoft.Agents.AI; -using Microsoft.Agents.AI.DurableTask; -using Microsoft.DurableTask; -using Microsoft.DurableTask.Client; -using Microsoft.DurableTask.Client.AzureManaged; -using Microsoft.DurableTask.Worker; -using Microsoft.DurableTask.Worker.AzureManaged; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using OpenAI.Chat; -using Environment = System.Environment; - -// Get the Azure OpenAI endpoint and deployment name from environment variables. -string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") - ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") - ?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT_NAME is not set."); - -// Get DTS connection string from environment variable -string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING") - ?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None"; - -// Use Azure Key Credential if provided, otherwise use Azure CLI Credential. -string? azureOpenAiKey = Environment.GetEnvironmentVariable("AZURE_OPENAI_API_KEY"); -// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. -// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid -// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. -AzureOpenAIClient client = !string.IsNullOrEmpty(azureOpenAiKey) - ? new AzureOpenAIClient(new Uri(endpoint), new AzureKeyCredential(azureOpenAiKey)) - : new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()); - -// Single agent used by the orchestration to demonstrate sequential calls on the same session. -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).AsAIAgent(WriterInstructions, WriterName); - -// Orchestrator function -static async Task RunOrchestratorAsync(TaskOrchestrationContext context) -{ - DurableAIAgent writer = context.GetAgent("WriterAgent"); - AgentSession writerSession = await writer.CreateSessionAsync(); - - AgentResponse initial = await writer.RunAsync( - message: "Write a concise inspirational sentence about learning.", - session: writerSession); - - AgentResponse refined = await writer.RunAsync( - message: $"Improve this further while keeping it under 25 words: {initial.Result.Text}", - session: writerSession); - - return refined.Result.Text; -} - -// Configure the console app to host the AI agent. -IHost host = Host.CreateDefaultBuilder(args) - .ConfigureLogging(loggingBuilder => loggingBuilder.SetMinimumLevel(LogLevel.Warning)) - .ConfigureServices(services => - { - services.ConfigureDurableAgents( - options => options.AddAIAgent(writerAgent), - workerBuilder: builder => - { - builder.UseDurableTaskScheduler(dtsConnectionString); - builder.AddTasks(registry => registry.AddOrchestratorFunc(nameof(RunOrchestratorAsync), RunOrchestratorAsync)); - }, - clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString)); - }) - .Build(); - -await host.StartAsync(); - -DurableTaskClient durableClient = host.Services.GetRequiredService(); - -// Console colors for better UX -Console.ForegroundColor = ConsoleColor.Cyan; -Console.WriteLine("=== Single Agent Orchestration Chaining Sample ==="); -Console.ResetColor(); -Console.WriteLine("Starting orchestration..."); -Console.WriteLine(); - -try -{ - // Start the orchestration - string instanceId = await durableClient.ScheduleNewOrchestrationInstanceAsync( - orchestratorName: nameof(RunOrchestratorAsync)); - - Console.ForegroundColor = ConsoleColor.Gray; - Console.WriteLine($"Orchestration started with instance ID: {instanceId}"); - Console.WriteLine("Waiting for completion..."); - Console.ResetColor(); - - // Wait for orchestration to complete - OrchestrationMetadata status = await durableClient.WaitForInstanceCompletionAsync( - instanceId, - getInputsAndOutputs: true, - CancellationToken.None); - - Console.WriteLine(); - - if (status.RuntimeStatus == OrchestrationRuntimeStatus.Completed) - { - Console.ForegroundColor = ConsoleColor.Green; - Console.WriteLine("✓ Orchestration completed successfully!"); - Console.ResetColor(); - Console.WriteLine(); - Console.ForegroundColor = ConsoleColor.Yellow; - Console.Write("Result: "); - Console.ResetColor(); - Console.WriteLine(status.ReadOutputAs()); - } - else if (status.RuntimeStatus == OrchestrationRuntimeStatus.Failed) - { - Console.ForegroundColor = ConsoleColor.Red; - Console.WriteLine("✗ Orchestration failed!"); - Console.ResetColor(); - if (status.FailureDetails != null) - { - Console.WriteLine($"Error: {status.FailureDetails.ErrorMessage}"); - } - Environment.Exit(1); - } - else - { - Console.ForegroundColor = ConsoleColor.Yellow; - Console.WriteLine($"Orchestration status: {status.RuntimeStatus}"); - Console.ResetColor(); - } -} -catch (Exception ex) -{ - Console.ForegroundColor = ConsoleColor.Red; - Console.Error.WriteLine($"Error: {ex.Message}"); - Console.ResetColor(); - Environment.Exit(1); -} -finally -{ - await host.StopAsync(); -} diff --git a/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/02_AgentOrchestration_Chaining/README.md b/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/02_AgentOrchestration_Chaining/README.md deleted file mode 100644 index 83a69b37a4b..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/02_AgentOrchestration_Chaining/README.md +++ /dev/null @@ -1,53 +0,0 @@ -# Single Agent Orchestration Sample - -This sample demonstrates how to use the durable agents extension to create a simple console app that orchestrates sequential calls to a single AI agent using the same session for context continuity. - -## Key Concepts Demonstrated - -- Orchestrating multiple interactions with the same agent in a deterministic order -- Using the same `AgentSession` across multiple calls to maintain conversational context -- Durable orchestration with automatic checkpointing and resumption from failures -- Waiting for orchestration completion using `WaitForInstanceCompletionAsync` - -## 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, you can run the sample: - -```bash -cd dotnet/samples/04-hosting/DurableAgents/ConsoleApps/02_AgentOrchestration_Chaining -dotnet run --framework net10.0 -``` - -The app will start the orchestration, wait for it to complete, and display the result: - -```text -=== Single Agent Orchestration Chaining Sample === -Starting orchestration... - -Orchestration started with instance ID: 86313f1d45fb42eeb50b1852626bf3ff -Waiting for completion... - -✓ Orchestration completed successfully! - -Result: Learning serves as the key, opening doors to boundless opportunities and a brighter future. -``` - -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 - -## Viewing Orchestration State - -You can view the state of the orchestration in the Durable Task Scheduler dashboard: - -1. Open your browser and navigate to `http://localhost:8082` -2. In the dashboard, you can see: - - **Orchestrations**: View the orchestration instance, including its runtime status, input, output, and execution history - - **Agents**: View the state of the WriterAgent, including conversation history maintained across the orchestration steps - -The orchestration instance ID is displayed in the console output. You can use this ID to find the specific orchestration in the dashboard and inspect its execution details, including the sequence of agent calls and their results. diff --git a/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/03_AgentOrchestration_Concurrency/03_AgentOrchestration_Concurrency.csproj b/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/03_AgentOrchestration_Concurrency/03_AgentOrchestration_Concurrency.csproj deleted file mode 100644 index af1e8fbba20..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/03_AgentOrchestration_Concurrency/03_AgentOrchestration_Concurrency.csproj +++ /dev/null @@ -1,30 +0,0 @@ - - - net10.0 - Exe - enable - enable - AgentOrchestration_Concurrency - AgentOrchestration_Concurrency - - - - - - - - - - - - - - - - - diff --git a/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/03_AgentOrchestration_Concurrency/Models.cs b/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/03_AgentOrchestration_Concurrency/Models.cs deleted file mode 100644 index 042e245f7f8..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/03_AgentOrchestration_Concurrency/Models.cs +++ /dev/null @@ -1,6 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -namespace AgentOrchestration_Concurrency; - -// Response model -public sealed record TextResponse(string Text); diff --git a/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/03_AgentOrchestration_Concurrency/Program.cs b/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/03_AgentOrchestration_Concurrency/Program.cs deleted file mode 100644 index 714558abc02..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/03_AgentOrchestration_Concurrency/Program.cs +++ /dev/null @@ -1,194 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Text.Json; -using AgentOrchestration_Concurrency; -using Azure; -using Azure.AI.OpenAI; -using Azure.Identity; -using Microsoft.Agents.AI; -using Microsoft.Agents.AI.DurableTask; -using Microsoft.DurableTask; -using Microsoft.DurableTask.Client; -using Microsoft.DurableTask.Client.AzureManaged; -using Microsoft.DurableTask.Worker; -using Microsoft.DurableTask.Worker.AzureManaged; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using OpenAI.Chat; - -// Get the Azure OpenAI endpoint and deployment name from environment variables. -string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") - ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") - ?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT_NAME is not set."); - -// Get DTS connection string from environment variable -string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING") - ?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None"; - -// Use Azure Key Credential if provided, otherwise use Azure CLI Credential. -string? azureOpenAiKey = Environment.GetEnvironmentVariable("AZURE_OPENAI_API_KEY"); -// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. -// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid -// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. -AzureOpenAIClient client = !string.IsNullOrEmpty(azureOpenAiKey) - ? new AzureOpenAIClient(new Uri(endpoint), new AzureKeyCredential(azureOpenAiKey)) - : new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()); - -// 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 a middle school chemistry teacher. You answer questions so that middle school students can understand."; - -AIAgent physicistAgent = client.GetChatClient(deploymentName).AsAIAgent(PhysicistInstructions, PhysicistName); -AIAgent chemistAgent = client.GetChatClient(deploymentName).AsAIAgent(ChemistInstructions, ChemistName); - -// Orchestrator function -static async Task RunOrchestratorAsync(TaskOrchestrationContext context, string prompt) -{ - // Get both agents - DurableAIAgent physicist = context.GetAgent(PhysicistName); - DurableAIAgent chemist = context.GetAgent(ChemistName); - - // 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, - }; -} - -// Configure the console app to host the AI agents. -IHost host = Host.CreateDefaultBuilder(args) - .ConfigureLogging(loggingBuilder => loggingBuilder.SetMinimumLevel(LogLevel.Warning)) - .ConfigureServices(services => - { - services.ConfigureDurableAgents( - options => - { - options - .AddAIAgent(physicistAgent) - .AddAIAgent(chemistAgent); - }, - workerBuilder: builder => - { - builder.UseDurableTaskScheduler(dtsConnectionString); - builder.AddTasks( - registry => registry.AddOrchestratorFunc(nameof(RunOrchestratorAsync), RunOrchestratorAsync)); - }, - clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString)); - }) - .Build(); - -await host.StartAsync(); - -DurableTaskClient durableTaskClient = host.Services.GetRequiredService(); - -// Console colors for better UX -Console.ForegroundColor = ConsoleColor.Cyan; -Console.WriteLine("=== Multi-Agent Concurrent Orchestration Sample ==="); -Console.ResetColor(); -Console.WriteLine("Enter a question for the agents:"); -Console.WriteLine(); - -// Read prompt from stdin -string? prompt = Console.ReadLine(); -if (string.IsNullOrWhiteSpace(prompt)) -{ - Console.ForegroundColor = ConsoleColor.Red; - Console.Error.WriteLine("Error: Prompt is required."); - Console.ResetColor(); - Environment.Exit(1); - return; -} - -Console.WriteLine(); -Console.ForegroundColor = ConsoleColor.Gray; -Console.WriteLine("Starting orchestration..."); -Console.ResetColor(); - -try -{ - // Start the orchestration - string instanceId = await durableTaskClient.ScheduleNewOrchestrationInstanceAsync( - orchestratorName: nameof(RunOrchestratorAsync), - input: prompt); - - Console.ForegroundColor = ConsoleColor.Gray; - Console.WriteLine($"Orchestration started with instance ID: {instanceId}"); - Console.WriteLine("Waiting for completion..."); - Console.ResetColor(); - - // Wait for orchestration to complete - OrchestrationMetadata status = await durableTaskClient.WaitForInstanceCompletionAsync( - instanceId, - getInputsAndOutputs: true, - CancellationToken.None); - - Console.WriteLine(); - - if (status.RuntimeStatus == OrchestrationRuntimeStatus.Completed) - { - Console.ForegroundColor = ConsoleColor.Green; - Console.WriteLine("✓ Orchestration completed successfully!"); - Console.ResetColor(); - Console.WriteLine(); - - // Parse the output - using JsonDocument doc = JsonDocument.Parse(status.SerializedOutput!); - JsonElement output = doc.RootElement; - - Console.ForegroundColor = ConsoleColor.Yellow; - Console.WriteLine("Physicist's response:"); - Console.ResetColor(); - Console.WriteLine(output.GetProperty("physicist").GetString()); - Console.WriteLine(); - - Console.ForegroundColor = ConsoleColor.Yellow; - Console.WriteLine("Chemist's response:"); - Console.ResetColor(); - Console.WriteLine(output.GetProperty("chemist").GetString()); - } - else if (status.RuntimeStatus == OrchestrationRuntimeStatus.Failed) - { - Console.ForegroundColor = ConsoleColor.Red; - Console.WriteLine("✗ Orchestration failed!"); - Console.ResetColor(); - if (status.FailureDetails != null) - { - Console.WriteLine($"Error: {status.FailureDetails.ErrorMessage}"); - } - Environment.Exit(1); - } - else - { - Console.ForegroundColor = ConsoleColor.Yellow; - Console.WriteLine($"Orchestration status: {status.RuntimeStatus}"); - Console.ResetColor(); - } -} -catch (Exception ex) -{ - Console.ForegroundColor = ConsoleColor.Red; - Console.Error.WriteLine($"Error: {ex.Message}"); - Console.ResetColor(); - Environment.Exit(1); -} -finally -{ - await host.StopAsync(); -} diff --git a/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/03_AgentOrchestration_Concurrency/README.md b/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/03_AgentOrchestration_Concurrency/README.md deleted file mode 100644 index da75416beae..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/03_AgentOrchestration_Concurrency/README.md +++ /dev/null @@ -1,68 +0,0 @@ -# Multi-Agent Concurrent Orchestration Sample - -This sample demonstrates how to use the durable agents extension to create a console app that orchestrates concurrent execution of multiple AI agents using durable orchestration. - -## Key Concepts Demonstrated - -- Running multiple agents concurrently in a single orchestration -- Using `Task.WhenAll` to wait for concurrent agent executions -- Combining results from multiple agents into a single response -- Waiting for orchestration completion using `WaitForInstanceCompletionAsync` - -## 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, you can run the sample: - -```bash -cd dotnet/samples/04-hosting/DurableAgents/ConsoleApps/03_AgentOrchestration_Concurrency -dotnet run --framework net10.0 -``` - -The app will prompt you for a question: - -```text -=== Multi-Agent Concurrent Orchestration Sample === -Enter a question for the agents: - -What is temperature? -``` - -The orchestration will run both agents concurrently and display their responses: - -```text -Orchestration started with instance ID: 86313f1d45fb42eeb50b1852626bf3ff -Waiting for completion... - -✓ Orchestration completed successfully! - -Physicist's response: -Temperature is a measure of the average kinetic energy of particles in a system... - -Chemist's response: -From a chemistry perspective, temperature is crucial for chemical reactions... -``` - -Both agents run in parallel, and the orchestration waits for both to complete before returning the combined results. - -## Viewing Orchestration State - -You can view the state of the orchestration in the Durable Task Scheduler dashboard: - -1. Open your browser and navigate to `http://localhost:8082` -2. In the dashboard, you can see: - - **Orchestrations**: View the orchestration instance, including its runtime status, input, output, and execution history - - **Agents**: View the state of both the PhysicistAgent and ChemistAgent, including their individual conversation histories - -The orchestration instance ID is displayed in the console output. You can use this ID to find the specific orchestration in the dashboard and inspect how the concurrent agent executions were coordinated, including the timing of when each agent started and completed. - -## Scriptable Usage - -You can also pipe input to the app: - -```bash -echo "What is temperature?" | dotnet run -``` diff --git a/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/04_AgentOrchestration_Conditionals/04_AgentOrchestration_Conditionals.csproj b/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/04_AgentOrchestration_Conditionals/04_AgentOrchestration_Conditionals.csproj deleted file mode 100644 index 43a8ec54bca..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/04_AgentOrchestration_Conditionals/04_AgentOrchestration_Conditionals.csproj +++ /dev/null @@ -1,30 +0,0 @@ - - - net10.0 - Exe - enable - enable - AgentOrchestration_Conditionals - AgentOrchestration_Conditionals - - - - - - - - - - - - - - - - - diff --git a/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/04_AgentOrchestration_Conditionals/Models.cs b/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/04_AgentOrchestration_Conditionals/Models.cs deleted file mode 100644 index a39695d7d07..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/04_AgentOrchestration_Conditionals/Models.cs +++ /dev/null @@ -1,38 +0,0 @@ -// 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/04-hosting/DurableAgents/ConsoleApps/04_AgentOrchestration_Conditionals/Program.cs b/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/04_AgentOrchestration_Conditionals/Program.cs deleted file mode 100644 index 7b1751f2987..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/04_AgentOrchestration_Conditionals/Program.cs +++ /dev/null @@ -1,231 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using AgentOrchestration_Conditionals; -using Azure; -using Azure.AI.OpenAI; -using Azure.Identity; -using Microsoft.Agents.AI; -using Microsoft.Agents.AI.DurableTask; -using Microsoft.DurableTask; -using Microsoft.DurableTask.Client; -using Microsoft.DurableTask.Client.AzureManaged; -using Microsoft.DurableTask.Worker; -using Microsoft.DurableTask.Worker.AzureManaged; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using OpenAI.Chat; - -// Get the Azure OpenAI endpoint and deployment name from environment variables. -string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") - ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") - ?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT_NAME is not set."); - -// Get DTS connection string from environment variable -string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING") - ?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None"; - -// Use Azure Key Credential if provided, otherwise use Azure CLI Credential. -string? azureOpenAiKey = Environment.GetEnvironmentVariable("AZURE_OPENAI_API_KEY"); -// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. -// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid -// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. -AzureOpenAIClient client = !string.IsNullOrEmpty(azureOpenAiKey) - ? new AzureOpenAIClient(new Uri(endpoint), new AzureKeyCredential(azureOpenAiKey)) - : new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()); - -// Spam detection agent -const string SpamDetectionAgentName = "SpamDetectionAgent"; -const string SpamDetectionAgentInstructions = - """ - You are an expert email spam detection system. Analyze emails and determine if they are spam. - Return your analysis as JSON with 'is_spam' (boolean) and 'reason' (string) fields. - """; - -// Email assistant agent -const string EmailAssistantAgentName = "EmailAssistantAgent"; -const string EmailAssistantAgentInstructions = - """ - You are a professional email assistant. Draft professional, courteous, and helpful email responses. - Return your response as JSON with a 'response' field containing the reply. - """; - -AIAgent spamDetectionAgent = client.GetChatClient(deploymentName).AsAIAgent(SpamDetectionAgentInstructions, SpamDetectionAgentName); -AIAgent emailAssistantAgent = client.GetChatClient(deploymentName).AsAIAgent(EmailAssistantAgentInstructions, EmailAssistantAgentName); - -// Orchestrator function -static async Task RunOrchestratorAsync(TaskOrchestrationContext context, Email email) -{ - // Get the spam detection agent - DurableAIAgent spamDetectionAgent = context.GetAgent(SpamDetectionAgentName); - AgentSession spamSession = await spamDetectionAgent.CreateSessionAsync(); - - // Step 1: Check if the email is spam - AgentResponse 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} - """, - session: spamSession); - 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(EmailAssistantAgentName); - AgentSession emailSession = await emailAssistantAgent.CreateSessionAsync(); - - AgentResponse 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} - """, - session: emailSession); - - EmailResponse emailResponse = emailAssistantResponse.Result; - - return await context.CallActivityAsync(nameof(SendEmail), emailResponse.Response); -} - -// Activity functions -static void HandleSpamEmail(TaskActivityContext context, string reason) -{ - Console.WriteLine($"Email marked as spam: {reason}"); -} - -static void SendEmail(TaskActivityContext context, string message) -{ - Console.WriteLine($"Email sent: {message}"); -} - -// Configure the console app to host the AI agents. -IHost host = Host.CreateDefaultBuilder(args) - .ConfigureLogging(loggingBuilder => loggingBuilder.SetMinimumLevel(LogLevel.Warning)) - .ConfigureServices(services => - { - services.ConfigureDurableAgents( - options => - { - options - .AddAIAgent(spamDetectionAgent) - .AddAIAgent(emailAssistantAgent); - }, - workerBuilder: builder => - { - builder.UseDurableTaskScheduler(dtsConnectionString); - builder.AddTasks(registry => - { - registry.AddOrchestratorFunc(nameof(RunOrchestratorAsync), RunOrchestratorAsync); - registry.AddActivityFunc(nameof(HandleSpamEmail), HandleSpamEmail); - registry.AddActivityFunc(nameof(SendEmail), SendEmail); - }); - }, - clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString)); - }) - .Build(); - -await host.StartAsync(); - -DurableTaskClient durableTaskClient = host.Services.GetRequiredService(); - -// Console colors for better UX -Console.ForegroundColor = ConsoleColor.Cyan; -Console.WriteLine("=== Multi-Agent Conditional Orchestration Sample ==="); -Console.ResetColor(); -Console.WriteLine("Enter email content:"); -Console.WriteLine(); - -// Read email content from stdin -string? emailContent = Console.ReadLine(); -if (string.IsNullOrWhiteSpace(emailContent)) -{ - Console.ForegroundColor = ConsoleColor.Red; - Console.Error.WriteLine("Error: Email content is required."); - Console.ResetColor(); - Environment.Exit(1); - return; -} - -// Generate email ID automatically -Email email = new() -{ - EmailId = $"email-{Guid.NewGuid():N}", - EmailContent = emailContent -}; - -Console.WriteLine(); -Console.ForegroundColor = ConsoleColor.Gray; -Console.WriteLine("Starting orchestration..."); -Console.ResetColor(); - -try -{ - // Start the orchestration - string instanceId = await durableTaskClient.ScheduleNewOrchestrationInstanceAsync( - orchestratorName: nameof(RunOrchestratorAsync), - input: email); - - Console.ForegroundColor = ConsoleColor.Gray; - Console.WriteLine($"Orchestration started with instance ID: {instanceId}"); - Console.WriteLine("Waiting for completion..."); - Console.ResetColor(); - - // Wait for orchestration to complete - OrchestrationMetadata status = await durableTaskClient.WaitForInstanceCompletionAsync( - instanceId, - getInputsAndOutputs: true, - CancellationToken.None); - - Console.WriteLine(); - - if (status.RuntimeStatus == OrchestrationRuntimeStatus.Completed) - { - Console.ForegroundColor = ConsoleColor.Green; - Console.WriteLine("✓ Orchestration completed successfully!"); - Console.ResetColor(); - Console.WriteLine(); - Console.ForegroundColor = ConsoleColor.Yellow; - Console.Write("Result: "); - Console.ResetColor(); - Console.WriteLine(status.ReadOutputAs()); - } - else if (status.RuntimeStatus == OrchestrationRuntimeStatus.Failed) - { - Console.ForegroundColor = ConsoleColor.Red; - Console.WriteLine("✗ Orchestration failed!"); - Console.ResetColor(); - if (status.FailureDetails != null) - { - Console.WriteLine($"Error: {status.FailureDetails.ErrorMessage}"); - } - Environment.Exit(1); - } - else - { - Console.ForegroundColor = ConsoleColor.Yellow; - Console.WriteLine($"Orchestration status: {status.RuntimeStatus}"); - Console.ResetColor(); - } -} -catch (Exception ex) -{ - Console.ForegroundColor = ConsoleColor.Red; - Console.Error.WriteLine($"Error: {ex.Message}"); - Console.ResetColor(); - Environment.Exit(1); -} -finally -{ - await host.StopAsync(); -} diff --git a/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/04_AgentOrchestration_Conditionals/README.md b/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/04_AgentOrchestration_Conditionals/README.md deleted file mode 100644 index c10b33145a3..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/04_AgentOrchestration_Conditionals/README.md +++ /dev/null @@ -1,95 +0,0 @@ -# Multi-Agent Conditional Orchestration Sample - -This sample demonstrates how to use the durable agents extension to create a console app that orchestrates multiple AI agents with conditional logic based on the results of previous agent interactions. - -## Key Concepts Demonstrated - -- Multi-agent orchestration with conditional branching -- Using agent responses to determine workflow paths -- Activity functions for non-agent operations -- Waiting for orchestration completion using `WaitForInstanceCompletionAsync` - -## 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, you can run the sample: - -```bash -cd dotnet/samples/04-hosting/DurableAgents/ConsoleApps/04_AgentOrchestration_Conditionals -dotnet run --framework net10.0 -``` - -The app will prompt you for email content. You can test both legitimate emails and spam emails: - -### Testing with a Legitimate Email - -```text -=== Multi-Agent Conditional Orchestration Sample === -Enter 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! -``` - -The orchestration will analyze the email and display the result: - -```text -Orchestration started with instance ID: 86313f1d45fb42eeb50b1852626bf3ff -Waiting for completion... - -✓ Orchestration completed successfully! - -Result: Email sent: Thank you for your email. I'll prepare the updated figures... -``` - -### Testing with a Spam Email - -```text -=== Multi-Agent Conditional Orchestration Sample === -Enter email content: - -URGENT! You've won $1,000,000! Click here now to claim your prize! Limited time offer! Don't miss out! -``` - -The orchestration will detect it as spam and display: - -```text -Orchestration started with instance ID: 86313f1d45fb42eeb50b1852626bf3ff -Waiting for completion... - -✓ Orchestration completed successfully! - -Result: Email marked as spam: Contains suspicious claims about winning money and urgent action requests... -``` - -## Scriptable Usage - -You can also pipe email content to the app: - -```bash -# Test with a legitimate email -echo "Hi John, I hope you're doing well..." | dotnet run - -# Test with a spam email -echo "URGENT! You've won $1,000,000! Click here now!" | dotnet run -``` - -The orchestration will proceed as follows: - -1. The SpamDetectionAgent analyzes the email to determine if it's spam -2. Based on the result: - - If spam: The orchestration calls the `HandleSpamEmail` activity function - - If not spam: The EmailAssistantAgent drafts a response, then the `SendEmail` activity function is called - -## Viewing Orchestration State - -You can view the state of the orchestration in the Durable Task Scheduler dashboard: - -1. Open your browser and navigate to `http://localhost:8082` -2. In the dashboard, you can see: - - **Orchestrations**: View the orchestration instance, including its runtime status, input, output, and execution history - - **Agents**: View the state of both the SpamDetectionAgent and EmailAssistantAgent - -The orchestration instance ID is displayed in the console output. You can use this ID to find the specific orchestration in the dashboard and inspect the conditional branching logic, including which path was taken based on the spam detection result. diff --git a/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/05_AgentOrchestration_HITL/05_AgentOrchestration_HITL.csproj b/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/05_AgentOrchestration_HITL/05_AgentOrchestration_HITL.csproj deleted file mode 100644 index e1d62cd5175..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/05_AgentOrchestration_HITL/05_AgentOrchestration_HITL.csproj +++ /dev/null @@ -1,30 +0,0 @@ - - - net10.0 - Exe - enable - enable - AgentOrchestration_HITL - AgentOrchestration_HITL - - - - - - - - - - - - - - - - - diff --git a/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/05_AgentOrchestration_HITL/Models.cs b/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/05_AgentOrchestration_HITL/Models.cs deleted file mode 100644 index 1eaf1407eb1..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/05_AgentOrchestration_HITL/Models.cs +++ /dev/null @@ -1,44 +0,0 @@ -// 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/04-hosting/DurableAgents/ConsoleApps/05_AgentOrchestration_HITL/Program.cs b/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/05_AgentOrchestration_HITL/Program.cs deleted file mode 100644 index 80c09fee016..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/05_AgentOrchestration_HITL/Program.cs +++ /dev/null @@ -1,336 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Text.Json; -using AgentOrchestration_HITL; -using Azure; -using Azure.AI.OpenAI; -using Azure.Identity; -using Microsoft.Agents.AI; -using Microsoft.Agents.AI.DurableTask; -using Microsoft.DurableTask; -using Microsoft.DurableTask.Client; -using Microsoft.DurableTask.Client.AzureManaged; -using Microsoft.DurableTask.Worker; -using Microsoft.DurableTask.Worker.AzureManaged; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using OpenAI.Chat; - -// Get the Azure OpenAI endpoint and deployment name from environment variables. -string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") - ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") - ?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT_NAME is not set."); - -// Get DTS connection string from environment variable -string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING") - ?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None"; - -// Use Azure Key Credential if provided, otherwise use Azure CLI Credential. -string? azureOpenAiKey = Environment.GetEnvironmentVariable("AZURE_OPENAI_API_KEY"); -// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. -// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid -// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. -AzureOpenAIClient client = !string.IsNullOrEmpty(azureOpenAiKey) - ? new AzureOpenAIClient(new Uri(endpoint), new AzureKeyCredential(azureOpenAiKey)) - : new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()); - -// 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).AsAIAgent(WriterInstructions, WriterName); - -// Orchestrator function -static async Task RunOrchestratorAsync(TaskOrchestrationContext context, ContentGenerationInput input) -{ - // Get the writer agent - DurableAIAgent writerAgent = context.GetAgent("WriterAgent"); - AgentSession writerSession = await writerAgent.CreateSessionAsync(); - - // Set initial status - context.SetCustomStatus($"Starting content generation for topic: {input.Topic}"); - - // Step 1: Generate initial content - AgentResponse writerResponse = await writerAgent.RunAsync( - message: $"Write a short article about '{input.Topic}' in less than 300 words.", - session: writerSession); - 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} - """, - session: writerSession); - - 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."); -} - -// Activity functions -static void NotifyUserForApproval(TaskActivityContext context, GeneratedContent content) -{ - // In a real implementation, this would send notifications via email, SMS, etc. - Console.WriteLine( - $""" - NOTIFICATION: Please review the following content for approval: - Title: {content.Title} - Content: {content.Content} - Use the approval endpoint to approve or reject this content. - """); -} - -static void PublishContent(TaskActivityContext context, GeneratedContent content) -{ - // In a real implementation, this would publish to a CMS, website, etc. - Console.WriteLine( - $""" - PUBLISHING: Content has been published successfully. - Title: {content.Title} - Content: {content.Content} - """); -} - -// Configure the console app to host the AI agent. -IHost host = Host.CreateDefaultBuilder(args) - .ConfigureLogging(loggingBuilder => loggingBuilder.SetMinimumLevel(LogLevel.Warning)) - .ConfigureServices(services => - { - services.ConfigureDurableAgents( - options => options.AddAIAgent(writerAgent), - workerBuilder: builder => - { - builder.UseDurableTaskScheduler(dtsConnectionString); - builder.AddTasks(registry => - { - registry.AddOrchestratorFunc(nameof(RunOrchestratorAsync), RunOrchestratorAsync); - registry.AddActivityFunc(nameof(NotifyUserForApproval), NotifyUserForApproval); - registry.AddActivityFunc(nameof(PublishContent), PublishContent); - }); - }, - clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString)); - }) - .Build(); - -await host.StartAsync(); - -DurableTaskClient durableTaskClient = host.Services.GetRequiredService(); - -// Console colors for better UX -Console.ForegroundColor = ConsoleColor.Cyan; -Console.WriteLine("=== Human-in-the-Loop Orchestration Sample ==="); -Console.ResetColor(); -Console.WriteLine("Enter topic for content generation:"); -Console.WriteLine(); - -// Read topic from stdin -string? topic = Console.ReadLine(); -if (string.IsNullOrWhiteSpace(topic)) -{ - Console.ForegroundColor = ConsoleColor.Red; - Console.Error.WriteLine("Error: Topic is required."); - Console.ResetColor(); - Environment.Exit(1); - return; -} - -// Prompt for optional parameters with defaults -Console.WriteLine(); -Console.WriteLine("Max review attempts (default: 3):"); -string? maxAttemptsInput = Console.ReadLine(); -int maxReviewAttempts = int.TryParse(maxAttemptsInput, out int maxAttempts) && maxAttempts > 0 - ? maxAttempts - : 3; - -Console.WriteLine("Approval timeout in hours (default: 72):"); -string? timeoutInput = Console.ReadLine(); -float approvalTimeoutHours = float.TryParse(timeoutInput, out float timeout) && timeout > 0 - ? timeout - : 72; - -ContentGenerationInput input = new() -{ - Topic = topic, - MaxReviewAttempts = maxReviewAttempts, - ApprovalTimeoutHours = approvalTimeoutHours -}; - -Console.WriteLine(); -Console.ForegroundColor = ConsoleColor.Gray; -Console.WriteLine("Starting orchestration..."); -Console.ResetColor(); - -try -{ - // Start the orchestration - string instanceId = await durableTaskClient.ScheduleNewOrchestrationInstanceAsync( - orchestratorName: nameof(RunOrchestratorAsync), - input: input); - - Console.ForegroundColor = ConsoleColor.Gray; - Console.WriteLine($"Orchestration started with instance ID: {instanceId}"); - Console.WriteLine("Waiting for human approval..."); - Console.ResetColor(); - Console.WriteLine(); - - // Monitor orchestration status and handle approval prompts - using CancellationTokenSource cts = new(); - Task orchestrationTask = Task.Run(async () => - { - while (!cts.Token.IsCancellationRequested) - { - OrchestrationMetadata? status = await durableTaskClient.GetInstanceAsync( - instanceId, - getInputsAndOutputs: true, - cts.Token); - - if (status == null) - { - await Task.Delay(TimeSpan.FromSeconds(1), cts.Token); - continue; - } - - // Check if we're waiting for approval - if (status.SerializedCustomStatus != null) - { - string? customStatus = status.ReadCustomStatusAs(); - if (customStatus?.StartsWith("Requesting human feedback", StringComparison.OrdinalIgnoreCase) == true) - { - // Prompt user for approval - Console.ForegroundColor = ConsoleColor.Yellow; - Console.WriteLine("Content is ready for review. Check the logs above for details."); - Console.Write("Approve? (y/n): "); - Console.ResetColor(); - - string? approvalInput = Console.ReadLine(); - bool approved = approvalInput?.Trim().Equals("y", StringComparison.OrdinalIgnoreCase) == true; - - Console.Write("Feedback (optional): "); - string? feedback = Console.ReadLine() ?? ""; - - HumanApprovalResponse approvalResponse = new() - { - Approved = approved, - Feedback = feedback - }; - - await durableTaskClient.RaiseEventAsync(instanceId, "HumanApproval", approvalResponse); - } - } - - if (status.RuntimeStatus is OrchestrationRuntimeStatus.Completed or OrchestrationRuntimeStatus.Failed or OrchestrationRuntimeStatus.Terminated) - { - break; - } - - await Task.Delay(TimeSpan.FromSeconds(1), cts.Token); - } - }, cts.Token); - - // Wait for orchestration to complete - OrchestrationMetadata finalStatus = await durableTaskClient.WaitForInstanceCompletionAsync( - instanceId, - getInputsAndOutputs: true, - CancellationToken.None); - - cts.Cancel(); - await orchestrationTask; - - Console.WriteLine(); - - if (finalStatus.RuntimeStatus == OrchestrationRuntimeStatus.Completed) - { - Console.ForegroundColor = ConsoleColor.Green; - Console.WriteLine("✓ Orchestration completed successfully!"); - Console.ResetColor(); - Console.WriteLine(); - - JsonElement output = finalStatus.ReadOutputAs(); - if (output.TryGetProperty("content", out JsonElement contentElement)) - { - Console.ForegroundColor = ConsoleColor.Yellow; - Console.WriteLine("Published content:"); - Console.ResetColor(); - Console.WriteLine(contentElement.GetString()); - } - } - else if (finalStatus.RuntimeStatus == OrchestrationRuntimeStatus.Failed) - { - Console.ForegroundColor = ConsoleColor.Red; - Console.WriteLine("✗ Orchestration failed!"); - Console.ResetColor(); - if (finalStatus.FailureDetails != null) - { - Console.WriteLine($"Error: {finalStatus.FailureDetails.ErrorMessage}"); - } - Environment.Exit(1); - } - else - { - Console.ForegroundColor = ConsoleColor.Yellow; - Console.WriteLine($"Orchestration status: {finalStatus.RuntimeStatus}"); - Console.ResetColor(); - } -} -catch (Exception ex) -{ - Console.ForegroundColor = ConsoleColor.Red; - Console.Error.WriteLine($"Error: {ex.Message}"); - Console.ResetColor(); - Environment.Exit(1); -} -finally -{ - await host.StopAsync(); -} diff --git a/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/05_AgentOrchestration_HITL/README.md b/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/05_AgentOrchestration_HITL/README.md deleted file mode 100644 index ec0fe9911e1..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/05_AgentOrchestration_HITL/README.md +++ /dev/null @@ -1,73 +0,0 @@ -# Human-in-the-Loop Orchestration Sample - -This sample demonstrates how to use the durable agents extension to create a console app that implements a human-in-the-loop workflow using durable orchestration, including interactive approval prompts. - -## Key Concepts Demonstrated - -- Human-in-the-loop workflows with durable orchestration -- External event handling for human approval/rejection -- Timeout handling for approval requests -- Iterative content refinement based on human feedback - -## 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, you can run the sample: - -```bash -cd dotnet/samples/04-hosting/DurableAgents/ConsoleApps/05_AgentOrchestration_HITL -dotnet run --framework net10.0 -``` - -The app will prompt you for input: - -```text -=== Human-in-the-Loop Orchestration Sample === -Enter topic for content generation: - -The Future of Artificial Intelligence - -Max review attempts (default: 3): -3 -Approval timeout in hours (default: 72): -72 -``` - -The orchestration will generate content and prompt you for approval: - -```text -Orchestration started with instance ID: 86313f1d45fb42eeb50b1852626bf3ff - -=== NOTIFICATION: Content Ready for Review === -Title: The Future of Artificial Intelligence - -Content: -[Generated content appears here] - -Please review the content above and provide your approval. - -Content is ready for review. Check the logs above for details. -Approve? (y/n): n -Feedback (optional): Please add more details about the ethical implications. -``` - -The orchestration will incorporate your feedback and regenerate the content. Once approved, it will publish and complete. - -## Viewing Orchestration State - -You can view the state of the orchestration in the Durable Task Scheduler dashboard: - -1. Open your browser and navigate to `http://localhost:8082` -2. In the dashboard, you can see: - - **Orchestrations**: View the orchestration instance, including its runtime status, custom status (which shows approval state), input, output, and execution history - - **Agents**: View the state of the WriterAgent, including conversation history - -The orchestration instance ID is displayed in the console output. You can use this ID to find the specific orchestration in the dashboard and inspect: - -- The custom status field, which shows the current state of the approval workflow -- When the orchestration is waiting for external events -- The iteration count and feedback history -- The final published content diff --git a/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/06_LongRunningTools/06_LongRunningTools.csproj b/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/06_LongRunningTools/06_LongRunningTools.csproj deleted file mode 100644 index e07b30b9f89..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/06_LongRunningTools/06_LongRunningTools.csproj +++ /dev/null @@ -1,30 +0,0 @@ - - - net10.0 - Exe - enable - enable - LongRunningTools - LongRunningTools - - - - - - - - - - - - - - - - - diff --git a/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/06_LongRunningTools/Models.cs b/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/06_LongRunningTools/Models.cs deleted file mode 100644 index 43ab9d99f8b..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/06_LongRunningTools/Models.cs +++ /dev/null @@ -1,44 +0,0 @@ -// 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 feedback response. -/// -public sealed class HumanFeedbackResponse -{ - [JsonPropertyName("approved")] - public bool Approved { get; set; } - - [JsonPropertyName("feedback")] - public string Feedback { get; set; } = string.Empty; -} diff --git a/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/06_LongRunningTools/Program.cs b/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/06_LongRunningTools/Program.cs deleted file mode 100644 index 7bb593ab293..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/06_LongRunningTools/Program.cs +++ /dev/null @@ -1,355 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.ComponentModel; -using Azure; -using Azure.AI.OpenAI; -using Azure.Identity; -using LongRunningTools; -using Microsoft.Agents.AI; -using Microsoft.Agents.AI.DurableTask; -using Microsoft.DurableTask; -using Microsoft.DurableTask.Client; -using Microsoft.DurableTask.Client.AzureManaged; -using Microsoft.DurableTask.Worker; -using Microsoft.DurableTask.Worker.AzureManaged; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using OpenAI.Chat; - -// Get the Azure OpenAI endpoint and deployment name from environment variables. -string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") - ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") - ?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT_NAME is not set."); - -// Get DTS connection string from environment variable -string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING") - ?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None"; - -// Use Azure Key Credential if provided, otherwise use Azure CLI Credential. -string? azureOpenAiKey = Environment.GetEnvironmentVariable("AZURE_OPENAI_API_KEY"); -// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. -// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid -// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. -AzureOpenAIClient client = !string.IsNullOrEmpty(azureOpenAiKey) - ? new AzureOpenAIClient(new Uri(endpoint), new AzureKeyCredential(azureOpenAiKey)) - : new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()); - -// 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).AsAIAgent(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. - """; - -const string HumanFeedbackEventName = "HumanFeedback"; - -// Orchestrator function -static async Task RunOrchestratorAsync(TaskOrchestrationContext context, ContentGenerationInput input) -{ - // Get the writer agent - DurableAIAgent writerAgent = context.GetAgent(WriterAgentName); - AgentSession writerSession = await writerAgent.CreateSessionAsync(); - - // Set initial status - context.SetCustomStatus($"Starting content generation for topic: {input.Topic}"); - - // Step 1: Generate initial content - AgentResponse writerResponse = await writerAgent.RunAsync( - message: $"Write a short article about '{input.Topic}'.", - session: writerSession); - 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) - { - // NOTE: CustomStatus has a 16 KB UTF-16 limit in Durable Functions. - // Only include short metadata here - the full content is passed via activity inputs/outputs. - context.SetCustomStatus( - new - { - message = "Requesting human feedback.", - approvalTimeoutHours = input.ApprovalTimeoutHours, - iterationCount, - contentTitle = content.Title, - }); - - // Step 2: Notify user to review the content - await context.CallActivityAsync(nameof(NotifyUserForApproval), content); - - // Step 3: Wait for human feedback with configurable timeout - HumanFeedbackResponse humanResponse; - try - { - humanResponse = await context.WaitForExternalEvent( - eventName: HumanFeedbackEventName, - 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, - }); - 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...", - contentTitle = content.Title, - }); - - // 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, - contentTitle = content.Title, - }); - return new { content = content.Content }; - } - - context.SetCustomStatus(new - { - message = "Content rejected by human reviewer. Incorporating feedback and regenerating...", - humanFeedback = humanResponse, - contentTitle = content.Title, - }); - - // 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} - """, - session: writerSession); - - 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."); -} - -// Activity functions -static void NotifyUserForApproval(TaskActivityContext context, GeneratedContent content) -{ - // In a real implementation, this would send notifications via email, SMS, etc. - Console.ForegroundColor = ConsoleColor.DarkMagenta; - Console.WriteLine( - $""" - NOTIFICATION: Please review the following content for approval: - Title: {content.Title} - Content: {content.Content} - """); - Console.ResetColor(); -} - -static void PublishContent(TaskActivityContext context, GeneratedContent content) -{ - // In a real implementation, this would publish to a CMS, website, etc. - Console.ForegroundColor = ConsoleColor.DarkMagenta; - Console.WriteLine( - $""" - PUBLISHING: Content has been published successfully. - Title: {content.Title} - Content: {content.Content} - """); - Console.ResetColor(); -} - -// Tools that demonstrate starting orchestrations from agent tool calls. -[Description("Starts a content generation workflow and returns the instance ID for tracking.")] -static string StartContentGenerationWorkflow([Description("The topic for content generation")] string 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(RunOrchestratorAsync), - input: new ContentGenerationInput - { - Topic = topic, - MaxReviewAttempts = MaxReviewAttempts, - ApprovalTimeoutHours = ApprovalTimeoutHours - }); - - return $"Workflow started with instance ID: {instanceId}"; -} - -[Description("Gets the status of a workflow orchestration and returns a summary of the workflow's current status.")] -static async Task GetWorkflowStatusAsync( - [Description("The instance ID of the workflow to check")] string instanceId, - [Description("Whether to include detailed information")] bool includeDetails = true) -{ - // Get the current agent context using the session-static property - OrchestrationMetadata? status = await DurableAgentContext.Current.GetOrchestrationStatusAsync( - instanceId, - includeDetails); - - if (status is null) - { - 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. If approved, the workflow will be published. " + - "If rejected, the workflow will generate new content.")] -static async Task SubmitHumanFeedbackAsync( - [Description("The instance ID of the workflow to submit feedback for")] string instanceId, - [Description("Feedback to submit")] HumanFeedbackResponse feedback) -{ - await DurableAgentContext.Current.RaiseOrchestrationEventAsync(instanceId, HumanFeedbackEventName, feedback); -} - -// Configure the console app to host the AI agents. -IHost host = Host.CreateDefaultBuilder(args) - .ConfigureLogging(loggingBuilder => loggingBuilder.SetMinimumLevel(LogLevel.Warning)) - .ConfigureServices(services => - { - services.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 => - { - return client.GetChatClient(deploymentName).AsAIAgent( - instructions: PublisherAgentInstructions, - name: PublisherAgentName, - services: sp, - tools: [ - AIFunctionFactory.Create(StartContentGenerationWorkflow), - AIFunctionFactory.Create(GetWorkflowStatusAsync), - AIFunctionFactory.Create(SubmitHumanFeedbackAsync), - ]); - }); - }, - workerBuilder: builder => - { - builder.UseDurableTaskScheduler(dtsConnectionString); - builder.AddTasks(registry => - { - registry.AddOrchestratorFunc(nameof(RunOrchestratorAsync), RunOrchestratorAsync); - registry.AddActivityFunc(nameof(NotifyUserForApproval), NotifyUserForApproval); - registry.AddActivityFunc(nameof(PublishContent), PublishContent); - }); - }, - clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString)); - }) - .Build(); - -await host.StartAsync(); - -// Get the agent proxy from services -IServiceProvider services = host.Services; -AIAgent? agentProxy = services.GetKeyedService(PublisherAgentName); -if (agentProxy == null) -{ - Console.ForegroundColor = ConsoleColor.Red; - Console.Error.WriteLine("Agent 'Publisher' not found."); - Console.ResetColor(); - Environment.Exit(1); - return; -} - -// Console colors for better UX -Console.ForegroundColor = ConsoleColor.Cyan; -Console.WriteLine("=== Long Running Tools Sample ==="); -Console.ResetColor(); -Console.WriteLine("Enter a topic for the Publisher agent to write about (or 'exit' to quit):"); -Console.WriteLine(); - -// Create a session for the conversation -AgentSession session = await agentProxy.CreateSessionAsync(); - -using CancellationTokenSource cts = new(); -Console.CancelKeyPress += (sender, e) => -{ - e.Cancel = true; - cts.Cancel(); -}; - -while (!cts.Token.IsCancellationRequested) -{ - // Read input from stdin - Console.ForegroundColor = ConsoleColor.Yellow; - Console.Write("You: "); - Console.ResetColor(); - - string? input = Console.ReadLine(); - if (string.IsNullOrWhiteSpace(input) || input.Equals("exit", StringComparison.OrdinalIgnoreCase)) - { - break; - } - - // Run the agent - Console.ForegroundColor = ConsoleColor.Green; - Console.Write("Publisher: "); - Console.ResetColor(); - - try - { - AgentResponse agentResponse = await agentProxy.RunAsync( - message: input, - session: session, - cancellationToken: cts.Token); - - Console.WriteLine(agentResponse.Text); - Console.WriteLine(); - } - catch (Exception ex) - { - Console.ForegroundColor = ConsoleColor.Red; - Console.Error.WriteLine($"Error: {ex.Message}"); - Console.ResetColor(); - Console.WriteLine(); - } - - Console.WriteLine("(Press Enter to prompt the Publisher agent again)"); - _ = Console.ReadLine(); -} - -await host.StopAsync(); diff --git a/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/06_LongRunningTools/README.md b/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/06_LongRunningTools/README.md deleted file mode 100644 index 1c87ab50ed9..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/06_LongRunningTools/README.md +++ /dev/null @@ -1,90 +0,0 @@ -# Long Running Tools Sample - -This sample demonstrates how to use the durable agents extension to create a console app with agents that have 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, you can run the sample: - -```bash -cd dotnet/samples/04-hosting/DurableAgents/ConsoleApps/06_LongRunningTools -dotnet run --framework net10.0 -``` - -The app will prompt you for input. You can interact with the Publisher agent: - -```text -=== Long Running Tools Sample === -Enter a topic for the Publisher agent to write about (or 'exit' to quit): - -You: Start a content generation workflow for the topic 'The Future of Artificial Intelligence' -Publisher: 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! -``` - -Behind the scenes, the publisher agent will: - -1. Start the content generation workflow via a tool call -2. The workflow will generate initial content using the Writer agent and wait for human approval, which will be visible in the terminal - -Once the workflow is waiting for human approval, you can send approval or rejection by prompting the publisher agent accordingly. - -> [!NOTE] -> You must press Enter after each message to continue the conversation. The sample is set up this way because the workflow is running in the background and may write to the console asynchronously. - -To tell the agent to rewrite the content with feedback, you can prompt it to reject the content with feedback. - -```text -You: Reject the content with feedback: The article needs more technical depth and better examples. -Publisher: The content has been successfully rejected with the feedback: "The article needs more technical depth and better examples." The workflow will now generate new content based on this feedback. -``` - -Once you're satisfied with the content, you can approve it for publishing. - -```text -You: Approve the content -Publisher: The content has been successfully approved for publishing. If you need any more assistance or have further requests, feel free to let me know! -``` - -Once the workflow has completed, you can get the status by prompting the publisher agent to give you the status. - -```text -You: Get the status of the workflow you previously started -Publisher: The status of the workflow with instance ID **6a04276e8d824d8d941e1dc4142cc254** is as follows: - -- **Execution Status:** Completed -- **Created At:** December 22, 2025, 23:08:13 UTC -- **Last Updated At:** December 22, 2025, 23:09:59 UTC -- **Workflow Status:** - - Message: Content published successfully at December 22, 2025, 23:09:59 UTC - - Human Feedback: Approved -``` - -## Viewing Agent and Orchestration State - -You can view the state of both the agent and the orchestrations it starts in the Durable Task Scheduler dashboard: - -1. Open your browser and navigate to `http://localhost:8082` -2. In the dashboard, you can see: - - **Agents**: View the state of the Publisher agent, including its conversation history and tool call history - - **Orchestrations**: View the content generation orchestration instances that were started by the agent via tool calls, including their runtime status, custom status, input, output, and execution history - -When the publisher agent starts a workflow, the orchestration instance ID is included in the agent's response. You can use this ID to find the specific orchestration in the dashboard and inspect: - -- The orchestration's execution progress -- When it's waiting for human approval (visible in custom status) -- The content generation workflow state -- The WriterAgent state within the orchestration - -This demonstrates how agents can manage long-running workflows and how you can monitor both the agent's state and the workflows it orchestrates. diff --git a/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/07_ReliableStreaming/07_ReliableStreaming.csproj b/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/07_ReliableStreaming/07_ReliableStreaming.csproj deleted file mode 100644 index 0ffe410d65f..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/07_ReliableStreaming/07_ReliableStreaming.csproj +++ /dev/null @@ -1,31 +0,0 @@ - - - net10.0 - Exe - enable - enable - ReliableStreaming - ReliableStreaming - - - - - - - - - - - - - - - - - - diff --git a/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/07_ReliableStreaming/Program.cs b/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/07_ReliableStreaming/Program.cs deleted file mode 100644 index 9be3f4f659a..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/07_ReliableStreaming/Program.cs +++ /dev/null @@ -1,367 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -// This sample demonstrates how to implement reliable streaming for durable agents using Redis Streams. -// It reads prompts from stdin and streams agent responses to stdout in real-time. - -using System.ComponentModel; -using Azure; -using Azure.AI.OpenAI; -using Azure.Identity; -using Microsoft.Agents.AI; -using Microsoft.Agents.AI.DurableTask; -using Microsoft.DurableTask.Client.AzureManaged; -using Microsoft.DurableTask.Worker.AzureManaged; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using OpenAI.Chat; -using ReliableStreaming; -using StackExchange.Redis; - -// Get the Azure OpenAI endpoint and deployment name from environment variables. -string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") - ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") - ?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT_NAME is not set."); - -// Get Redis connection string from environment variable. -string redisConnectionString = Environment.GetEnvironmentVariable("REDIS_CONNECTION_STRING") - ?? "localhost:6379"; - -// Get the Redis stream TTL from environment variable (default: 10 minutes). -int redisStreamTtlMinutes = int.Parse(Environment.GetEnvironmentVariable("REDIS_STREAM_TTL_MINUTES") ?? "10"); - -// Get DTS connection string from environment variable -string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING") - ?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None"; - -// Use Azure Key Credential if provided, otherwise use Azure CLI Credential. -string? azureOpenAiKey = Environment.GetEnvironmentVariable("AZURE_OPENAI_API_KEY"); -// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. -// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid -// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. -AzureOpenAIClient client = !string.IsNullOrEmpty(azureOpenAiKey) - ? new AzureOpenAIClient(new Uri(endpoint), new AzureKeyCredential(azureOpenAiKey)) - : new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()); - -// Travel Planner agent instructions - designed to produce longer responses for demonstrating streaming. -const string TravelPlannerName = "TravelPlanner"; -const string TravelPlannerInstructions = - """ - You are an expert travel planner who creates detailed, personalized travel itineraries. - When asked to plan a trip, you should: - 1. Create a comprehensive day-by-day itinerary - 2. Include specific recommendations for activities, restaurants, and attractions - 3. Provide practical tips for each destination - 4. Consider weather and local events when making recommendations - 5. Include estimated times and logistics between activities - - Always use the available tools to get current weather forecasts and local events - for the destination to make your recommendations more relevant and timely. - - Format your response with clear headings for each day and include emoji icons - to make the itinerary easy to scan and visually appealing. - """; - -// Mock travel tools that return hardcoded data for demonstration purposes. -[Description("Gets the weather forecast for a destination on a specific date. Use this to provide weather-aware recommendations in the itinerary.")] -static string GetWeatherForecast(string destination, string date) -{ - Dictionary weatherByRegion = new(StringComparer.OrdinalIgnoreCase) - { - ["Tokyo"] = ("Partly cloudy with a chance of light rain", 58, 45), - ["Paris"] = ("Overcast with occasional drizzle", 52, 41), - ["New York"] = ("Clear and cold", 42, 28), - ["London"] = ("Foggy morning, clearing in afternoon", 48, 38), - ["Sydney"] = ("Sunny and warm", 82, 68), - ["Rome"] = ("Sunny with light breeze", 62, 48), - ["Barcelona"] = ("Partly sunny", 59, 47), - ["Amsterdam"] = ("Cloudy with light rain", 46, 38), - ["Dubai"] = ("Sunny and hot", 85, 72), - ["Singapore"] = ("Tropical thunderstorms in afternoon", 88, 77), - ["Bangkok"] = ("Hot and humid, afternoon showers", 91, 78), - ["Los Angeles"] = ("Sunny and pleasant", 72, 55), - ["San Francisco"] = ("Morning fog, afternoon sun", 62, 52), - ["Seattle"] = ("Rainy with breaks", 48, 40), - ["Miami"] = ("Warm and sunny", 78, 65), - ["Honolulu"] = ("Tropical paradise weather", 82, 72), - }; - - (string condition, int highF, int lowF) forecast = ("Partly cloudy", 65, 50); - foreach (KeyValuePair entry in weatherByRegion) - { - if (destination.Contains(entry.Key, StringComparison.OrdinalIgnoreCase)) - { - forecast = entry.Value; - break; - } - } - - return $""" - Weather forecast for {destination} on {date}: - Conditions: {forecast.condition} - High: {forecast.highF}°F ({(forecast.highF - 32) * 5 / 9}°C) - Low: {forecast.lowF}°F ({(forecast.lowF - 32) * 5 / 9}°C) - - Recommendation: {GetWeatherRecommendation(forecast.condition)} - """; -} - -[Description("Gets local events and activities happening at a destination around a specific date. Use this to suggest timely activities and experiences.")] -static string GetLocalEvents(string destination, string date) -{ - Dictionary eventsByCity = new(StringComparer.OrdinalIgnoreCase) - { - ["Tokyo"] = [ - "🎭 Kabuki Theater Performance at Kabukiza Theatre - Traditional Japanese drama", - "🌸 Winter Illuminations at Yoyogi Park - Spectacular light displays", - "🍜 Ramen Festival at Tokyo Station - Sample ramen from across Japan", - "🎮 Gaming Expo at Tokyo Big Sight - Latest video games and technology", - ], - ["Paris"] = [ - "🎨 Impressionist Exhibition at Musée d'Orsay - Extended evening hours", - "🍷 Wine Tasting Tour in Le Marais - Local sommelier guided", - "🎵 Jazz Night at Le Caveau de la Huchette - Historic jazz club", - "🥐 French Pastry Workshop - Learn from master pâtissiers", - ], - ["New York"] = [ - "🎭 Broadway Show: Hamilton - Limited engagement performances", - "🏀 Knicks vs Lakers at Madison Square Garden", - "🎨 Modern Art Exhibit at MoMA - New installations", - "🍕 Pizza Walking Tour of Brooklyn - Artisan pizzerias", - ], - ["London"] = [ - "👑 Royal Collection Exhibition at Buckingham Palace", - "🎭 West End Musical: The Phantom of the Opera", - "🍺 Craft Beer Festival at Brick Lane", - "🎪 Winter Wonderland at Hyde Park - Rides and markets", - ], - ["Sydney"] = [ - "🏄 Pro Surfing Competition at Bondi Beach", - "🎵 Opera at Sydney Opera House - La Bohème", - "🦘 Wildlife Night Safari at Taronga Zoo", - "🍽️ Harbor Dinner Cruise with fireworks", - ], - ["Rome"] = [ - "🏛️ After-Hours Vatican Tour - Skip the crowds", - "🍝 Pasta Making Class in Trastevere", - "🎵 Classical Concert at Borghese Gallery", - "🍷 Wine Tasting in Roman Cellars", - ], - }; - - string[] events = [ - "🎭 Local theater performance", - "🍽️ Food and wine festival", - "🎨 Art gallery opening", - "🎵 Live music at local venues", - ]; - - foreach (KeyValuePair entry in eventsByCity) - { - if (destination.Contains(entry.Key, StringComparison.OrdinalIgnoreCase)) - { - events = entry.Value; - break; - } - } - - string eventList = string.Join("\n• ", events); - return $""" - Local events in {destination} around {date}: - - • {eventList} - - 💡 Tip: Book popular events in advance as they may sell out quickly! - """; -} - -static string GetWeatherRecommendation(string condition) -{ - return condition switch - { - string c when c.Contains("rain", StringComparison.OrdinalIgnoreCase) || c.Contains("drizzle", StringComparison.OrdinalIgnoreCase) => - "Bring an umbrella and waterproof jacket. Consider indoor activities for backup.", - string c when c.Contains("fog", StringComparison.OrdinalIgnoreCase) => - "Morning visibility may be limited. Plan outdoor sightseeing for afternoon.", - string c when c.Contains("cold", StringComparison.OrdinalIgnoreCase) => - "Layer up with warm clothing. Hot drinks and cozy cafés recommended.", - string c when c.Contains("hot", StringComparison.OrdinalIgnoreCase) || c.Contains("warm", StringComparison.OrdinalIgnoreCase) => - "Stay hydrated and use sunscreen. Plan strenuous activities for cooler morning hours.", - string c when c.Contains("thunder", StringComparison.OrdinalIgnoreCase) || c.Contains("storm", StringComparison.OrdinalIgnoreCase) => - "Keep an eye on weather updates. Have indoor alternatives ready.", - _ => "Pleasant conditions expected. Great day for outdoor exploration!" - }; -} - -// Configure the console app to host the AI agent. -IHost host = Host.CreateDefaultBuilder(args) - .ConfigureLogging(loggingBuilder => loggingBuilder.SetMinimumLevel(LogLevel.Warning)) - .ConfigureServices(services => - { - services.ConfigureDurableAgents( - options => - { - // Define the Travel Planner agent with tools for weather and events - options.AddAIAgentFactory(TravelPlannerName, sp => - { - return client.GetChatClient(deploymentName).AsAIAgent( - instructions: TravelPlannerInstructions, - name: TravelPlannerName, - services: sp, - tools: [ - AIFunctionFactory.Create(GetWeatherForecast), - AIFunctionFactory.Create(GetLocalEvents), - ]); - }); - }, - workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString), - clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString)); - - // Register Redis connection as a singleton - services.AddSingleton(_ => - ConnectionMultiplexer.Connect(redisConnectionString)); - - // Register the Redis stream response handler - this captures agent responses - // and publishes them to Redis Streams for reliable delivery. - services.AddSingleton(sp => - new RedisStreamResponseHandler( - sp.GetRequiredService(), - TimeSpan.FromMinutes(redisStreamTtlMinutes))); - services.AddSingleton(sp => - sp.GetRequiredService()); - }) - .Build(); - -await host.StartAsync(); - -// Get the agent proxy from services -IServiceProvider services = host.Services; -AIAgent? agentProxy = services.GetKeyedService(TravelPlannerName); -RedisStreamResponseHandler streamHandler = services.GetRequiredService(); - -if (agentProxy == null) -{ - Console.ForegroundColor = ConsoleColor.Red; - Console.Error.WriteLine($"Agent '{TravelPlannerName}' not found."); - Console.ResetColor(); - Environment.Exit(1); - return; -} - -// Console colors for better UX -Console.ForegroundColor = ConsoleColor.Cyan; -Console.WriteLine("=== Reliable Streaming Sample ==="); -Console.ResetColor(); -Console.WriteLine("Enter a travel planning request (or 'exit' to quit):"); -Console.WriteLine(); - -string? lastCursor = null; - -async Task ReadStreamTask(string conversationId, string? cursor, CancellationToken cancellationToken) -{ - // Initialize lastCursor to the starting cursor position - // This ensures we have a valid cursor even if cancellation happens before any chunks are processed - lastCursor = cursor; - - await foreach (StreamChunk chunk in streamHandler.ReadStreamAsync(conversationId, cursor, cancellationToken)) - { - if (chunk.Error != null) - { - Console.ForegroundColor = ConsoleColor.Red; - Console.Error.WriteLine($"\n[Error: {chunk.Error}]"); - Console.ResetColor(); - break; - } - - if (chunk.IsDone) - { - Console.WriteLine(); - Console.WriteLine(); - break; - } - - if (chunk.Text != null) - { - Console.Write(chunk.Text); - Console.Out.Flush(); - } - - // Always update lastCursor to track the latest entry ID, even if text is null - // This ensures we can resume from the correct position after interruption - if (!string.IsNullOrEmpty(chunk.EntryId)) - { - lastCursor = chunk.EntryId; - } - } -} - -// New conversation: prompt from stdin -Console.ForegroundColor = ConsoleColor.Yellow; -Console.Write("You: "); -Console.ResetColor(); - -string? prompt = Console.ReadLine(); -if (string.IsNullOrWhiteSpace(prompt) || prompt.Equals("exit", StringComparison.OrdinalIgnoreCase)) -{ - return; -} - -// Create a new agent session -AgentSession session = await agentProxy.CreateSessionAsync(); -AgentSessionId sessionId = session.GetService(); -string conversationId = sessionId.ToString(); - -Console.ForegroundColor = ConsoleColor.Green; -Console.WriteLine($"Conversation ID: {conversationId}"); -Console.WriteLine("Press [Enter] to interrupt the stream."); -Console.ResetColor(); - -// Run the agent in the background -DurableAgentRunOptions options = new() { IsFireAndForget = true }; -await agentProxy.RunAsync(prompt, session, options, CancellationToken.None); - -bool streamCompleted = false; -while (!streamCompleted) -{ - // On a key press, cancel the cancellation token to stop the stream - using CancellationTokenSource userCancellationSource = new(); - _ = Task.Run(() => - { - _ = Console.ReadLine(); - userCancellationSource.Cancel(); - }); - - try - { - // Start reading the stream and wait for it to complete - await ReadStreamTask(conversationId, lastCursor, userCancellationSource.Token); - streamCompleted = true; - } - catch (OperationCanceledException) - { - Console.ForegroundColor = ConsoleColor.Yellow; - Console.WriteLine("Stream cancelled. Press [Enter] to reconnect and resume the stream from the last cursor."); - // Ensure lastCursor is set - if it's still null, we at least have the starting cursor - string cursorValue = lastCursor ?? "(n/a)"; - Console.WriteLine($"Last cursor: {cursorValue}"); - Console.ResetColor(); - // Explicitly flush to ensure the message is written immediately - Console.Out.Flush(); - } - - if (!streamCompleted) - { - Console.ReadLine(); - Console.ForegroundColor = ConsoleColor.Green; - Console.WriteLine($"Resuming conversation: {conversationId} from cursor: {lastCursor ?? "(beginning)"}"); - Console.ResetColor(); - } -} - -Console.ForegroundColor = ConsoleColor.Green; -Console.WriteLine("Conversation completed."); -Console.ResetColor(); - -await host.StopAsync(); diff --git a/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/07_ReliableStreaming/README.md b/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/07_ReliableStreaming/README.md deleted file mode 100644 index b93a66191cd..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/07_ReliableStreaming/README.md +++ /dev/null @@ -1,181 +0,0 @@ -# Reliable Streaming with Redis - -This sample demonstrates how to implement reliable streaming for durable agents using Redis Streams as a message broker. It enables clients to disconnect and reconnect to ongoing agent responses without losing messages, inspired by [OpenAI's background mode](https://platform.openai.com/docs/guides/background) for the Responses API. - -## Key Concepts Demonstrated - -- **Reliable message delivery**: Agent responses are persisted to Redis Streams, allowing clients to resume from any point -- **Real-time streaming**: Chunks are printed to stdout as they arrive (like `tail -f`) -- **Cursor-based resumption**: Each chunk includes an entry ID that can be used to resume the stream -- **Fire-and-forget agent invocation**: The agent runs in the background while the client streams from Redis - -## 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. - -### Additional Requirements: Redis - -This sample requires a Redis instance. Start a local Redis instance using Docker: - -```bash -docker run -d --name redis -p 6379:6379 redis:latest -``` - -To verify Redis is running: - -```bash -docker ps | grep redis -``` - -## Running the Sample - -With the environment setup, you can run the sample: - -```bash -cd dotnet/samples/04-hosting/DurableAgents/ConsoleApps/07_ReliableStreaming -dotnet run --framework net10.0 -``` - -The app will prompt you for a travel planning request: - -```text -=== Reliable Streaming Sample === -Enter a travel planning request (or 'exit' to quit): - -You: Plan a 7-day trip to Tokyo, Japan for next month. Include daily activities, restaurant recommendations, and tips for getting around. -``` - -The agent's response will stream to your console in real-time as chunks arrive from Redis: - -```text -Starting new conversation: @dafx-travelplanner@a1b2c3d4e5f67890abcdef1234567890 -Press [Enter] to interrupt the stream. - -TravelPlanner: # 7-Day Tokyo Adventure - -## Day 1: Arrival and Exploration -... -``` - -### Demonstrating Stream Interruption and Resumption - -This is the key feature of reliable streaming. Follow these steps to see it in action: - -1. **Start a stream**: Run the app and enter a travel planning request -2. **Note the conversation ID**: The conversation ID is displayed at the start of the stream (e.g., `Starting new conversation: @dafx-travelplanner@a1b2c3d4e5f67890abcdef1234567890`) -3. **Interrupt the stream**: While the agent is still generating text, press **`Enter`** to interrupt. The agent continues running in the background - your messages are being saved to Redis. -4. **Resume the stream**: Press **`Enter`** again to reconnect and resume the stream from the last cursor position. The app will automatically resume from where it left off. - -```text -Starting new conversation: @dafx-travelplanner@a1b2c3d4e5f67890abcdef1234567890 -Press [Enter] to interrupt the stream. - -TravelPlanner: # 7-Day Tokyo Adventure - -## Day 1: Arrival and Exploration -[Streaming content...] - -[Press Enter to interrupt] -Stream cancelled. Press [Enter] to reconnect and resume the stream from the last cursor. -Last cursor: 1734567890123-0 - -[Press Enter to resume] -Resuming conversation: @dafx-travelplanner@a1b2c3d4e5f67890abcdef1234567890 from cursor: 1734567890123-0 - -[Stream continues from where it left off...] -``` - -## Viewing Agent State - -You can view the state of the agent in the Durable Task Scheduler dashboard: - -1. Open your browser and navigate to `http://localhost:8082` -2. In the dashboard, you can see: - - **Agents**: View the state of the TravelPlanner agent, including conversation history and current state - - **Orchestrations**: View any orchestrations that may have been triggered by the agent - -The conversation ID displayed in the console output (shown as "Starting new conversation: {conversationId}") corresponds to the agent's conversation thread. You can use this to identify the agent in the dashboard and inspect: - -- The agent's conversation state -- Tool calls made by the agent (weather and events lookups) -- The streaming response state - -Note that while the console app streams responses from Redis, the agent state in DTS shows the underlying durable agent execution, including all tool calls and conversation context. - -## Architecture Overview - -```text -┌─────────────┐ stdin (prompt) ┌─────────────────────┐ -│ Client │ ─────────────────────► │ Console App │ -│ (stdin) │ │ (Program.cs) │ -└─────────────┘ └──────────────┬──────┘ - ▲ │ - │ stdout (chunks) Signal Entity - │ │ - │ ▼ - │ ┌─────────────────────┐ - │ │ AgentEntity │ - │ │ (Durable Entity) │ - │ └──────────┬──────────┘ - │ │ - │ IAgentResponseHandler - │ │ - │ ▼ - │ ┌─────────────────────┐ - │ │ RedisStreamResponse │ - │ │ Handler │ - │ └──────────┬──────────┘ - │ │ - │ XADD (write) - │ │ - │ ▼ - │ ┌─────────────────────┐ - └─────────── XREAD (poll) ────────── │ Redis Streams │ - │ (Durable Log) │ - └─────────────────────┘ -``` - -### Data Flow - -1. **Client sends prompt**: The console app reads the prompt from stdin and generates a new agent thread. - -2. **Agent invoked**: The durable agent is signaled to run the travel planner agent. This is fire-and-forget from the console app's perspective. - -3. **Responses captured**: As the agent generates responses, the `RedisStreamResponseHandler` (implementing `IAgentResponseHandler`) extracts the text from each `AgentRunResponseUpdate` and publishes it to a Redis Stream keyed by the agent session's conversation ID. - -4. **Client polls Redis**: The console app streams events by polling the Redis Stream and printing chunks to stdout as they arrive. - -5. **Resumption**: If the client interrupts the stream (e.g., by pressing Enter in the sample), it can resume from the last cursor position by providing the conversation ID and cursor to the call to resume the stream. - -## Message Delivery Guarantees - -This sample provides **at-least-once delivery** with the following characteristics: - -- **Durability**: Messages are persisted to Redis Streams with configurable TTL (default: 10 minutes). -- **Ordering**: Messages are delivered in order within a session. -- **Real-time**: Chunks are printed as soon as they arrive from Redis. - -### Important Considerations - -- **No exactly-once delivery**: If a client disconnects exactly when receiving a message, it may receive that message again upon resumption. Clients should handle duplicate messages idempotently. -- **TTL expiration**: Streams expire after the configured TTL. Clients cannot resume streams that have expired. -- **Redis guarantees**: Redis streams are backed by Redis persistence mechanisms (RDB/AOF). Ensure your Redis instance is configured for durability as needed. - -## Configuration - -| Environment Variable | Description | Default | -|---------------------|-------------|---------| -| `REDIS_CONNECTION_STRING` | Redis connection string | `localhost:6379` | -| `REDIS_STREAM_TTL_MINUTES` | How long streams are retained after last write | `10` | -| `AZURE_OPENAI_ENDPOINT` | Azure OpenAI endpoint URL | (required) | -| `AZURE_OPENAI_DEPLOYMENT_NAME` | Azure OpenAI deployment name | (required) | -| `AZURE_OPENAI_API_KEY` | API key (optional, uses Azure CLI auth if not set) | (optional) | - -## Cleanup - -To stop and remove the Redis Docker containers: - -```bash -docker stop redis -docker rm redis -``` diff --git a/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/07_ReliableStreaming/RedisStreamResponseHandler.cs b/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/07_ReliableStreaming/RedisStreamResponseHandler.cs deleted file mode 100644 index 3ba08a98b15..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/07_ReliableStreaming/RedisStreamResponseHandler.cs +++ /dev/null @@ -1,216 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Runtime.CompilerServices; -using Microsoft.Agents.AI; -using Microsoft.Agents.AI.DurableTask; -using StackExchange.Redis; - -namespace ReliableStreaming; - -/// -/// Represents a chunk of data read from a Redis stream. -/// -/// The Redis stream entry ID (can be used as a cursor for resumption). -/// The text content of the chunk, or null if this is a completion/error marker. -/// True if this chunk marks the end of the stream. -/// An error message if something went wrong, or null otherwise. -public readonly record struct StreamChunk(string EntryId, string? Text, bool IsDone, string? Error); - -/// -/// An implementation of that publishes agent response updates -/// to Redis Streams for reliable delivery. This enables clients to disconnect and reconnect -/// to ongoing agent responses without losing messages. -/// -/// -/// -/// Redis Streams provide a durable, append-only log that supports consumer groups and message -/// acknowledgment. This implementation uses auto-generated IDs (which are timestamp-based) -/// as sequence numbers, allowing clients to resume from any point in the stream. -/// -/// -/// Each agent session gets its own Redis Stream, keyed by session ID. The stream entries -/// contain text chunks extracted from objects. -/// -/// -public sealed class RedisStreamResponseHandler : IAgentResponseHandler -{ - private const int MaxEmptyReads = 300; // 5 minutes at 1 second intervals - private const int PollIntervalMs = 1000; - - private readonly IConnectionMultiplexer _redis; - private readonly TimeSpan _streamTtl; - - /// - /// Initializes a new instance of the class. - /// - /// The Redis connection multiplexer. - /// The time-to-live for stream entries. Streams will expire after this duration of inactivity. - public RedisStreamResponseHandler(IConnectionMultiplexer redis, TimeSpan streamTtl) - { - this._redis = redis; - this._streamTtl = streamTtl; - } - - /// - public async ValueTask OnStreamingResponseUpdateAsync( - IAsyncEnumerable messageStream, - CancellationToken cancellationToken) - { - // Get the current session ID from the DurableAgentContext - // This is set by the AgentEntity before invoking the response handler - DurableAgentContext context = DurableAgentContext.Current - ?? throw new InvalidOperationException("DurableAgentContext.Current is not set. This handler must be used within a durable agent context."); - - // Get conversation ID from the current session context, which is only available in the context of - // a durable agent execution. - string conversationId = context.CurrentSession.GetService().ToString(); - if (string.IsNullOrEmpty(conversationId)) - { - throw new InvalidOperationException("Unable to determine conversation ID from the current session."); - } - - string streamKey = GetStreamKey(conversationId); - - IDatabase db = this._redis.GetDatabase(); - int sequenceNumber = 0; - - await foreach (AgentResponseUpdate update in messageStream.WithCancellation(cancellationToken)) - { - // Extract just the text content - this avoids serialization round-trip issues - string text = update.Text; - - // Only publish non-empty text chunks - if (!string.IsNullOrEmpty(text)) - { - // Create the stream entry with the text and metadata - NameValueEntry[] entries = - [ - new NameValueEntry("text", text), - new NameValueEntry("sequence", sequenceNumber++), - new NameValueEntry("timestamp", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()), - ]; - - // Add to the Redis Stream with auto-generated ID (timestamp-based) - await db.StreamAddAsync(streamKey, entries); - - // Refresh the TTL on each write to keep the stream alive during active streaming - await db.KeyExpireAsync(streamKey, this._streamTtl); - } - } - - // Add a sentinel entry to mark the end of the stream - NameValueEntry[] endEntries = - [ - new NameValueEntry("text", ""), - new NameValueEntry("sequence", sequenceNumber), - new NameValueEntry("timestamp", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()), - new NameValueEntry("done", "true"), - ]; - await db.StreamAddAsync(streamKey, endEntries); - - // Set final TTL - the stream will be cleaned up after this duration - await db.KeyExpireAsync(streamKey, this._streamTtl); - } - - /// - public ValueTask OnAgentResponseAsync(AgentResponse message, CancellationToken cancellationToken) - { - // This handler is optimized for streaming responses. - // For non-streaming responses, we don't need to store in Redis since - // the response is returned directly to the caller. - return ValueTask.CompletedTask; - } - - /// - /// Reads chunks from a Redis stream for the given session, yielding them as they become available. - /// - /// The conversation ID to read from. - /// Optional cursor to resume from. If null, reads from the beginning. - /// Cancellation token. - /// An async enumerable of stream chunks. - public async IAsyncEnumerable ReadStreamAsync( - string conversationId, - string? cursor, - [EnumeratorCancellation] CancellationToken cancellationToken) - { - string streamKey = GetStreamKey(conversationId); - - IDatabase db = this._redis.GetDatabase(); - string startId = string.IsNullOrEmpty(cursor) ? "0-0" : cursor; - - int emptyReadCount = 0; - bool hasSeenData = false; - - while (!cancellationToken.IsCancellationRequested) - { - StreamEntry[]? entries = null; - string? errorMessage = null; - - try - { - entries = await db.StreamReadAsync(streamKey, startId, count: 100); - } - catch (Exception ex) - { - errorMessage = ex.Message; - } - - if (errorMessage != null) - { - yield return new StreamChunk(startId, null, false, errorMessage); - yield break; - } - - // entries is guaranteed to be non-null if errorMessage is null - if (entries!.Length == 0) - { - if (!hasSeenData) - { - emptyReadCount++; - if (emptyReadCount >= MaxEmptyReads) - { - yield return new StreamChunk( - startId, - null, - false, - $"Stream not found or timed out after {MaxEmptyReads * PollIntervalMs / 1000} seconds"); - yield break; - } - } - - await Task.Delay(PollIntervalMs, cancellationToken); - continue; - } - - hasSeenData = true; - - foreach (StreamEntry entry in entries) - { - startId = entry.Id.ToString(); - string? text = entry["text"]; - string? done = entry["done"]; - - if (done == "true") - { - yield return new StreamChunk(startId, null, true, null); - yield break; - } - - if (!string.IsNullOrEmpty(text)) - { - yield return new StreamChunk(startId, text, false, null); - } - } - } - - // If we exited the loop due to cancellation, throw to signal the caller - cancellationToken.ThrowIfCancellationRequested(); - } - - /// - /// Gets the Redis Stream key for a given conversation ID. - /// - /// The conversation ID. - /// The Redis Stream key. - internal static string GetStreamKey(string conversationId) => $"agent-stream:{conversationId}"; -} diff --git a/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/README.md b/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/README.md index 43f988d3273..1871e96947a 100644 --- a/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/README.md +++ b/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/README.md @@ -1,109 +1,3 @@ -# Console App Samples +# Durable Agent Console Samples Have Moved -This directory contains samples for console app hosting of durable agents. These samples use standard I/O (stdin/stdout) for interaction, making them both interactive and scriptable. - -- **[01_SingleAgent](01_SingleAgent)**: A sample that demonstrates how to host a single conversational agent in a console app and interact with it via stdin/stdout. -- **[02_AgentOrchestration_Chaining](02_AgentOrchestration_Chaining)**: A sample that demonstrates how to host a single conversational agent in a console app and invoke it using a durable orchestration. -- **[03_AgentOrchestration_Concurrency](03_AgentOrchestration_Concurrency)**: A sample that demonstrates how to host multiple agents in a console 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 a console 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 interactive approval prompts. -- **[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_ReliableStreaming](07_ReliableStreaming)**: A sample that demonstrates how to implement reliable streaming for durable agents using Redis Streams, enabling clients to disconnect and reconnect without losing messages. - -## 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 10.0 SDK or later](https://dotnet.microsoft.com/download/dotnet) -- [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-5.4-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 -- [Redis](https://redis.io/) (for sample 07 only) - can be run locally using Docker - -### 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 console 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_API_KEY` environment variable. - -Bash (Linux/macOS/WSL): - -```bash -export AZURE_OPENAI_API_KEY="your-api-key" -``` - -PowerShell: - -```powershell -$env:AZURE_OPENAI_API_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`. - -### Environment Configuration - -Each sample reads configuration from environment variables. You'll need to set the following environment variables: - -```bash -export AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" -export AZURE_OPENAI_DEPLOYMENT_NAME="your-deployment-name" -``` - -### Running the Console Apps - -Navigate to the sample directory and run the console app: - -```bash -cd dotnet/samples/04-hosting/DurableAgents/ConsoleApps/01_SingleAgent -dotnet run --framework net10.0 -``` - -> [!NOTE] -> The `--framework` option is required to specify the target framework for the console app because the samples are designed to support multiple target frameworks. If you are using a different target framework, you can specify it with the `--framework` option. - -The app will prompt you for input via stdin. - -### Viewing the sample output - -The console app output is displayed directly in the terminal where you ran `dotnet run`. Agent responses are printed to stdout with subtle color coding for better readability. - -You can also see the state of agents and orchestrations in the Durable Task Scheduler dashboard at `http://localhost:8082`. +These samples are now maintained in the [Durable Agent Framework extension repository](https://github.com/microsoft/agent-framework-durable-extension/tree/main/dotnet/samples/DurableAgents/ConsoleApps). diff --git a/dotnet/samples/04-hosting/DurableAgents/Directory.Build.props b/dotnet/samples/04-hosting/DurableAgents/Directory.Build.props deleted file mode 100644 index 63c25dd5f0f..00000000000 --- a/dotnet/samples/04-hosting/DurableAgents/Directory.Build.props +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/dotnet/samples/04-hosting/DurableAgents/README.md b/dotnet/samples/04-hosting/DurableAgents/README.md new file mode 100644 index 00000000000..a6ef24ff6cf --- /dev/null +++ b/dotnet/samples/04-hosting/DurableAgents/README.md @@ -0,0 +1,3 @@ +# Durable Agent Samples Have Moved + +The .NET Durable Agent samples are now maintained in the [Durable Agent Framework extension repository](https://github.com/microsoft/agent-framework-durable-extension/tree/main/dotnet/samples/DurableAgents). diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/01_SequentialWorkflow.csproj b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/01_SequentialWorkflow.csproj deleted file mode 100644 index 0c0e4f7fe0f..00000000000 --- a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/01_SequentialWorkflow.csproj +++ /dev/null @@ -1,42 +0,0 @@ - - - net10.0 - v4 - Exe - enable - enable - - SingleAgent - SingleAgent - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/OrderCancelExecutors.cs b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/OrderCancelExecutors.cs deleted file mode 100644 index 6d86bfe7577..00000000000 --- a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/OrderCancelExecutors.cs +++ /dev/null @@ -1,215 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Agents.AI.Workflows; - -namespace SequentialWorkflow; - -/// -/// Looks up an order by its ID and return an Order object. -/// -internal sealed class OrderLookup() : Executor("OrderLookup") -{ - public override async ValueTask HandleAsync( - string message, - IWorkflowContext context, - CancellationToken cancellationToken = default) - { - Console.WriteLine(); - Console.ForegroundColor = ConsoleColor.Magenta; - Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐"); - Console.WriteLine($"│ [Activity] OrderLookup: Starting lookup for order '{message}'"); - Console.ResetColor(); - - // Simulate database lookup with delay - await Task.Delay(TimeSpan.FromMicroseconds(100), cancellationToken); - - Order order = new( - Id: message, - OrderDate: DateTime.UtcNow.AddDays(-1), - IsCancelled: false, - Customer: new Customer(Name: "Jerry", Email: "jerry@example.com")); - - Console.ForegroundColor = ConsoleColor.Magenta; - Console.WriteLine($"│ [Activity] OrderLookup: Found order '{message}' for customer '{order.Customer.Name}'"); - Console.WriteLine("└─────────────────────────────────────────────────────────────────┘"); - Console.ResetColor(); - - return order; - } -} - -/// -/// Cancels an order. -/// -internal sealed class OrderCancel() : Executor("OrderCancel") -{ - public override async ValueTask HandleAsync( - Order message, - IWorkflowContext context, - CancellationToken cancellationToken = default) - { - Console.WriteLine(); - Console.ForegroundColor = ConsoleColor.Yellow; - Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐"); - Console.WriteLine($"│ [Activity] OrderCancel: Starting cancellation for order '{message.Id}'"); - Console.ResetColor(); - - // Simulate a slow cancellation process (e.g., calling external payment system) - for (int i = 1; i <= 3; i++) - { - await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken); - Console.ForegroundColor = ConsoleColor.DarkYellow; - Console.WriteLine("│ [Activity] OrderCancel: Processing..."); - Console.ResetColor(); - } - - Order cancelledOrder = message with { IsCancelled = true }; - - Console.ForegroundColor = ConsoleColor.Yellow; - Console.WriteLine($"│ [Activity] OrderCancel: ✓ Order '{cancelledOrder.Id}' has been cancelled"); - Console.WriteLine("└─────────────────────────────────────────────────────────────────┘"); - Console.ResetColor(); - - return cancelledOrder; - } -} - -/// -/// Sends a cancellation confirmation email to the customer. -/// -internal sealed class SendEmail() : Executor("SendEmail") -{ - public override ValueTask HandleAsync( - Order message, - IWorkflowContext context, - CancellationToken cancellationToken = default) - { - Console.WriteLine(); - Console.ForegroundColor = ConsoleColor.Cyan; - Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐"); - Console.WriteLine($"│ [Activity] SendEmail: Sending email to '{message.Customer.Email}'..."); - Console.ResetColor(); - - string result = $"Cancellation email sent for order {message.Id} to {message.Customer.Email}."; - - Console.ForegroundColor = ConsoleColor.Cyan; - Console.WriteLine("│ [Activity] SendEmail: ✓ Email sent successfully!"); - Console.WriteLine("└─────────────────────────────────────────────────────────────────┘"); - Console.ResetColor(); - - return ValueTask.FromResult(result); - } -} - -internal sealed record Order(string Id, DateTime OrderDate, bool IsCancelled, Customer Customer); - -internal sealed record Customer(string Name, string Email); - -/// -/// Represents a batch cancellation request with multiple order IDs and a reason. -/// This demonstrates using a complex typed object as workflow input. -/// -#pragma warning disable CA1812 // Instantiated via JSON deserialization at runtime -internal sealed record BatchCancelRequest(string[] OrderIds, string Reason, bool NotifyCustomers); -#pragma warning restore CA1812 - -/// -/// Represents the result of processing a batch cancellation. -/// -internal sealed record BatchCancelResult(int TotalOrders, int CancelledCount, string Reason); - -/// -/// Generates a status report for an order. -/// -internal sealed class StatusReport() : Executor("StatusReport") -{ - public override ValueTask HandleAsync( - Order message, - IWorkflowContext context, - CancellationToken cancellationToken = default) - { - Console.WriteLine(); - Console.ForegroundColor = ConsoleColor.Green; - Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐"); - Console.WriteLine($"│ [Activity] StatusReport: Generating report for order '{message.Id}'"); - Console.ResetColor(); - - string status = message.IsCancelled ? "Cancelled" : "Active"; - string result = $"Order {message.Id} for {message.Customer.Name}: Status={status}, Date={message.OrderDate:yyyy-MM-dd}"; - - Console.ForegroundColor = ConsoleColor.Green; - Console.WriteLine($"│ [Activity] StatusReport: ✓ {result}"); - Console.WriteLine("└─────────────────────────────────────────────────────────────────┘"); - Console.ResetColor(); - - return ValueTask.FromResult(result); - } -} - -/// -/// Processes a batch cancellation request. Accepts a complex object -/// as input, demonstrating how workflows can receive structured JSON input. -/// -internal sealed class BatchCancelProcessor() : Executor("BatchCancelProcessor") -{ - public override async ValueTask HandleAsync( - BatchCancelRequest message, - IWorkflowContext context, - CancellationToken cancellationToken = default) - { - Console.WriteLine(); - Console.ForegroundColor = ConsoleColor.Yellow; - Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐"); - Console.WriteLine($"│ [Activity] BatchCancelProcessor: Processing {message.OrderIds.Length} orders"); - Console.WriteLine($"│ [Activity] BatchCancelProcessor: Reason: {message.Reason}"); - Console.WriteLine($"│ [Activity] BatchCancelProcessor: Notify customers: {message.NotifyCustomers}"); - Console.ResetColor(); - - // Simulate processing each order - int cancelledCount = 0; - foreach (string orderId in message.OrderIds) - { - await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken); - cancelledCount++; - Console.ForegroundColor = ConsoleColor.DarkYellow; - Console.WriteLine($"│ [Activity] BatchCancelProcessor: ✓ Cancelled order '{orderId}'"); - Console.ResetColor(); - } - - BatchCancelResult result = new(message.OrderIds.Length, cancelledCount, message.Reason); - - Console.ForegroundColor = ConsoleColor.Yellow; - Console.WriteLine($"│ [Activity] BatchCancelProcessor: ✓ Batch complete: {cancelledCount}/{message.OrderIds.Length} cancelled"); - Console.WriteLine("└─────────────────────────────────────────────────────────────────┘"); - Console.ResetColor(); - - return result; - } -} - -/// -/// Generates a summary of the batch cancellation. -/// -internal sealed class BatchCancelSummary() : Executor("BatchCancelSummary") -{ - public override ValueTask HandleAsync( - BatchCancelResult message, - IWorkflowContext context, - CancellationToken cancellationToken = default) - { - Console.WriteLine(); - Console.ForegroundColor = ConsoleColor.Cyan; - Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐"); - Console.WriteLine("│ [Activity] BatchCancelSummary: Generating summary"); - Console.ResetColor(); - - string result = $"Batch cancellation complete: {message.CancelledCount}/{message.TotalOrders} orders cancelled. Reason: {message.Reason}"; - - Console.ForegroundColor = ConsoleColor.Cyan; - Console.WriteLine($"│ [Activity] BatchCancelSummary: ✓ {result}"); - Console.WriteLine("└─────────────────────────────────────────────────────────────────┘"); - Console.ResetColor(); - - return ValueTask.FromResult(result); - } -} diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/Program.cs b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/Program.cs deleted file mode 100644 index 20da58d1a17..00000000000 --- a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/Program.cs +++ /dev/null @@ -1,52 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -// This sample demonstrates three workflows that share executors. -// The CancelOrder workflow cancels an order and notifies the customer. -// The OrderStatus workflow looks up an order and generates a status report. -// The BatchCancelOrders workflow accepts a complex JSON input to cancel multiple orders. -// Both CancelOrder and OrderStatus reuse the same OrderLookup executor, demonstrating executor sharing. - -using Microsoft.Agents.AI.Hosting.AzureFunctions; -using Microsoft.Agents.AI.Workflows; -using Microsoft.Azure.Functions.Worker.Builder; -using Microsoft.Extensions.Hosting; -using SequentialWorkflow; - -// Define executors for all workflows -OrderLookup orderLookup = new(); -OrderCancel orderCancel = new(); -SendEmail sendEmail = new(); -StatusReport statusReport = new(); -BatchCancelProcessor batchCancelProcessor = new(); -BatchCancelSummary batchCancelSummary = new(); - -// Build the CancelOrder workflow: OrderLookup -> OrderCancel -> SendEmail -Workflow cancelOrder = new WorkflowBuilder(orderLookup) - .WithName("CancelOrder") - .WithDescription("Cancel an order and notify the customer") - .AddEdge(orderLookup, orderCancel) - .AddEdge(orderCancel, sendEmail) - .Build(); - -// Build the OrderStatus workflow: OrderLookup -> StatusReport -// This workflow shares the OrderLookup executor with the CancelOrder workflow. -Workflow orderStatus = new WorkflowBuilder(orderLookup) - .WithName("OrderStatus") - .WithDescription("Look up an order and generate a status report") - .AddEdge(orderLookup, statusReport) - .Build(); - -// Build the BatchCancelOrders workflow: BatchCancelProcessor -> BatchCancelSummary -// This workflow demonstrates using a complex JSON object as the workflow input. -Workflow batchCancelOrders = new WorkflowBuilder(batchCancelProcessor) - .WithName("BatchCancelOrders") - .WithDescription("Cancel multiple orders in a batch using a complex JSON input") - .AddEdge(batchCancelProcessor, batchCancelSummary) - .Build(); - -using IHost app = FunctionsApplication - .CreateBuilder(args) - .ConfigureFunctionsWebApplication() - .ConfigureDurableWorkflows(workflows => workflows.AddWorkflows(cancelOrder, orderStatus, batchCancelOrders)) - .Build(); -app.Run(); diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/README.md b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/README.md deleted file mode 100644 index 4f455b3dece..00000000000 --- a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/README.md +++ /dev/null @@ -1,147 +0,0 @@ -# Sequential Workflow Sample - -This sample demonstrates how to use the Microsoft Agent Framework to create an Azure Functions app that hosts durable workflows with sequential executor chains. It showcases two workflows that share a common executor, demonstrating executor reuse across workflows. - -## Key Concepts Demonstrated - -- Defining workflows with sequential executor chains using `WorkflowBuilder` -- Sharing executors across multiple workflows (the `OrderLookup` executor is used by both workflows) -- Registering workflows with the Function app using `ConfigureDurableWorkflows` -- Durable orchestration ensuring workflows survive process restarts and failures -- Starting workflows via HTTP requests -- Viewing workflow execution history and status in the Durable Task Scheduler (DTS) dashboard - -## Workflows - -This sample defines two workflows: - -1. **CancelOrder**: `OrderLookup` → `OrderCancel` → `SendEmail` — Looks up an order, cancels it, and sends a confirmation email. -2. **OrderStatus**: `OrderLookup` → `StatusReport` — Looks up an order and generates a status report. - -Both workflows share the `OrderLookup` executor, which is registered only once by the framework. - -## 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 HTTP requests to the workflow endpoints. - -You can use the `demo.http` file to trigger the workflows, or a command line tool like `curl` as shown below: - -### Cancel an Order - -Bash (Linux/macOS/WSL): - -```bash -curl -X POST http://localhost:7071/api/workflows/CancelOrder/run \ - -H "Content-Type: text/plain" \ - -d "12345" -``` - -PowerShell: - -```powershell -Invoke-RestMethod -Method Post ` - -Uri http://localhost:7071/api/workflows/CancelOrder/run ` - -ContentType text/plain ` - -Body "12345" -``` - -The response will confirm the workflow orchestration has started: - -```text -Workflow orchestration started for CancelOrder. Orchestration runId: abc123def456 -``` - -> **Tip:** You can provide a custom run ID by appending a `runId` query parameter: -> -> ```bash -> curl -X POST "http://localhost:7071/api/workflows/CancelOrder/run?runId=my-order-123" \ -> -H "Content-Type: text/plain" \ -> -d "12345" -> ``` -> -> If not provided, a unique run ID is auto-generated. - -### Wait for the Workflow Result - -By default, the HTTP endpoint returns `202 Accepted` immediately with the run ID. If you want to wait for the workflow to complete and get the result in the response, add the `x-ms-wait-for-response: true` header: - -Bash (Linux/macOS/WSL): - -```bash -curl -X POST http://localhost:7071/api/workflows/CancelOrder/run \ - -H "Content-Type: text/plain" \ - -H "x-ms-wait-for-response: true" \ - -d "12345" -``` - -PowerShell: - -```powershell -Invoke-RestMethod -Method Post ` - -Uri http://localhost:7071/api/workflows/CancelOrder/run ` - -ContentType text/plain ` - -Headers @{ "x-ms-wait-for-response" = "true" } ` - -Body "12345" -``` - -The response will contain the workflow result as plain text (200 OK): - -```text -Cancellation email sent for order 12345 to jerry@example.com. -``` - -To get the result as JSON, also include the `Accept: application/json` header: - -```bash -curl -X POST http://localhost:7071/api/workflows/CancelOrder/run \ - -H "Content-Type: text/plain" \ - -H "x-ms-wait-for-response: true" \ - -H "Accept: application/json" \ - -d "12345" -``` - -```json -{ - "runId": "abc123def456", - "workflowStatus": "Completed", - "result": "Cancellation email sent for order 12345 to jerry@example.com." -} -``` - -In the function app logs, you will see the sequential execution of each executor: - -```text -│ [Activity] OrderLookup: Starting lookup for order '12345' -│ [Activity] OrderLookup: Found order '12345' for customer 'Jerry' -│ [Activity] OrderCancel: Starting cancellation for order '12345' -│ [Activity] OrderCancel: ✓ Order '12345' has been cancelled -│ [Activity] SendEmail: Sending email to 'jerry@example.com'... -│ [Activity] SendEmail: ✓ Email sent successfully! -``` - -### Get Order Status - -```bash -curl -X POST http://localhost:7071/api/workflows/OrderStatus/run \ - -H "Content-Type: text/plain" \ - -d "12345" -``` - -The `OrderStatus` workflow reuses the same `OrderLookup` executor and then generates a status report: - -```text -│ [Activity] OrderLookup: Starting lookup for order '12345' -│ [Activity] OrderLookup: Found order '12345' for customer 'Jerry' -│ [Activity] StatusReport: Generating report for order '12345' -│ [Activity] StatusReport: ✓ Order 12345 for Jerry: Status=Active, Date=2025-01-01 -``` - -### Viewing Workflows in the DTS Dashboard - -After running a workflow, you can navigate to the Durable Task Scheduler (DTS) dashboard to visualize the completed orchestration, inspect inputs/outputs for each step, and view execution history. - -If you are using the DTS emulator, the dashboard is available at `http://localhost:8082`. diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/demo.http b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/demo.http deleted file mode 100644 index fb9793f449e..00000000000 --- a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/demo.http +++ /dev/null @@ -1,48 +0,0 @@ -# Default endpoint address for local testing -@authority=http://localhost:7071 - -### Cancel an order -POST {{authority}}/api/workflows/CancelOrder/run -Content-Type: text/plain - -12345 - -### Cancel an order and wait for the result -POST {{authority}}/api/workflows/CancelOrder/run -Content-Type: text/plain -x-ms-wait-for-response: true - -12345 - -### Cancel an order and wait for the result (JSON response) -POST {{authority}}/api/workflows/CancelOrder/run -Content-Type: text/plain -Accept: application/json -x-ms-wait-for-response: true - -12345 - -### Cancel an order with a custom run ID -POST {{authority}}/api/workflows/CancelOrder/run?runId=my-custom-id-123 -Content-Type: text/plain - -99999 - -### Get order status (shares OrderLookup executor with CancelOrder) -POST {{authority}}/api/workflows/OrderStatus/run -Content-Type: text/plain - -12345 - -### Get order status and wait for the result -POST {{authority}}/api/workflows/OrderStatus/run -Content-Type: text/plain -x-ms-wait-for-response: true - -12345 - -### Batch cancel orders with a complex JSON input -POST {{authority}}/api/workflows/BatchCancelOrders/run -Content-Type: application/json - -{"orderIds": ["1001", "1002", "1003"], "reason": "Customer requested cancellation", "notifyCustomers": true} diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/host.json b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/host.json deleted file mode 100644 index 9384a0a583d..00000000000 --- a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/host.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "version": "2.0", - "logging": { - "logLevel": { - "Microsoft.Agents.AI.DurableTask": "Information", - "Microsoft.Agents.AI.Hosting.AzureFunctions": "Information", - "DurableTask": "Information", - "Microsoft.DurableTask": "Information" - } - }, - "extensions": { - "durableTask": { - "hubName": "default", - "storageProvider": { - "type": "AzureManaged", - "connectionStringName": "DURABLE_TASK_SCHEDULER_CONNECTION_STRING" - } - } - } -} diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/02_ConcurrentWorkflow/02_ConcurrentWorkflow.csproj b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/02_ConcurrentWorkflow/02_ConcurrentWorkflow.csproj deleted file mode 100644 index 0c0e4f7fe0f..00000000000 --- a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/02_ConcurrentWorkflow/02_ConcurrentWorkflow.csproj +++ /dev/null @@ -1,42 +0,0 @@ - - - net10.0 - v4 - Exe - enable - enable - - SingleAgent - SingleAgent - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/02_ConcurrentWorkflow/ExpertExecutors.cs b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/02_ConcurrentWorkflow/ExpertExecutors.cs deleted file mode 100644 index 40674126f64..00000000000 --- a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/02_ConcurrentWorkflow/ExpertExecutors.cs +++ /dev/null @@ -1,73 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Agents.AI.Workflows; - -namespace WorkflowConcurrency; - -/// -/// Parses and validates the incoming question before sending to AI agents. -/// -internal sealed class ParseQuestionExecutor() : Executor("ParseQuestion") -{ - public override ValueTask HandleAsync( - string message, - IWorkflowContext context, - CancellationToken cancellationToken = default) - { - Console.WriteLine(); - Console.ForegroundColor = ConsoleColor.Magenta; - Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐"); - Console.WriteLine("│ [ParseQuestion] Preparing question for AI agents..."); - - string formattedQuestion = message.Trim(); - if (!formattedQuestion.EndsWith('?')) - { - formattedQuestion += "?"; - } - - Console.WriteLine($"│ [ParseQuestion] Question: \"{formattedQuestion}\""); - Console.WriteLine("│ [ParseQuestion] → Sending to Physicist and Chemist in PARALLEL..."); - Console.WriteLine("└─────────────────────────────────────────────────────────────────┘"); - Console.ResetColor(); - - return ValueTask.FromResult(formattedQuestion); - } -} - -/// -/// Aggregates responses from all AI agents into a comprehensive answer. -/// This is the Fan-in point where parallel results are collected. -/// -internal sealed class AggregatorExecutor() : Executor("Aggregator") -{ - public override ValueTask HandleAsync( - string[] message, - IWorkflowContext context, - CancellationToken cancellationToken = default) - { - Console.WriteLine(); - Console.ForegroundColor = ConsoleColor.Cyan; - Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐"); - Console.WriteLine($"│ [Aggregator] 📋 Received {message.Length} AI agent responses"); - Console.WriteLine("│ [Aggregator] Combining into comprehensive answer..."); - Console.WriteLine("│ [Aggregator] ✓ Aggregation complete!"); - Console.WriteLine("└─────────────────────────────────────────────────────────────────┘"); - Console.ResetColor(); - - string aggregatedResult = "═══════════════════════════════════════════════════════════════\n" + - " AI EXPERT PANEL RESPONSES\n" + - "═══════════════════════════════════════════════════════════════\n\n"; - - for (int i = 0; i < message.Length; i++) - { - string expertLabel = i == 0 ? "⚛️ PHYSICIST" : "🧪 CHEMIST"; - aggregatedResult += $"{expertLabel}:\n{message[i]}\n\n"; - } - - aggregatedResult += "═══════════════════════════════════════════════════════════════\n" + - $"Summary: Received perspectives from {message.Length} AI experts.\n" + - "═══════════════════════════════════════════════════════════════"; - - return ValueTask.FromResult(aggregatedResult); - } -} diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/02_ConcurrentWorkflow/Program.cs b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/02_ConcurrentWorkflow/Program.cs deleted file mode 100644 index 6532009d4b8..00000000000 --- a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/02_ConcurrentWorkflow/Program.cs +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -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.Agents.AI.Workflows; -using Microsoft.Azure.Functions.Worker.Builder; -using Microsoft.Extensions.Hosting; -using OpenAI.Chat; -using WorkflowConcurrency; - -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."); -string? azureOpenAiKey = Environment.GetEnvironmentVariable("AZURE_OPENAI_KEY"); - -// Create Azure OpenAI client -AzureOpenAIClient openAiClient = !string.IsNullOrEmpty(azureOpenAiKey) - ? new AzureOpenAIClient(new Uri(endpoint), new AzureKeyCredential(azureOpenAiKey)) - : new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()); -ChatClient chatClient = openAiClient.GetChatClient(deploymentName); - -// Define the 4 executors for the workflow -ParseQuestionExecutor parseQuestion = new(); -AIAgent physicist = chatClient.AsAIAgent("You are a physics expert. Be concise (2-3 sentences).", "Physicist"); -AIAgent chemist = chatClient.AsAIAgent("You are a chemistry expert. Be concise (2-3 sentences).", "Chemist"); -AggregatorExecutor aggregator = new(); - -// Build workflow: ParseQuestion -> [Physicist, Chemist] (parallel) -> Aggregator -Workflow workflow = new WorkflowBuilder(parseQuestion) - .WithName("ExpertReview") - .AddFanOutEdge(parseQuestion, [physicist, chemist]) - .AddFanInBarrierEdge([physicist, chemist], aggregator) - .Build(); - -using IHost app = FunctionsApplication - .CreateBuilder(args) - .ConfigureFunctionsWebApplication() - .ConfigureDurableWorkflows(workflows => workflows.AddWorkflows(workflow)) - .Build(); -app.Run(); diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/02_ConcurrentWorkflow/README.md b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/02_ConcurrentWorkflow/README.md deleted file mode 100644 index 73230ff0482..00000000000 --- a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/02_ConcurrentWorkflow/README.md +++ /dev/null @@ -1,90 +0,0 @@ -# Concurrent Workflow Sample - -This sample demonstrates how to use the Microsoft Agent Framework to create an Azure Functions app that orchestrates concurrent execution of multiple AI agents using the fan-out/fan-in pattern within a durable workflow. - -## Key Concepts Demonstrated - -- Defining workflows with fan-out/fan-in edges for parallel execution using `WorkflowBuilder` -- Mixing custom executors with AI agents in a single workflow -- Concurrent execution of multiple AI agents (physics and chemistry experts) -- Response aggregation from parallel branches into a unified result -- Durable orchestration with automatic checkpointing and resumption from failures -- Viewing workflow execution history and status in the Durable Task Scheduler (DTS) dashboard - -## Workflow - -This sample defines a single workflow: - -**ExpertReview**: `ParseQuestion` → [`Physicist`, `Chemist`] (parallel) → `Aggregator` - -1. **ParseQuestion** — A custom executor that validates and formats the incoming question. -2. **Physicist** and **Chemist** — AI agents that run concurrently, each providing an expert perspective. -3. **Aggregator** — A custom executor that combines the parallel responses into a comprehensive answer. - -## 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. - -This sample requires Azure OpenAI. Set the following environment variables: - -- `AZURE_OPENAI_ENDPOINT` — Your Azure OpenAI endpoint URL. -- `AZURE_OPENAI_DEPLOYMENT` — The name of your chat model deployment. -- `AZURE_OPENAI_KEY` (optional) — Your Azure OpenAI API key. If not set, Azure CLI credentials are used. - -## Running the Sample - -With the environment setup and function app running, you can test the sample by sending an HTTP request with a science question to the workflow endpoint. - -You can use the `demo.http` file to trigger the workflow, or a command line tool like `curl` as shown below: - -Bash (Linux/macOS/WSL): - -```bash -curl -X POST http://localhost:7071/api/workflows/ExpertReview/run \ - -H "Content-Type: text/plain" \ - -d "What is temperature?" -``` - -PowerShell: - -```powershell -Invoke-RestMethod -Method Post ` - -Uri http://localhost:7071/api/workflows/ExpertReview/run ` - -ContentType text/plain ` - -Body "What is temperature?" -``` - -The response will confirm the workflow orchestration has started: - -```text -Workflow orchestration started for ExpertReview. Orchestration runId: abc123def456 -``` - -> **Tip:** You can provide a custom run ID by appending a `runId` query parameter: -> -> ```bash -> curl -X POST "http://localhost:7071/api/workflows/ExpertReview/run?runId=my-review-123" \ -> -H "Content-Type: text/plain" \ -> -d "What is temperature?" -> ``` -> -> If not provided, a unique run ID is auto-generated. - -In the function app logs, you will see the fan-out/fan-in execution pattern: - -```text -│ [ParseQuestion] Preparing question for AI agents... -│ [ParseQuestion] Question: "What is temperature?" -│ [ParseQuestion] → Sending to Physicist and Chemist in PARALLEL... -│ [Aggregator] 📋 Received 2 AI agent responses -│ [Aggregator] Combining into comprehensive answer... -│ [Aggregator] ✓ Aggregation complete! -``` - -The Physicist and Chemist AI agents execute concurrently, and the Aggregator combines their responses into a formatted expert panel result. - -### Viewing Workflows in the DTS Dashboard - -After running a workflow, you can navigate to the Durable Task Scheduler (DTS) dashboard to visualize the completed orchestration, inspect inputs/outputs for each step, and view execution history. - -If you are using the DTS emulator, the dashboard is available at `http://localhost:8082`. diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/02_ConcurrentWorkflow/demo.http b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/02_ConcurrentWorkflow/demo.http deleted file mode 100644 index 1a9e5631264..00000000000 --- a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/02_ConcurrentWorkflow/demo.http +++ /dev/null @@ -1,14 +0,0 @@ -# Default endpoint address for local testing -@authority=http://localhost:7071 - -### Prompt the agent -POST {{authority}}/api/workflows/ExpertReview/run -Content-Type: text/plain - -What is temperature? - -### Start with a custom run ID -POST {{authority}}/api/workflows/ExpertReview/run?runId=my-review-123 -Content-Type: text/plain - -What is gravity? diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/02_ConcurrentWorkflow/host.json b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/02_ConcurrentWorkflow/host.json deleted file mode 100644 index 9384a0a583d..00000000000 --- a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/02_ConcurrentWorkflow/host.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "version": "2.0", - "logging": { - "logLevel": { - "Microsoft.Agents.AI.DurableTask": "Information", - "Microsoft.Agents.AI.Hosting.AzureFunctions": "Information", - "DurableTask": "Information", - "Microsoft.DurableTask": "Information" - } - }, - "extensions": { - "durableTask": { - "hubName": "default", - "storageProvider": { - "type": "AzureManaged", - "connectionStringName": "DURABLE_TASK_SCHEDULER_CONNECTION_STRING" - } - } - } -} diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/03_WorkflowHITL/03_WorkflowHITL.csproj b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/03_WorkflowHITL/03_WorkflowHITL.csproj deleted file mode 100644 index c569deacd0e..00000000000 --- a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/03_WorkflowHITL/03_WorkflowHITL.csproj +++ /dev/null @@ -1,43 +0,0 @@ - - - net10.0 - v4 - Exe - enable - enable - - WorkflowHITLFunctions - WorkflowHITLFunctions - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/03_WorkflowHITL/Executors.cs b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/03_WorkflowHITL/Executors.cs deleted file mode 100644 index c299ee2cd5f..00000000000 --- a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/03_WorkflowHITL/Executors.cs +++ /dev/null @@ -1,63 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Agents.AI.Workflows; - -namespace WorkflowHITLFunctions; - -/// Expense approval request passed to the RequestPort. -public record ApprovalRequest(string ExpenseId, decimal Amount, string EmployeeName); - -/// Approval response received from the RequestPort. -public record ApprovalResponse(bool Approved, string? Comments); - -/// Looks up expense details and creates an approval request. -internal sealed class CreateApprovalRequest() : Executor("RetrieveRequest") -{ - public override ValueTask HandleAsync( - string message, - IWorkflowContext context, - CancellationToken cancellationToken = default) - { - // In a real scenario, this would look up expense details from a database - return new ValueTask(new ApprovalRequest(message, 1500.00m, "Jerry")); - } -} - -/// Prepares the approval request for finance review after manager approval. -internal sealed class PrepareFinanceReview() : Executor("PrepareFinanceReview") -{ - public override ValueTask HandleAsync( - ApprovalResponse message, - IWorkflowContext context, - CancellationToken cancellationToken = default) - { - if (!message.Approved) - { - throw new InvalidOperationException("Cannot proceed to finance review — manager denied the expense."); - } - - // In a real scenario, this would retrieve the original expense details - return new ValueTask(new ApprovalRequest("EXP-2025-001", 1500.00m, "Jerry")); - } -} - -/// Processes the expense reimbursement based on the parallel approval responses. -internal sealed class ExpenseReimburse() : Executor("Reimburse") -{ - public override async ValueTask HandleAsync( - ApprovalResponse[] message, - IWorkflowContext context, - CancellationToken cancellationToken = default) - { - // Check that all parallel approvals passed - ApprovalResponse? denied = Array.Find(message, r => !r.Approved); - if (denied is not null) - { - return $"Expense reimbursement denied. Comments: {denied.Comments}"; - } - - // Simulate payment processing - await Task.Delay(1000, cancellationToken); - return $"Expense reimbursed at {DateTime.UtcNow:O}"; - } -} diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/03_WorkflowHITL/Program.cs b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/03_WorkflowHITL/Program.cs deleted file mode 100644 index 1aa1972e623..00000000000 --- a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/03_WorkflowHITL/Program.cs +++ /dev/null @@ -1,51 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -// This sample demonstrates a Human-in-the-Loop (HITL) workflow hosted in Azure Functions. -// -// ┌──────────────────────┐ ┌────────────────┐ ┌─────────────────────┐ ┌────────────────────┐ -// │ CreateApprovalRequest│──►│ManagerApproval │──►│PrepareFinanceReview │──┬►│ BudgetApproval │──┐ -// └──────────────────────┘ │ (RequestPort) │ └─────────────────────┘ │ │ (RequestPort) │ │ -// └────────────────┘ │ └────────────────────┘ │ ┌─────────────────┐ -// │ ├─►│ExpenseReimburse │ -// │ ┌────────────────────┐ │ └─────────────────┘ -// └►│ComplianceApproval │──┘ -// │ (RequestPort) │ -// └────────────────────┘ -// -// The workflow pauses at three RequestPorts — one for the manager, then two in parallel for finance. -// After manager approval, BudgetApproval and ComplianceApproval run concurrently via fan-out/fan-in. -// The framework auto-generates three HTTP endpoints for each workflow: -// POST /api/workflows/{name}/run - Start the workflow -// GET /api/workflows/{name}/status/{id} - Check status and pending approvals -// POST /api/workflows/{name}/respond/{id} - Send approval response to resume - -using Microsoft.Agents.AI.Hosting.AzureFunctions; -using Microsoft.Agents.AI.Workflows; -using Microsoft.Azure.Functions.Worker.Builder; -using Microsoft.Extensions.Hosting; -using WorkflowHITLFunctions; - -// Define executors and RequestPorts for the three HITL pause points -CreateApprovalRequest createRequest = new(); -RequestPort managerApproval = RequestPort.Create("ManagerApproval"); -PrepareFinanceReview prepareFinanceReview = new(); -RequestPort budgetApproval = RequestPort.Create("BudgetApproval"); -RequestPort complianceApproval = RequestPort.Create("ComplianceApproval"); -ExpenseReimburse reimburse = new(); - -// Build the workflow: CreateApprovalRequest -> ManagerApproval -> PrepareFinanceReview -> [BudgetApproval AND ComplianceApproval] -> ExpenseReimburse -Workflow expenseApproval = new WorkflowBuilder(createRequest) - .WithName("ExpenseReimbursement") - .WithDescription("Expense reimbursement with manager and parallel finance approvals") - .AddEdge(createRequest, managerApproval) - .AddEdge(managerApproval, prepareFinanceReview) - .AddFanOutEdge(prepareFinanceReview, [budgetApproval, complianceApproval]) - .AddFanInBarrierEdge([budgetApproval, complianceApproval], reimburse) - .Build(); - -using IHost app = FunctionsApplication - .CreateBuilder(args) - .ConfigureFunctionsWebApplication() - .ConfigureDurableWorkflows(workflows => workflows.AddWorkflow(expenseApproval, exposeStatusEndpoint: true)) - .Build(); -app.Run(); diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/03_WorkflowHITL/README.md b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/03_WorkflowHITL/README.md deleted file mode 100644 index 27322b7b6a9..00000000000 --- a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/03_WorkflowHITL/README.md +++ /dev/null @@ -1,266 +0,0 @@ -# Human-in-the-Loop (HITL) Workflow — Azure Functions - -This sample demonstrates a durable workflow with Human-in-the-Loop support hosted in Azure Functions. The workflow pauses at three `RequestPort` nodes — one sequential manager approval, then two parallel finance approvals (budget and compliance) via fan-out/fan-in. Approval responses are sent via HTTP endpoints. - -## Key Concepts Demonstrated - -- Using multiple `RequestPort` nodes for sequential and parallel human-in-the-loop interactions in a durable workflow -- Fan-out/fan-in pattern for parallel approval steps -- Auto-generated HTTP endpoints for running workflows, checking status, and sending HITL responses -- Pausing orchestrations via `WaitForExternalEvent` and resuming via `RaiseEventAsync` -- Viewing inputs the workflow is waiting for via the status endpoint - -## Workflow - -This sample implements the following workflow: - -``` -┌──────────────────────┐ ┌────────────────┐ ┌─────────────────────┐ ┌────────────────────┐ -│ CreateApprovalRequest│──►│ManagerApproval │──►│PrepareFinanceReview │──┬►│ BudgetApproval │──┐ -└──────────────────────┘ │ (RequestPort) │ └─────────────────────┘ │ │ (RequestPort) │ │ - └────────────────┘ │ └────────────────────┘ │ ┌─────────────────┐ - │ ├─►│ExpenseReimburse │ - │ ┌────────────────────┐ │ └─────────────────┘ - └►│ComplianceApproval │──┘ - │ (RequestPort) │ - └────────────────────┘ -``` - -## HTTP Endpoints - -The framework auto-generates these endpoints for workflows with `RequestPort` nodes: - -| Method | Endpoint | Description | -|--------|----------|-------------| -| POST | `/api/workflows/ExpenseReimbursement/run` | Start the workflow | -| GET | `/api/workflows/ExpenseReimbursement/status/{runId}` | Check status and inputs the workflow is waiting for | -| POST | `/api/workflows/ExpenseReimbursement/respond/{runId}` | Send approval response to resume | - -## Environment Setup - -See the [README.md](../../README.md) file in the parent directory for information on how to configure the environment, including how to install and run the Durable Task Scheduler. - -## Running the Sample - -With the environment setup and function app running, you can test the sample by sending HTTP requests to the workflow endpoints. - -You can use the `demo.http` file to trigger the workflow, or a command line tool like `curl` as shown below: - -### Step 1: Start the Workflow - -Bash (Linux/macOS/WSL): - -```bash -curl -X POST http://localhost:7071/api/workflows/ExpenseReimbursement/run \ - -H "Content-Type: text/plain" -d "EXP-2025-001" -``` - -PowerShell: - -```powershell -Invoke-RestMethod -Method Post ` - -Uri http://localhost:7071/api/workflows/ExpenseReimbursement/run ` - -ContentType text/plain ` - -Body "EXP-2025-001" -``` - -The response will confirm the workflow orchestration has started: - -```text -Workflow orchestration started for ExpenseReimbursement. Orchestration runId: abc123def456 -``` - -> [!TIP] -> You can provide a custom run ID by appending a `runId` query parameter: -> -> Bash (Linux/macOS/WSL): -> -> ```bash -> curl -X POST "http://localhost:7071/api/workflows/ExpenseReimbursement/run?runId=expense-001" \ -> -H "Content-Type: text/plain" -d "EXP-2025-001" -> ``` -> -> PowerShell: -> -> ```powershell -> Invoke-RestMethod -Method Post ` -> -Uri "http://localhost:7071/api/workflows/ExpenseReimbursement/run?runId=expense-001" ` -> -ContentType text/plain ` -> -Body "EXP-2025-001" -> ``` -> -> If not provided, a unique run ID is auto-generated. - -### Step 2: Check Workflow Status - -The workflow pauses at the `ManagerApproval` RequestPort. Query the status endpoint to see what input it is waiting for: - -Bash (Linux/macOS/WSL): - -```bash -curl http://localhost:7071/api/workflows/ExpenseReimbursement/status/{runId} -``` - -PowerShell: - -```powershell -Invoke-RestMethod -Uri http://localhost:7071/api/workflows/ExpenseReimbursement/status/{runId} -``` - -```json -{ - "runId": "{runId}", - "status": "Running", - "waitingForInput": [ - { "eventName": "ManagerApproval", "input": { "ExpenseId": "EXP-2025-001", "Amount": 1500.00, "EmployeeName": "Jerry" } } - ] -} -``` - -> [!TIP] -> You can also verify this in the DTS dashboard at `http://localhost:8082`. Find the orchestration by its `runId` and you will see it is in a "Running" state, paused at a `WaitForExternalEvent` call for the `ManagerApproval` event. - -### Step 3: Send Manager Approval Response - -Bash (Linux/macOS/WSL): - -```bash -curl -X POST http://localhost:7071/api/workflows/ExpenseReimbursement/respond/{runId} \ - -H "Content-Type: application/json" \ - -d '{"eventName": "ManagerApproval", "response": {"Approved": true, "Comments": "Approved by manager."}}' -``` - -PowerShell: - -```powershell -Invoke-RestMethod -Method Post ` - -Uri http://localhost:7071/api/workflows/ExpenseReimbursement/respond/{runId} ` - -ContentType application/json ` - -Body '{"eventName": "ManagerApproval", "response": {"Approved": true, "Comments": "Approved by manager."}}' -``` - -```json -{ - "message": "Response sent to workflow.", - "runId": "{runId}", - "eventName": "ManagerApproval", - "validated": true -} -``` - -### Step 4: Check Workflow Status Again - -The workflow now pauses at both the `BudgetApproval` and `ComplianceApproval` RequestPorts in parallel: - -Bash (Linux/macOS/WSL): - -```bash -curl http://localhost:7071/api/workflows/ExpenseReimbursement/status/{runId} -``` - -PowerShell: - -```powershell -Invoke-RestMethod -Uri http://localhost:7071/api/workflows/ExpenseReimbursement/status/{runId} -``` - -```json -{ - "runId": "{runId}", - "status": "Running", - "waitingForInput": [ - { "eventName": "BudgetApproval", "input": { "ExpenseId": "EXP-2025-001", "Amount": 1500.00, "EmployeeName": "Jerry" } }, - { "eventName": "ComplianceApproval", "input": { "ExpenseId": "EXP-2025-001", "Amount": 1500.00, "EmployeeName": "Jerry" } } - ] -} -``` - -### Step 5a: Send Budget Approval Response - -Bash (Linux/macOS/WSL): - -```bash -curl -X POST http://localhost:7071/api/workflows/ExpenseReimbursement/respond/{runId} \ - -H "Content-Type: application/json" \ - -d '{"eventName": "BudgetApproval", "response": {"Approved": true, "Comments": "Budget approved."}}' -``` - -PowerShell: - -```powershell -Invoke-RestMethod -Method Post ` - -Uri http://localhost:7071/api/workflows/ExpenseReimbursement/respond/{runId} ` - -ContentType application/json ` - -Body '{"eventName": "BudgetApproval", "response": {"Approved": true, "Comments": "Budget approved."}}' -``` - -```json -{ - "message": "Response sent to workflow.", - "runId": "{runId}", - "eventName": "BudgetApproval", - "validated": true -} -``` - -### Step 5b: Send Compliance Approval Response - -Bash (Linux/macOS/WSL): - -```bash -curl -X POST http://localhost:7071/api/workflows/ExpenseReimbursement/respond/{runId} \ - -H "Content-Type: application/json" \ - -d '{"eventName": "ComplianceApproval", "response": {"Approved": true, "Comments": "Compliance approved."}}' -``` - -PowerShell: - -```powershell -Invoke-RestMethod -Method Post ` - -Uri http://localhost:7071/api/workflows/ExpenseReimbursement/respond/{runId} ` - -ContentType application/json ` - -Body '{"eventName": "ComplianceApproval", "response": {"Approved": true, "Comments": "Compliance approved."}}' -``` - -```json -{ - "message": "Response sent to workflow.", - "runId": "{runId}", - "eventName": "ComplianceApproval", - "validated": true -} -``` - -### Step 6: Check Final Status - -After all approvals, the workflow completes and the expense is reimbursed: - -Bash (Linux/macOS/WSL): - -```bash -curl http://localhost:7071/api/workflows/ExpenseReimbursement/status/{runId} -``` - -PowerShell: - -```powershell -Invoke-RestMethod -Uri http://localhost:7071/api/workflows/ExpenseReimbursement/status/{runId} -``` - -```json -{ - "runId": "{runId}", - "status": "Completed", - "waitingForInput": null -} -``` - -### Viewing Workflows in the DTS Dashboard - -After running a workflow, you can navigate to the Durable Task Scheduler (DTS) dashboard to visualize the orchestration and inspect its execution history. - -If you are using the DTS emulator, the dashboard is available at `http://localhost:8082`. - -1. Open the dashboard and look for the orchestration instance matching the `runId` returned in Step 1 (e.g., `abc123def456` or your custom ID like `expense-001`). -2. Click into the instance to see the execution timeline, which shows each executor activity and the `WaitForExternalEvent` pauses where the workflow waited for human input — including the two parallel finance approvals. -3. Expand individual activity steps to inspect inputs and outputs — for example, the `ManagerApproval`, `BudgetApproval`, and `ComplianceApproval` external events will show the approval request sent and the response received. diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/03_WorkflowHITL/demo.http b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/03_WorkflowHITL/demo.http deleted file mode 100644 index 5e2993ac1cc..00000000000 --- a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/03_WorkflowHITL/demo.http +++ /dev/null @@ -1,53 +0,0 @@ -# Default endpoint address for local testing -@authority=http://localhost:7071 - -### Step 1: Start the expense reimbursement workflow -POST {{authority}}/api/workflows/ExpenseReimbursement/run -Content-Type: text/plain - -EXP-2025-001 - -### Step 1 (alternative): Start the workflow with a custom run ID -POST {{authority}}/api/workflows/ExpenseReimbursement/run?runId=expense-001 -Content-Type: text/plain - -EXP-2025-001 - -### Step 2: Check workflow status (replace {runId} with actual run ID from Step 1) -GET {{authority}}/api/workflows/ExpenseReimbursement/status/{runId} - -### Step 3: Send manager approval (replace {runId} with actual run ID from Step 1) -POST {{authority}}/api/workflows/ExpenseReimbursement/respond/{runId} -Content-Type: application/json - -{"eventName": "ManagerApproval", "response": {"Approved": true, "Comments": "Approved by manager."}} - -### Step 3 (alternative): Deny the expense at manager level -POST {{authority}}/api/workflows/ExpenseReimbursement/respond/{runId} -Content-Type: application/json - -{"eventName": "ManagerApproval", "response": {"Approved": false, "Comments": "Insufficient documentation. Please resubmit."}} - -### Step 4: Check workflow status after manager approval (now waiting for parallel finance approvals) -GET {{authority}}/api/workflows/ExpenseReimbursement/status/{runId} - -### Step 5a: Send budget approval (replace {runId} with actual run ID from Step 1) -POST {{authority}}/api/workflows/ExpenseReimbursement/respond/{runId} -Content-Type: application/json - -{"eventName": "BudgetApproval", "response": {"Approved": true, "Comments": "Budget approved."}} - -### Step 5b: Send compliance approval (replace {runId} with actual run ID from Step 1) -POST {{authority}}/api/workflows/ExpenseReimbursement/respond/{runId} -Content-Type: application/json - -{"eventName": "ComplianceApproval", "response": {"Approved": true, "Comments": "Compliance approved."}} - -### Step 5b (alternative): Deny the expense at compliance level -POST {{authority}}/api/workflows/ExpenseReimbursement/respond/{runId} -Content-Type: application/json - -{"eventName": "ComplianceApproval", "response": {"Approved": false, "Comments": "Compliance requirements not met."}} - -### Step 6: Check final workflow status after all approvals -GET {{authority}}/api/workflows/ExpenseReimbursement/status/{runId} diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/03_WorkflowHITL/host.json b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/03_WorkflowHITL/host.json deleted file mode 100644 index 9384a0a583d..00000000000 --- a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/03_WorkflowHITL/host.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "version": "2.0", - "logging": { - "logLevel": { - "Microsoft.Agents.AI.DurableTask": "Information", - "Microsoft.Agents.AI.Hosting.AzureFunctions": "Information", - "DurableTask": "Information", - "Microsoft.DurableTask": "Information" - } - }, - "extensions": { - "durableTask": { - "hubName": "default", - "storageProvider": { - "type": "AzureManaged", - "connectionStringName": "DURABLE_TASK_SCHEDULER_CONNECTION_STRING" - } - } - } -} diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/04_WorkflowMcpTool.csproj b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/04_WorkflowMcpTool.csproj deleted file mode 100644 index 68f9ccb801b..00000000000 --- a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/04_WorkflowMcpTool.csproj +++ /dev/null @@ -1,35 +0,0 @@ - - - net10.0 - v4 - Exe - enable - enable - - WorkflowMcpTool - WorkflowMcpTool - - - - - - - - - - - - - - - - - - - - - diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/Executors.cs b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/Executors.cs deleted file mode 100644 index 0621680e332..00000000000 --- a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/Executors.cs +++ /dev/null @@ -1,59 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Agents.AI.Workflows; - -namespace WorkflowMcpTool; - -internal sealed class TranslateText() : Executor("TranslateText") -{ - public override ValueTask HandleAsync( - string message, - IWorkflowContext context, - CancellationToken cancellationToken = default) - { - Console.WriteLine($"[Activity] TranslateText: '{message}'"); - return ValueTask.FromResult(new TranslationResult(message, message.ToUpperInvariant())); - } -} - -internal sealed class FormatOutput() : Executor("FormatOutput") -{ - public override ValueTask HandleAsync( - TranslationResult message, - IWorkflowContext context, - CancellationToken cancellationToken = default) - { - Console.WriteLine("[Activity] FormatOutput: Formatting result"); - return ValueTask.FromResult($"Original: {message.Original} => Translated: {message.Translated}"); - } -} - -internal sealed class LookupOrder() : Executor("LookupOrder") -{ - public override ValueTask HandleAsync( - string message, - IWorkflowContext context, - CancellationToken cancellationToken = default) - { - Console.WriteLine($"[Activity] LookupOrder: '{message}'"); - return ValueTask.FromResult(new OrderInfo(message, "Alice Johnson", "Wireless Headphones", Quantity: 2, UnitPrice: 49.99m)); - } -} - -internal sealed class EnrichOrder() : Executor("EnrichOrder") -{ - public override ValueTask HandleAsync( - OrderInfo message, - IWorkflowContext context, - CancellationToken cancellationToken = default) - { - Console.WriteLine($"[Activity] EnrichOrder: '{message.OrderId}'"); - return ValueTask.FromResult(new OrderSummary(message, TotalPrice: message.Quantity * message.UnitPrice, Status: "Confirmed")); - } -} - -internal sealed record TranslationResult(string Original, string Translated); - -internal sealed record OrderInfo(string OrderId, string CustomerName, string Product, int Quantity, decimal UnitPrice); - -internal sealed record OrderSummary(OrderInfo Order, decimal TotalPrice, string Status); diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/Program.cs b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/Program.cs deleted file mode 100644 index 0970ca16b81..00000000000 --- a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/Program.cs +++ /dev/null @@ -1,44 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -// This sample demonstrates how to expose a durable workflow as an MCP (Model Context Protocol) tool. -// When using AddWorkflow with exposeMcpToolTrigger: true, the Functions host will automatically -// generate a remote MCP endpoint for the app at /runtime/webhooks/mcp with a workflow-specific -// tool name. MCP-compatible clients can then invoke the workflow as a tool. - -using Microsoft.Agents.AI.Hosting.AzureFunctions; -using Microsoft.Agents.AI.Workflows; -using Microsoft.Azure.Functions.Worker.Builder; -using Microsoft.Extensions.Hosting; -using WorkflowMcpTool; - -// Define executors -TranslateText translateText = new(); -FormatOutput formatOutput = new(); -LookupOrder lookupOrder = new(); -EnrichOrder enrichOrder = new(); - -// Build a simple workflow: TranslateText -> FormatOutput -Workflow translateWorkflow = new WorkflowBuilder(translateText) - .WithName("Translate") - .WithDescription("Translate text to uppercase and format the result") - .AddEdge(translateText, formatOutput) - .Build(); - -// Build a workflow that returns a POCO: LookupOrder -> EnrichOrder -Workflow orderLookupWorkflow = new WorkflowBuilder(lookupOrder) - .WithName("OrderLookup") - .WithDescription("Look up an order by ID and return enriched order details") - .AddEdge(lookupOrder, enrichOrder) - .Build(); - -using IHost app = FunctionsApplication - .CreateBuilder(args) - .ConfigureFunctionsWebApplication() - .ConfigureDurableWorkflows(workflows => - { - // Expose both workflows as MCP tool triggers. - workflows.AddWorkflow(translateWorkflow, exposeStatusEndpoint: false, exposeMcpToolTrigger: true); - workflows.AddWorkflow(orderLookupWorkflow, exposeStatusEndpoint: false, exposeMcpToolTrigger: true); - }) - .Build(); -app.Run(); diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/README.md b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/README.md deleted file mode 100644 index a5411bf375f..00000000000 --- a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/README.md +++ /dev/null @@ -1,81 +0,0 @@ -# Workflow as MCP Tool Sample - -This sample demonstrates how to expose durable workflows as [MCP (Model Context Protocol)](https://modelcontextprotocol.io/) tools, enabling MCP-compatible clients to invoke workflows directly. - -## Key Concepts Demonstrated - -- **Workflow as MCP Tool**: Expose workflows as callable MCP tools using `exposeMcpToolTrigger: true` -- **MCP Server Hosting**: The Azure Functions host automatically generates a remote MCP endpoint at `/runtime/webhooks/mcp` -- **String and POCO Results**: Shows workflows returning both plain strings and structured JSON objects - -## Sample Architecture - -The sample creates two workflows exposed as MCP tools: - -### Translate Workflow (returns a string) - -| Executor | Input | Output | Description | -|----------|-------|--------|-------------| -| **TranslateText** | `string` | `TranslationResult` | Converts input text to uppercase | -| **FormatOutput** | `TranslationResult` | `string` | Formats the result into a readable string | - -### OrderLookup Workflow (returns a POCO) - -| Executor | Input | Output | Description | -|----------|-------|--------|-------------| -| **LookupOrder** | `string` | `OrderInfo` | Looks up an order by ID | -| **EnrichOrder** | `OrderInfo` | `OrderSummary` | Adds computed fields (total price, status) | - -## Environment Setup - -See the [README.md](../../README.md) file in the parent directory for complete setup instructions, including: - -- Prerequisites installation -- Durable Task Scheduler setup -- Storage emulator configuration - -For this sample, you'll also need [Node.js](https://nodejs.org/en/download) to use the [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector). - -## Running the Sample - -1. **Start the Function App**: - - ```bash - cd dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool - func start - ``` - -2. **Note the MCP Server Endpoint**: When the app starts, you'll see the MCP server endpoint in the terminal output: - - ```text - MCP server endpoint: http://localhost:7071/runtime/webhooks/mcp - ``` - -## Invoking Workflows via MCP Inspector - -1. Install and run the [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector): - - ```bash - npx @modelcontextprotocol/inspector - ``` - -2. Connect to the MCP server endpoint: - - For **Transport Type**, select **"Streamable HTTP"** - - For **URL**, enter `http://localhost:7071/runtime/webhooks/mcp` - - Click the **Connect** button - -3. Click the **List Tools** button. You should see two tools: `Translate` and `OrderLookup`. - -4. Test the **Translate** tool (returns a plain string): - - Select the `Translate` tool - - Set `hello world` as the `input` parameter - - Click **Run Tool** - - Expected result: `Original: hello world => Translated: HELLO WORLD` - -5. Test the **OrderLookup** tool (returns a JSON object): - - Select the `OrderLookup` tool - - Set `ORD-2025-42` as the `input` parameter - - Click **Run Tool** - - Expected result: A JSON object containing order details such as `OrderId`, `CustomerName`, `Product`, `TotalPrice`, and `Status` - -You'll see the workflow executor activities logged in the terminal where you ran `func start`. diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/host.json b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/host.json deleted file mode 100644 index 9384a0a583d..00000000000 --- a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/host.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "version": "2.0", - "logging": { - "logLevel": { - "Microsoft.Agents.AI.DurableTask": "Information", - "Microsoft.Agents.AI.Hosting.AzureFunctions": "Information", - "DurableTask": "Information", - "Microsoft.DurableTask": "Information" - } - }, - "extensions": { - "durableTask": { - "hubName": "default", - "storageProvider": { - "type": "AzureManaged", - "connectionStringName": "DURABLE_TASK_SCHEDULER_CONNECTION_STRING" - } - } - } -} diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/05_WorkflowAndAgents.csproj b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/05_WorkflowAndAgents.csproj deleted file mode 100644 index 517dd323a71..00000000000 --- a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/05_WorkflowAndAgents.csproj +++ /dev/null @@ -1,42 +0,0 @@ - - - net10.0 - v4 - Exe - enable - enable - - WorkflowAndAgents - WorkflowAndAgents - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/Executors.cs b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/Executors.cs deleted file mode 100644 index 727379b4824..00000000000 --- a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/Executors.cs +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Agents.AI.Workflows; - -namespace WorkflowAndAgents; - -internal sealed class TranslateText() : Executor("TranslateText") -{ - public override ValueTask HandleAsync( - string message, - IWorkflowContext context, - CancellationToken cancellationToken = default) - { - Console.WriteLine($"[Activity] TranslateText: '{message}'"); - return ValueTask.FromResult(new TranslationResult(message, message.ToUpperInvariant())); - } -} - -internal sealed class FormatOutput() : Executor("FormatOutput") -{ - public override ValueTask HandleAsync( - TranslationResult message, - IWorkflowContext context, - CancellationToken cancellationToken = default) - { - Console.WriteLine("[Activity] FormatOutput: Formatting result"); - return ValueTask.FromResult($"Original: {message.Original} => Translated: {message.Translated}"); - } -} - -internal sealed record TranslationResult(string Original, string Translated); diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/Program.cs b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/Program.cs deleted file mode 100644 index b9108b4b292..00000000000 --- a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/Program.cs +++ /dev/null @@ -1,67 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -// This sample demonstrates using ConfigureDurableOptions to register BOTH agents AND workflows -// in a single Azure Functions app. It uses a workflow to translate text and a standalone AI agent -// accessible via HTTP and MCP tool triggers. - -#pragma warning disable IDE0002 // Simplify Member Access - -using Azure; -using Azure.AI.OpenAI; -using Azure.Identity; -using Microsoft.Agents.AI; -using Microsoft.Agents.AI.Hosting.AzureFunctions; -using Microsoft.Agents.AI.Workflows; -using Microsoft.Azure.Functions.Worker.Builder; -using Microsoft.Extensions.Hosting; -using OpenAI.Chat; -using WorkflowAndAgents; - -// Get the Azure OpenAI endpoint and deployment name from environment variables. -string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") - ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") - ?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT_NAME is not set."); - -// Use Azure Key Credential if provided, otherwise use Azure CLI Credential. -string? azureOpenAiKey = Environment.GetEnvironmentVariable("AZURE_OPENAI_API_KEY"); -AzureOpenAIClient client = !string.IsNullOrEmpty(azureOpenAiKey) - ? new AzureOpenAIClient(new Uri(endpoint), new AzureKeyCredential(azureOpenAiKey)) - // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. - // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid - // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. - : new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()); - -ChatClient chatClient = client.GetChatClient(deploymentName); - -// Define a standalone AI agent -AIAgent assistant = chatClient.AsAIAgent( - "You are a helpful assistant. Answer questions clearly and concisely.", - "Assistant", - description: "A general-purpose helpful assistant."); - -// Define workflow executors -TranslateText translateText = new(); -FormatOutput formatOutput = new(); - -// Build a workflow: TranslateText -> FormatOutput -Workflow translateWorkflow = new WorkflowBuilder(translateText) - .WithName("Translate") - .WithDescription("Translate text to uppercase and format the result") - .AddEdge(translateText, formatOutput) - .Build(); - -// Use ConfigureDurableOptions to register both agents and workflows together -using IHost app = FunctionsApplication - .CreateBuilder(args) - .ConfigureFunctionsWebApplication() - .ConfigureDurableOptions(options => - { - // Register the standalone agent with HTTP and MCP tool triggers - options.Agents.AddAIAgent(assistant, enableHttpTrigger: true, enableMcpToolTrigger: true); - - // Register the workflow with an HTTP endpoint and MCP tool trigger - options.Workflows.AddWorkflow(translateWorkflow, exposeStatusEndpoint: false, exposeMcpToolTrigger: true); - }) - .Build(); -app.Run(); diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/README.md b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/README.md deleted file mode 100644 index 37841777ccd..00000000000 --- a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/README.md +++ /dev/null @@ -1,76 +0,0 @@ -# Workflow and Agents Sample - -This sample demonstrates how to use `ConfigureDurableOptions` to register **both** AI agents **and** workflows in a single Azure Functions app. This is the recommended approach when your application needs both standalone agents and orchestrated workflows. - -## Key Concepts Demonstrated - -- **Unified Configuration**: Use `ConfigureDurableOptions` to register agents and workflows together -- **Standalone Agent**: An AI agent accessible via HTTP and MCP tool triggers -- **Workflow**: A simple text translation workflow also exposed as an MCP tool -- **Mixed Triggers**: Both agents and workflows coexist in the same Functions host - -## Sample Architecture - -### Standalone Agent - -| Agent | Description | -|-------|-------------| -| **Assistant** | A general-purpose AI assistant accessible via HTTP (`/agents/Assistant/run`) and as an MCP tool | - -### Translate Workflow - -| Executor | Input | Output | Description | -|----------|-------|--------|-------------| -| **TranslateText** | `string` | `TranslationResult` | Converts input text to uppercase | -| **FormatOutput** | `TranslationResult` | `string` | Formats the result into a readable string | - -## Environment Setup - -See the [README.md](../../README.md) file in the parent directory for complete setup instructions, including: - -- Prerequisites installation -- Durable Task Scheduler setup -- Storage emulator configuration - -This sample also requires Azure OpenAI credentials. Set the following in `local.settings.json`: - -- `AZURE_OPENAI_ENDPOINT`: Your Azure OpenAI endpoint URL -- `AZURE_OPENAI_DEPLOYMENT_NAME`: Your chat model deployment name -- `AZURE_OPENAI_API_KEY` (optional): If not set, Azure CLI credential is used - -## Running the Sample - -1. **Start the Function App**: - - ```bash - cd dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents - func start - ``` - -2. **Expected Functions**: When the app starts, you should see functions for both the agent and the workflow: - - - `dafx-Assistant` (entity trigger for the agent) - - `http-Assistant` (HTTP trigger for the agent) - - `mcptool-Assistant` (MCP tool trigger for the agent) - - `wf-Translate` (orchestration trigger for the workflow) - - `mcptool-wf-Translate` (MCP tool trigger for the workflow) - -## Invoking the Agent via HTTP - -```bash -curl -X POST http://localhost:7071/agents/Assistant/run \ - -H "Content-Type: application/json" \ - -d '{"query": "What is the capital of France?"}' -``` - -## Invoking via MCP Inspector - -1. Install and run the [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector): - - ```bash - npx @modelcontextprotocol/inspector - ``` - -2. Connect to `http://localhost:7071/runtime/webhooks/mcp` using **Streamable HTTP** transport. - -3. Click **List Tools** to see both the `Assistant` agent tool and the `Translate` workflow tool. diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/host.json b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/host.json deleted file mode 100644 index 9384a0a583d..00000000000 --- a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/host.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "version": "2.0", - "logging": { - "logLevel": { - "Microsoft.Agents.AI.DurableTask": "Information", - "Microsoft.Agents.AI.Hosting.AzureFunctions": "Information", - "DurableTask": "Information", - "Microsoft.DurableTask": "Information" - } - }, - "extensions": { - "durableTask": { - "hubName": "default", - "storageProvider": { - "type": "AzureManaged", - "connectionStringName": "DURABLE_TASK_SCHEDULER_CONNECTION_STRING" - } - } - } -} diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/README.md b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/README.md new file mode 100644 index 00000000000..539a4c02e02 --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/README.md @@ -0,0 +1,3 @@ +# Durable Workflow Azure Functions Samples Have Moved + +These samples are now maintained in the [Durable Agent Framework extension repository](https://github.com/microsoft/agent-framework-durable-extension/tree/main/dotnet/samples/DurableWorkflows/AzureFunctions). diff --git a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/01_SequentialWorkflow/01_SequentialWorkflow.csproj b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/01_SequentialWorkflow/01_SequentialWorkflow.csproj deleted file mode 100644 index 8a5308a6f53..00000000000 --- a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/01_SequentialWorkflow/01_SequentialWorkflow.csproj +++ /dev/null @@ -1,29 +0,0 @@ - - - net10.0 - Exe - enable - enable - SequentialWorkflow - SequentialWorkflow - - - - - - - - - - - - - - - - diff --git a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/01_SequentialWorkflow/OrderCancelExecutors.cs b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/01_SequentialWorkflow/OrderCancelExecutors.cs deleted file mode 100644 index 474cb8bcaa8..00000000000 --- a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/01_SequentialWorkflow/OrderCancelExecutors.cs +++ /dev/null @@ -1,116 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Agents.AI.Workflows; - -namespace SequentialWorkflow; - -/// -/// Represents a request to cancel an order. -/// -/// The ID of the order to cancel. -/// The reason for cancellation. -internal sealed record OrderCancelRequest(string OrderId, string Reason); - -/// -/// Looks up an order by its ID and return an Order object. -/// -internal sealed class OrderLookup() : Executor("OrderLookup") -{ - public override async ValueTask HandleAsync( - OrderCancelRequest message, - IWorkflowContext context, - CancellationToken cancellationToken = default) - { - Console.WriteLine(); - Console.ForegroundColor = ConsoleColor.Magenta; - Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐"); - Console.WriteLine($"│ [Activity] OrderLookup: Starting lookup for order '{message.OrderId}'"); - Console.WriteLine($"│ [Activity] OrderLookup: Cancellation reason: '{message.Reason}'"); - Console.ResetColor(); - - // Simulate database lookup with delay - await Task.Delay(TimeSpan.FromMicroseconds(100), cancellationToken); - - Order order = new( - Id: message.OrderId, - OrderDate: DateTime.UtcNow.AddDays(-1), - IsCancelled: false, - CancelReason: message.Reason, - Customer: new Customer(Name: "Jerry", Email: "jerry@example.com")); - - Console.ForegroundColor = ConsoleColor.Magenta; - Console.WriteLine($"│ [Activity] OrderLookup: Found order '{message.OrderId}' for customer '{order.Customer.Name}'"); - Console.WriteLine("└─────────────────────────────────────────────────────────────────┘"); - Console.ResetColor(); - - return order; - } -} - -/// -/// Cancels an order. -/// -internal sealed class OrderCancel() : Executor("OrderCancel") -{ - public override async ValueTask HandleAsync( - Order message, - IWorkflowContext context, - CancellationToken cancellationToken = default) - { - // Log that this activity is executing (not replaying) - Console.WriteLine(); - Console.ForegroundColor = ConsoleColor.Yellow; - Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐"); - Console.WriteLine($"│ [Activity] OrderCancel: Starting cancellation for order '{message.Id}'"); - Console.ResetColor(); - - // Simulate a slow cancellation process (e.g., calling external payment system) - for (int i = 1; i <= 3; i++) - { - await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken); - Console.ForegroundColor = ConsoleColor.DarkYellow; - Console.WriteLine("│ [Activity] OrderCancel: Processing..."); - Console.ResetColor(); - } - - Order cancelledOrder = message with { IsCancelled = true }; - - Console.ForegroundColor = ConsoleColor.Yellow; - Console.WriteLine($"│ [Activity] OrderCancel: ✓ Order '{cancelledOrder.Id}' has been cancelled"); - Console.WriteLine("└─────────────────────────────────────────────────────────────────┘"); - Console.ResetColor(); - - return cancelledOrder; - } -} - -/// -/// Sends a cancellation confirmation email to the customer. -/// -internal sealed class SendEmail() : Executor("SendEmail") -{ - public override ValueTask HandleAsync( - Order message, - IWorkflowContext context, - CancellationToken cancellationToken = default) - { - Console.WriteLine(); - Console.ForegroundColor = ConsoleColor.Cyan; - Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐"); - Console.WriteLine($"│ [Activity] SendEmail: Sending email to '{message.Customer.Email}'..."); - Console.ResetColor(); - - string result = $"Cancellation email sent for order {message.Id} to {message.Customer.Email}."; - - Console.ForegroundColor = ConsoleColor.Cyan; - Console.WriteLine("│ [Activity] SendEmail: ✓ Email sent successfully!"); - Console.WriteLine("└─────────────────────────────────────────────────────────────────┘"); - Console.ResetColor(); - - return ValueTask.FromResult(result); - } -} - -internal sealed record Order(string Id, DateTime OrderDate, bool IsCancelled, string? CancelReason, Customer Customer); - -internal sealed record Customer(string Name, string Email); diff --git a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/01_SequentialWorkflow/Program.cs b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/01_SequentialWorkflow/Program.cs deleted file mode 100644 index 03e4ed59286..00000000000 --- a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/01_SequentialWorkflow/Program.cs +++ /dev/null @@ -1,93 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Agents.AI.DurableTask; -using Microsoft.Agents.AI.DurableTask.Workflows; -using Microsoft.Agents.AI.Workflows; -using Microsoft.DurableTask.Client.AzureManaged; -using Microsoft.DurableTask.Worker.AzureManaged; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using SequentialWorkflow; - -// Get DTS connection string from environment variable -string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING") - ?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None"; - -// Define executors for the workflow -OrderLookup orderLookup = new(); -OrderCancel orderCancel = new(); -SendEmail sendEmail = new(); - -// Build the CancelOrder workflow: OrderLookup -> OrderCancel -> SendEmail -Workflow cancelOrder = new WorkflowBuilder(orderLookup) - .WithName("CancelOrder") - .WithDescription("Cancel an order and notify the customer") - .AddEdge(orderLookup, orderCancel) - .AddEdge(orderCancel, sendEmail) - .Build(); - -IHost host = Host.CreateDefaultBuilder(args) -.ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Warning)) -.ConfigureServices(services => -{ - services.ConfigureDurableWorkflows( - workflowOptions => workflowOptions.AddWorkflow(cancelOrder), - workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString), - clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString)); -}) -.Build(); - -await host.StartAsync(); - -IWorkflowClient workflowClient = host.Services.GetRequiredService(); - -Console.WriteLine("Durable Workflow Sample"); -Console.WriteLine("Workflow: OrderLookup -> OrderCancel -> SendEmail"); -Console.WriteLine(); -Console.WriteLine("Enter an order ID (or 'exit'):"); - -while (true) -{ - Console.Write("> "); - string? input = Console.ReadLine(); - if (string.IsNullOrWhiteSpace(input) || input.Equals("exit", StringComparison.OrdinalIgnoreCase)) - { - break; - } - - try - { - OrderCancelRequest request = new(OrderId: input, Reason: "Customer requested cancellation"); - await StartNewWorkflowAsync(request, cancelOrder, workflowClient); - } - catch (Exception ex) - { - Console.WriteLine($"Error: {ex.Message}"); - } - - Console.WriteLine(); -} - -await host.StopAsync(); - -// Start a new workflow using IWorkflowClient with typed input -static async Task StartNewWorkflowAsync(OrderCancelRequest request, Workflow workflow, IWorkflowClient client) -{ - Console.WriteLine($"Starting workflow for order '{request.OrderId}' (Reason: {request.Reason})..."); - - // RunAsync returns IWorkflowRun, cast to IAwaitableWorkflowRun for completion waiting - IAwaitableWorkflowRun run = (IAwaitableWorkflowRun)await client.RunAsync(workflow, request); - Console.WriteLine($"Run ID: {run.RunId}"); - - try - { - Console.WriteLine("Waiting for workflow to complete..."); - string? result = await run.WaitForCompletionAsync(); - Console.WriteLine($"Workflow completed. {result}"); - } - catch (InvalidOperationException ex) - { - Console.WriteLine($"Failed: {ex.Message}"); - } -} diff --git a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/01_SequentialWorkflow/README.md b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/01_SequentialWorkflow/README.md deleted file mode 100644 index ac5a3e43f55..00000000000 --- a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/01_SequentialWorkflow/README.md +++ /dev/null @@ -1,83 +0,0 @@ -# Sequential Workflow Sample - -This sample demonstrates how to run a sequential workflow as a durable orchestration from a console application using the Durable Task Framework. It showcases the **durability** aspect - if the process crashes mid-execution, the workflow automatically resumes without re-executing completed activities. - -## Key Concepts Demonstrated - -- Building a sequential workflow with the `WorkflowBuilder` API -- Using `ConfigureDurableWorkflows` to register workflows with dependency injection -- Running workflows with `IWorkflowClient` -- **Durability**: Automatic resume of interrupted workflows -- **Activity caching**: Completed activities are not re-executed on replay - -## Overview - -The sample implements an order cancellation workflow with three executors: - -``` -OrderLookup --> OrderCancel --> SendEmail -``` - -| Executor | Description | -|----------|-------------| -| OrderLookup | Looks up an order by ID | -| OrderCancel | Marks the order as cancelled | -| SendEmail | Sends a cancellation confirmation email | - -## Durability Demonstration - -The key feature of Durable Task Framework is **durability**: - -- **Activity results are persisted**: When an activity completes, its result is saved -- **Orchestrations replay**: On restart, the orchestration replays from the beginning -- **Completed activities skip execution**: The framework uses cached results -- **Automatic resume**: The worker automatically picks up pending work on startup - -### Try It Yourself - -> **Tip:** To give yourself more time to stop the application during `OrderCancel`, consider increasing the loop iteration count or `Task.Delay` duration in the `OrderCancel` executor in `OrderCancelExecutors.cs`. - -1. Start the application and enter an order ID (e.g., `12345`) -2. Wait for `OrderLookup` to complete, then stop the app (Ctrl+C) during `OrderCancel` -3. Restart the application -4. Observe: - - `OrderLookup` is **NOT** re-executed (result was cached) - - `OrderCancel` **restarts** (it didn't complete before the interruption) - - `SendEmail` runs after `OrderCancel` completes - -## Environment Setup - -See the [README.md](../../README.md) file in the parent directory for information on configuring the environment, including how to install and run the Durable Task Scheduler. - -## Running the Sample - -```bash -cd dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/01_SequentialWorkflow -dotnet run --framework net10.0 -``` - -### Sample Output - -```text -Durable Workflow Sample -Workflow: OrderLookup -> OrderCancel -> SendEmail - -Enter an order ID (or 'exit'): -> 12345 -Starting workflow for order: 12345 -Run ID: abc123... - -[OrderLookup] Looking up order '12345'... -[OrderLookup] Found order for customer 'Jerry' - -[OrderCancel] Cancelling order '12345'... -[OrderCancel] Order cancelled successfully - -[SendEmail] Sending email to 'jerry@example.com'... -[SendEmail] Email sent successfully - -Workflow completed! - -> exit -``` - diff --git a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/02_ConcurrentWorkflow/02_ConcurrentWorkflow.csproj b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/02_ConcurrentWorkflow/02_ConcurrentWorkflow.csproj deleted file mode 100644 index a05822a2866..00000000000 --- a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/02_ConcurrentWorkflow/02_ConcurrentWorkflow.csproj +++ /dev/null @@ -1,30 +0,0 @@ - - - net10.0 - Exe - enable - enable - WorkflowConcurrency - WorkflowConcurrency - - - - - - - - - - - - - - - - - diff --git a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/02_ConcurrentWorkflow/ExpertExecutors.cs b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/02_ConcurrentWorkflow/ExpertExecutors.cs deleted file mode 100644 index 40674126f64..00000000000 --- a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/02_ConcurrentWorkflow/ExpertExecutors.cs +++ /dev/null @@ -1,73 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Agents.AI.Workflows; - -namespace WorkflowConcurrency; - -/// -/// Parses and validates the incoming question before sending to AI agents. -/// -internal sealed class ParseQuestionExecutor() : Executor("ParseQuestion") -{ - public override ValueTask HandleAsync( - string message, - IWorkflowContext context, - CancellationToken cancellationToken = default) - { - Console.WriteLine(); - Console.ForegroundColor = ConsoleColor.Magenta; - Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐"); - Console.WriteLine("│ [ParseQuestion] Preparing question for AI agents..."); - - string formattedQuestion = message.Trim(); - if (!formattedQuestion.EndsWith('?')) - { - formattedQuestion += "?"; - } - - Console.WriteLine($"│ [ParseQuestion] Question: \"{formattedQuestion}\""); - Console.WriteLine("│ [ParseQuestion] → Sending to Physicist and Chemist in PARALLEL..."); - Console.WriteLine("└─────────────────────────────────────────────────────────────────┘"); - Console.ResetColor(); - - return ValueTask.FromResult(formattedQuestion); - } -} - -/// -/// Aggregates responses from all AI agents into a comprehensive answer. -/// This is the Fan-in point where parallel results are collected. -/// -internal sealed class AggregatorExecutor() : Executor("Aggregator") -{ - public override ValueTask HandleAsync( - string[] message, - IWorkflowContext context, - CancellationToken cancellationToken = default) - { - Console.WriteLine(); - Console.ForegroundColor = ConsoleColor.Cyan; - Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐"); - Console.WriteLine($"│ [Aggregator] 📋 Received {message.Length} AI agent responses"); - Console.WriteLine("│ [Aggregator] Combining into comprehensive answer..."); - Console.WriteLine("│ [Aggregator] ✓ Aggregation complete!"); - Console.WriteLine("└─────────────────────────────────────────────────────────────────┘"); - Console.ResetColor(); - - string aggregatedResult = "═══════════════════════════════════════════════════════════════\n" + - " AI EXPERT PANEL RESPONSES\n" + - "═══════════════════════════════════════════════════════════════\n\n"; - - for (int i = 0; i < message.Length; i++) - { - string expertLabel = i == 0 ? "⚛️ PHYSICIST" : "🧪 CHEMIST"; - aggregatedResult += $"{expertLabel}:\n{message[i]}\n\n"; - } - - aggregatedResult += "═══════════════════════════════════════════════════════════════\n" + - $"Summary: Received perspectives from {message.Length} AI experts.\n" + - "═══════════════════════════════════════════════════════════════"; - - return ValueTask.FromResult(aggregatedResult); - } -} diff --git a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/02_ConcurrentWorkflow/Program.cs b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/02_ConcurrentWorkflow/Program.cs deleted file mode 100644 index ae68a565623..00000000000 --- a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/02_ConcurrentWorkflow/Program.cs +++ /dev/null @@ -1,114 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -// This sample demonstrates the Fan-out/Fan-in pattern in a durable workflow. -// The workflow uses 4 executors: 2 class-based executors and 2 AI agents. -// -// WORKFLOW PATTERN: -// -// ParseQuestion (class-based) -// | -// +----------+----------+ -// | | -// Physicist Chemist -// (AI Agent) (AI Agent) -// | | -// +----------+----------+ -// | -// Aggregator (class-based) - -using Azure; -using Azure.AI.OpenAI; -using Azure.Identity; -using Microsoft.Agents.AI; -using Microsoft.Agents.AI.DurableTask; -using Microsoft.Agents.AI.DurableTask.Workflows; -using Microsoft.Agents.AI.Workflows; -using Microsoft.DurableTask.Client.AzureManaged; -using Microsoft.DurableTask.Worker.AzureManaged; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using OpenAI.Chat; -using WorkflowConcurrency; - -// Configuration -string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING") - ?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None"; -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."); -string? azureOpenAiKey = Environment.GetEnvironmentVariable("AZURE_OPENAI_KEY"); - -// Create Azure OpenAI client -AzureOpenAIClient openAiClient = !string.IsNullOrEmpty(azureOpenAiKey) - ? new AzureOpenAIClient(new Uri(endpoint), new AzureKeyCredential(azureOpenAiKey)) - : new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()); -ChatClient chatClient = openAiClient.GetChatClient(deploymentName); - -// Define the 4 executors for the workflow -ParseQuestionExecutor parseQuestion = new(); -AIAgent physicist = chatClient.AsAIAgent("You are a physics expert. Be concise (2-3 sentences).", "Physicist"); -AIAgent chemist = chatClient.AsAIAgent("You are a chemistry expert. Be concise (2-3 sentences).", "Chemist"); -AggregatorExecutor aggregator = new(); - -// Build workflow: ParseQuestion -> [Physicist, Chemist] (parallel) -> Aggregator -Workflow workflow = new WorkflowBuilder(parseQuestion) - .WithName("ExpertReview") - .AddFanOutEdge(parseQuestion, [physicist, chemist]) - .AddFanInBarrierEdge([physicist, chemist], aggregator) - .Build(); - -// Configure and start the host -IHost host = Host.CreateDefaultBuilder(args) - .ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Warning)) - .ConfigureServices(services => - { - services.ConfigureDurableOptions( - options => options.Workflows.AddWorkflow(workflow), - workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString), - clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString)); - }) - .Build(); - -await host.StartAsync(); - -IWorkflowClient workflowClient = host.Services.GetRequiredService(); - -Console.WriteLine("Fan-out/Fan-in Workflow Sample"); -Console.WriteLine("ParseQuestion -> [Physicist, Chemist] -> Aggregator"); -Console.WriteLine(); -Console.WriteLine("Enter a science question (or 'exit' to quit):"); - -while (true) -{ - Console.Write("> "); - string? input = Console.ReadLine(); - - if (string.IsNullOrWhiteSpace(input) || input.Equals("exit", StringComparison.OrdinalIgnoreCase)) - { - break; - } - - try - { - IWorkflowRun run = await workflowClient.RunAsync(workflow, input); - Console.WriteLine($"Run ID: {run.RunId}"); - - if (run is IAwaitableWorkflowRun awaitableRun) - { - string? result = await awaitableRun.WaitForCompletionAsync(); - - Console.WriteLine("Workflow completed!"); - Console.WriteLine(result); - } - } - catch (Exception ex) - { - Console.WriteLine($"Error: {ex.Message}"); - } - - Console.WriteLine(); -} - -await host.StopAsync(); diff --git a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/02_ConcurrentWorkflow/README.md b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/02_ConcurrentWorkflow/README.md deleted file mode 100644 index a0030058d6d..00000000000 --- a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/02_ConcurrentWorkflow/README.md +++ /dev/null @@ -1,100 +0,0 @@ -# Concurrent Workflow Sample (Fan-Out/Fan-In) - -This sample demonstrates the **fan-out/fan-in** pattern in a durable workflow, combining class-based executors with AI agents running in parallel. - -## Key Concepts Demonstrated - -- **Fan-out/Fan-in pattern**: Parallel execution with result aggregation -- **Mixed executor types**: Class-based executors and AI agents in the same workflow -- **AI agents as executors**: Using `ChatClient.AsAIAgent()` to create workflow-compatible agents -- **Workflow registration**: Auto-registration of agents used within workflows -- **Standalone agents**: Registering agents outside of workflows - -## Overview - -The sample implements an expert review workflow with four executors: - -``` - ParseQuestion - | - +----------+----------+ - | | - Physicist Chemist - (AI Agent) (AI Agent) - | | - +----------+----------+ - | - Aggregator -``` - -| Executor | Type | Description | -|----------|------|-------------| -| ParseQuestion | Class-based | Parses the user's question for expert review | -| Physicist | AI Agent | Provides physics perspective (runs in parallel) | -| Chemist | AI Agent | Provides chemistry perspective (runs in parallel) | -| Aggregator | Class-based | Combines expert responses into a final answer | - -## Fan-Out/Fan-In Pattern - -The workflow demonstrates the fan-out/fan-in pattern: - -1. **Fan-out**: `ParseQuestion` sends the question to both `Physicist` and `Chemist` simultaneously -2. **Parallel execution**: Both AI agents process the question concurrently -3. **Fan-in**: `Aggregator` waits for both agents to complete, then combines their responses - -This pattern is useful for: -- Gathering multiple perspectives on a problem -- Parallel processing of independent tasks -- Reducing overall execution time through concurrency - -## Environment Setup - -See the [README.md](../../README.md) file in the parent directory for information on configuring the environment. - -### Required Environment Variables - -```bash -# Durable Task Scheduler (optional, defaults to localhost) -DURABLE_TASK_SCHEDULER_CONNECTION_STRING="Endpoint=http://localhost:8080;TaskHub=default;Authentication=None" - -# Azure OpenAI (required) -AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" -AZURE_OPENAI_DEPLOYMENT="gpt-5.4-mini" -AZURE_OPENAI_KEY="your-key" # Optional if using Azure CLI credentials -``` - -## Running the Sample - -```bash -cd dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/02_ConcurrentWorkflow -dotnet run --framework net10.0 -``` - -### Sample Output - -```text -+-----------------------------------------------------------------------+ -| Fan-out/Fan-in Workflow Sample (4 Executors) | -| | -| ParseQuestion -> [Physicist, Chemist] -> Aggregator | -| (class-based) (AI agents, parallel) (class-based) | -+-----------------------------------------------------------------------+ - -Enter a science question (or 'exit' to quit): - -Question: Why is the sky blue? -Instance: abc123... - -[ParseQuestion] Parsing question for expert review... -[Physicist] Analyzing from physics perspective... -[Chemist] Analyzing from chemistry perspective... -[Aggregator] Combining expert responses... - -Workflow completed! - -Physics perspective: The sky appears blue due to Rayleigh scattering... -Chemistry perspective: The molecular composition of our atmosphere... -Combined answer: ... - -Question: exit -``` diff --git a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/03_ConditionalEdges/03_ConditionalEdges.csproj b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/03_ConditionalEdges/03_ConditionalEdges.csproj deleted file mode 100644 index b488b10425a..00000000000 --- a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/03_ConditionalEdges/03_ConditionalEdges.csproj +++ /dev/null @@ -1,29 +0,0 @@ - - - net10.0 - Exe - enable - enable - ConditionalEdges - ConditionalEdges - - - - - - - - - - - - - - - - diff --git a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/03_ConditionalEdges/NotifyFraud.cs b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/03_ConditionalEdges/NotifyFraud.cs deleted file mode 100644 index d22ac39e68c..00000000000 --- a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/03_ConditionalEdges/NotifyFraud.cs +++ /dev/null @@ -1,85 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Agents.AI.Workflows; - -namespace ConditionalEdges; - -internal sealed class Order -{ - public Order(string id, decimal amount) - { - this.Id = id; - this.Amount = amount; - } - public string Id { get; } - public decimal Amount { get; } - public Customer? Customer { get; set; } - public string? PaymentReferenceNumber { get; set; } -} - -public sealed record Customer(int Id, string Name, bool IsBlocked); - -internal sealed class OrderIdParser() : Executor("OrderIdParser") -{ - public override async ValueTask HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default) - { - return GetOrder(message); - } - - private static Order GetOrder(string id) - { - // Simulate fetching order details - return new Order(id, 100.0m); - } -} - -internal sealed class OrderEnrich() : Executor("EnrichOrder") -{ - public override async ValueTask HandleAsync(Order message, IWorkflowContext context, CancellationToken cancellationToken = default) - { - message.Customer = GetCustomerForOrder(message.Id); - return message; - } - - private static Customer GetCustomerForOrder(string orderId) - { - if (orderId.Contains('B')) - { - return new Customer(101, "George", true); - } - - return new Customer(201, "Jerry", false); - } -} - -internal sealed class PaymentProcessor() : Executor("PaymentProcessor") -{ - public override async ValueTask HandleAsync(Order message, IWorkflowContext context, CancellationToken cancellationToken = default) - { - // Call payment gateway. - message.PaymentReferenceNumber = Guid.NewGuid().ToString().Substring(0, 4); - return message; - } -} - -internal sealed class NotifyFraud() : Executor("NotifyFraud") -{ - public override async ValueTask HandleAsync(Order message, IWorkflowContext context, CancellationToken cancellationToken = default) - { - // Notify fraud team. - return $"Order {message.Id} flagged as fraudulent for customer {message.Customer?.Name}."; - } -} - -internal static class OrderRouteConditions -{ - /// - /// Returns a condition that evaluates to true when the customer is blocked. - /// - internal static Func WhenBlocked() => order => order?.Customer?.IsBlocked == true; - - /// - /// Returns a condition that evaluates to true when the customer is not blocked. - /// - internal static Func WhenNotBlocked() => order => order?.Customer?.IsBlocked == false; -} diff --git a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/03_ConditionalEdges/Program.cs b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/03_ConditionalEdges/Program.cs deleted file mode 100644 index b7f9ff99440..00000000000 --- a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/03_ConditionalEdges/Program.cs +++ /dev/null @@ -1,97 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -// This sample demonstrates conditional edges in a workflow. -// Orders are routed to different executors based on customer status: -// - Blocked customers → NotifyFraud -// - Valid customers → PaymentProcessor - -using ConditionalEdges; -using Microsoft.Agents.AI.DurableTask; -using Microsoft.Agents.AI.DurableTask.Workflows; -using Microsoft.Agents.AI.Workflows; -using Microsoft.DurableTask.Client.AzureManaged; -using Microsoft.DurableTask.Worker.AzureManaged; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; - -string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING") - ?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None"; - -// Create executor instances -OrderIdParser orderParser = new(); -OrderEnrich orderEnrich = new(); -PaymentProcessor paymentProcessor = new(); -NotifyFraud notifyFraud = new(); - -// Build workflow with conditional edges -// The condition functions evaluate the Order output from OrderEnrich -WorkflowBuilder builder = new(orderParser); -builder - .AddEdge(orderParser, orderEnrich) - .AddEdge(orderEnrich, notifyFraud, condition: OrderRouteConditions.WhenBlocked()) - .AddEdge(orderEnrich, paymentProcessor, condition: OrderRouteConditions.WhenNotBlocked()); - -Workflow auditOrder = builder.WithName("AuditOrder").Build(); - -IHost host = Host.CreateDefaultBuilder(args) -.ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Warning)) -.ConfigureServices(services => -{ - services.ConfigureDurableWorkflows( - workflowOptions => workflowOptions.AddWorkflow(auditOrder), - workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString), - clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString)); -}) -.Build(); - -await host.StartAsync(); - -IWorkflowClient workflowClient = host.Services.GetRequiredService(); - -Console.WriteLine("Enter an order ID (or 'exit'):"); -Console.WriteLine("Tip: Order IDs containing 'B' are flagged as blocked customers.\n"); - -while (true) -{ - Console.Write("> "); - string? input = Console.ReadLine(); - if (string.IsNullOrWhiteSpace(input) || input.Equals("exit", StringComparison.OrdinalIgnoreCase)) - { - break; - } - - try - { - await StartNewWorkflowAsync(input, auditOrder, workflowClient); - } - catch (Exception ex) - { - Console.WriteLine($"Error: {ex.Message}"); - } - - Console.WriteLine(); -} - -await host.StopAsync(); - -// Start a new workflow and wait for completion -static async Task StartNewWorkflowAsync(string orderId, Workflow workflow, IWorkflowClient client) -{ - Console.WriteLine($"Starting workflow for order '{orderId}'..."); - - // Cast to IAwaitableWorkflowRun to access WaitForCompletionAsync - IAwaitableWorkflowRun run = (IAwaitableWorkflowRun)await client.RunAsync(workflow, orderId); - Console.WriteLine($"Run ID: {run.RunId}"); - - try - { - Console.WriteLine("Waiting for workflow to complete..."); - string? result = await run.WaitForCompletionAsync(); - Console.WriteLine($"Workflow completed. {result}"); - } - catch (InvalidOperationException ex) - { - Console.WriteLine($"Failed: {ex.Message}"); - } -} diff --git a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/03_ConditionalEdges/README.md b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/03_ConditionalEdges/README.md deleted file mode 100644 index fb8c26bf804..00000000000 --- a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/03_ConditionalEdges/README.md +++ /dev/null @@ -1,92 +0,0 @@ -# Conditional Edges Workflow Sample - -This sample demonstrates how to build a workflow with **conditional edges** that route execution to different paths based on runtime conditions. The workflow evaluates conditions on the output of an executor to determine which downstream executor to run. - -## Key Concepts Demonstrated - -- Building workflows with **conditional edges** using `AddEdge` with a `condition` parameter -- Defining reusable condition functions for routing logic -- Branching workflow execution based on data-driven decisions -- Using `ConfigureDurableWorkflows` to register workflows with dependency injection - -## Overview - -The sample implements an order audit workflow that routes orders differently based on whether the customer is blocked (flagged for fraud): - -``` -OrderIdParser --> OrderEnrich --[IsBlocked]--> NotifyFraud - | - +--[NotBlocked]--> PaymentProcessor -``` - -| Executor | Description | -|----------|-------------| -| OrderIdParser | Parses the order ID and retrieves order details | -| OrderEnrich | Enriches the order with customer information | -| PaymentProcessor | Processes payment for valid orders | -| NotifyFraud | Notifies the fraud team for blocked customers | - -## How Conditional Edges Work - -Conditional edges allow you to specify a condition function that determines whether the edge should be traversed: - -```csharp -builder - .AddEdge(orderParser, orderEnrich) - .AddEdge(orderEnrich, notifyFraud, condition: OrderRouteConditions.WhenBlocked()) - .AddEdge(orderEnrich, paymentProcessor, condition: OrderRouteConditions.WhenNotBlocked()); -``` - -The condition functions receive the output of the source executor and return a boolean: - -```csharp -internal static class OrderRouteConditions -{ - // Routes to NotifyFraud when customer is blocked - internal static Func WhenBlocked() => - order => order?.Customer?.IsBlocked == true; - - // Routes to PaymentProcessor when customer is not blocked - internal static Func WhenNotBlocked() => - order => order?.Customer?.IsBlocked == false; -} -``` - -### Routing Logic - -In this sample, the routing is based on the order ID: -- Order IDs containing the letter **'B'** are associated with blocked customers → routed to `NotifyFraud` -- All other order IDs are associated with valid customers → routed to `PaymentProcessor` - -## Environment Setup - -See the [README.md](../../README.md) file in the parent directory for information on configuring the environment, including how to install and run the Durable Task Scheduler. - -## Running the Sample - -```bash -cd dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/03_ConditionalEdges -dotnet run --framework net10.0 -``` - -### Sample Output - -**Valid order (routes to PaymentProcessor):** -```text -Enter an order ID (or 'exit'): -> 12345 -Starting workflow for order '12345'... -Run ID: abc123... -Waiting for workflow to complete... -Workflow completed. {"Id":"12345","Amount":100.0,"Customer":{"Id":201,"Name":"Jerry","IsBlocked":false},"PaymentReferenceNumber":"a1b2"} -``` - -**Blocked order (routes to NotifyFraud):** -```text -Enter an order ID (or 'exit'): -> 12345B -Starting workflow for order '12345B'... -Run ID: def456... -Waiting for workflow to complete... -Workflow completed. Order 12345B flagged as fraudulent for customer George. -``` diff --git a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/04_WorkflowAndAgents/04_WorkflowAndAgents.csproj b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/04_WorkflowAndAgents/04_WorkflowAndAgents.csproj deleted file mode 100644 index a05822a2866..00000000000 --- a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/04_WorkflowAndAgents/04_WorkflowAndAgents.csproj +++ /dev/null @@ -1,30 +0,0 @@ - - - net10.0 - Exe - enable - enable - WorkflowConcurrency - WorkflowConcurrency - - - - - - - - - - - - - - - - - diff --git a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/04_WorkflowAndAgents/ParseQuestionExecutor.cs b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/04_WorkflowAndAgents/ParseQuestionExecutor.cs deleted file mode 100644 index e9a67123939..00000000000 --- a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/04_WorkflowAndAgents/ParseQuestionExecutor.cs +++ /dev/null @@ -1,73 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Agents.AI.Workflows; - -namespace WorkflowConcurrency; - -/// -/// Parses and validates the incoming question before sending to AI agents. -/// -internal sealed class ParseQuestionExecutor() : Executor("ParseQuestion") -{ - public override ValueTask HandleAsync( - string message, - IWorkflowContext context, - CancellationToken cancellationToken = default) - { - Console.WriteLine(); - Console.ForegroundColor = ConsoleColor.Magenta; - Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐"); - Console.WriteLine("│ [ParseQuestion] Preparing question for AI agents..."); - - string formattedQuestion = message.Trim(); - if (!formattedQuestion.EndsWith('?')) - { - formattedQuestion += "?"; - } - - Console.WriteLine($"│ [ParseQuestion] Question: \"{formattedQuestion}\""); - Console.WriteLine("│ [ParseQuestion] → Sending to experts..."); - Console.WriteLine("└─────────────────────────────────────────────────────────────────┘"); - Console.ResetColor(); - - return ValueTask.FromResult(formattedQuestion); - } -} - -/// -/// Aggregates responses from multiple AI agents into a unified response. -/// This executor collects all expert opinions and synthesizes them. -/// -internal sealed class ResponseAggregatorExecutor() : Executor("ResponseAggregator") -{ - public override ValueTask HandleAsync( - string[] message, - IWorkflowContext context, - CancellationToken cancellationToken = default) - { - Console.WriteLine(); - Console.ForegroundColor = ConsoleColor.Cyan; - Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐"); - Console.WriteLine($"│ [Aggregator] 📋 Received {message.Length} AI agent responses"); - Console.WriteLine("│ [Aggregator] Combining into comprehensive answer..."); - Console.WriteLine("│ [Aggregator] ✓ Aggregation complete!"); - Console.WriteLine("└─────────────────────────────────────────────────────────────────┘"); - Console.ResetColor(); - - string aggregatedResult = "═══════════════════════════════════════════════════════════════\n" + - " AI EXPERT PANEL RESPONSES\n" + - "═══════════════════════════════════════════════════════════════\n\n"; - - for (int i = 0; i < message.Length; i++) - { - string expertLabel = i == 0 ? "⚛️ PHYSICIST" : "🧪 CHEMIST"; - aggregatedResult += $"{expertLabel}:\n{message[i]}\n\n"; - } - - aggregatedResult += "═══════════════════════════════════════════════════════════════\n" + - $"Summary: Received perspectives from {message.Length} AI experts.\n" + - "═══════════════════════════════════════════════════════════════"; - - return ValueTask.FromResult(aggregatedResult); - } -} diff --git a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/04_WorkflowAndAgents/Program.cs b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/04_WorkflowAndAgents/Program.cs deleted file mode 100644 index 5dfec4f2770..00000000000 --- a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/04_WorkflowAndAgents/Program.cs +++ /dev/null @@ -1,133 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -// This sample demonstrates the THREE ways to configure durable agents and workflows: -// -// 1. ConfigureDurableAgents() - For standalone agents only -// 2. ConfigureDurableWorkflows() - For workflows only -// 3. ConfigureDurableOptions() - For both agents AND workflows -// -// KEY: All methods can be called MULTIPLE times - configurations are ADDITIVE. - -using Azure; -using Azure.AI.OpenAI; -using Azure.Identity; -using Microsoft.Agents.AI; -using Microsoft.Agents.AI.DurableTask; -using Microsoft.Agents.AI.DurableTask.Workflows; -using Microsoft.Agents.AI.Workflows; -using Microsoft.DurableTask.Client.AzureManaged; -using Microsoft.DurableTask.Worker.AzureManaged; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using OpenAI.Chat; -using WorkflowConcurrency; - -// Configuration -string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING") - ?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None"; -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."); -string? azureOpenAiKey = Environment.GetEnvironmentVariable("AZURE_OPENAI_KEY"); - -// Create AI agents -AzureOpenAIClient openAiClient = !string.IsNullOrEmpty(azureOpenAiKey) - ? new AzureOpenAIClient(new Uri(endpoint), new AzureKeyCredential(azureOpenAiKey)) - : new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()); -ChatClient chatClient = openAiClient.GetChatClient(deploymentName); - -AIAgent biologist = chatClient.AsAIAgent("You are a biology expert. Explain concepts clearly in 2-3 sentences.", "Biologist"); -AIAgent physicist = chatClient.AsAIAgent("You are a physics expert. Explain concepts clearly in 2-3 sentences.", "Physicist"); -AIAgent chemist = chatClient.AsAIAgent("You are a chemistry expert. Explain concepts clearly in 2-3 sentences.", "Chemist"); - -// Create workflows -ParseQuestionExecutor questionParser = new(); -ResponseAggregatorExecutor responseAggregator = new(); - -Workflow physicsWorkflow = new WorkflowBuilder(questionParser) - .WithName("PhysicsExpertReview") - .AddEdge(questionParser, physicist) - .Build(); - -Workflow expertTeamWorkflow = new WorkflowBuilder(questionParser) -.WithName("ExpertTeamReview") -.AddFanOutEdge(questionParser, [biologist, physicist]) -.AddFanInBarrierEdge([biologist, physicist], responseAggregator) -.Build(); - -Workflow chemistryWorkflow = new WorkflowBuilder(questionParser) - .WithName("ChemistryExpertReview") - .AddEdge(questionParser, chemist) - .Build(); - -// Configure services - demonstrating all 3 methods (each can be called multiple times) -IHost host = Host.CreateDefaultBuilder(args) - .ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Warning)) - .ConfigureServices(services => - { - // METHOD 1: ConfigureDurableAgents - for standalone agents only - services.ConfigureDurableAgents( - options => options.AddAIAgent(biologist), - workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString), - clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString)); - - // METHOD 2: ConfigureDurableWorkflows - for workflows only - services.ConfigureDurableWorkflows(options => options.AddWorkflow(physicsWorkflow)); - - // METHOD 3: ConfigureDurableOptions - for both agents AND workflows - services.ConfigureDurableOptions(options => - { - options.Agents.AddAIAgent(chemist); - options.Workflows.AddWorkflow(expertTeamWorkflow); - }); - - // Second call to ConfigureDurableOptions (additive - adds to existing config) - services.ConfigureDurableOptions(options => options.Workflows.AddWorkflow(chemistryWorkflow)); - }) - .Build(); - -await host.StartAsync(); -IServiceProvider services = host.Services; -IWorkflowClient workflowClient = services.GetRequiredService(); - -// DEMO 1: Direct agent conversation (standalone agents) -Console.WriteLine("\n═══ DEMO 1: Direct Agent Conversation ═══\n"); - -AIAgent biologistProxy = services.GetRequiredKeyedService("Biologist"); -AgentSession session = await biologistProxy.CreateSessionAsync(); -AgentResponse response = await biologistProxy.RunAsync("What is photosynthesis?", session); -Console.WriteLine($"🧬 Biologist: {response.Text}\n"); - -AIAgent chemistProxy = services.GetRequiredKeyedService("Chemist"); -session = await chemistProxy.CreateSessionAsync(); -response = await chemistProxy.RunAsync("What is a chemical bond?", session); -Console.WriteLine($"🧪 Chemist: {response.Text}\n"); - -// DEMO 2: Single-agent workflow -Console.WriteLine("═══ DEMO 2: Single-Agent Workflow ═══\n"); -await RunWorkflowAsync(workflowClient, physicsWorkflow, "What is the relationship between energy and mass?"); - -// DEMO 3: Multi-agent workflow -Console.WriteLine("═══ DEMO 3: Multi-Agent Workflow ═══\n"); -await RunWorkflowAsync(workflowClient, expertTeamWorkflow, "How does radiation affect living cells?"); - -// DEMO 4: Workflow from second ConfigureDurableOptions call -Console.WriteLine("═══ DEMO 4: Workflow (added via 2nd ConfigureDurableOptions) ═══\n"); -await RunWorkflowAsync(workflowClient, chemistryWorkflow, "What happens during combustion?"); - -Console.WriteLine("\n✅ All demos completed!"); -await host.StopAsync(); - -// Helper method -static async Task RunWorkflowAsync(IWorkflowClient client, Workflow workflow, string question) -{ - Console.WriteLine($"📋 {workflow.Name}: \"{question}\""); - IWorkflowRun run = await client.RunAsync(workflow, question); - if (run is IAwaitableWorkflowRun awaitable) - { - string? result = await awaitable.WaitForCompletionAsync(); - Console.WriteLine($"✅ {result}\n"); - } -} diff --git a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/05_WorkflowEvents/05_WorkflowEvents.csproj b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/05_WorkflowEvents/05_WorkflowEvents.csproj deleted file mode 100644 index 09e20ef622f..00000000000 --- a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/05_WorkflowEvents/05_WorkflowEvents.csproj +++ /dev/null @@ -1,28 +0,0 @@ - - - net10.0 - Exe - enable - enable - WorkflowEvents - WorkflowEvents - - - - - - - - - - - - - - - diff --git a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/05_WorkflowEvents/Executors.cs b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/05_WorkflowEvents/Executors.cs deleted file mode 100644 index 47880f0fff9..00000000000 --- a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/05_WorkflowEvents/Executors.cs +++ /dev/null @@ -1,129 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Agents.AI.Workflows; - -namespace WorkflowEvents; - -// ═══════════════════════════════════════════════════════════════════════════════ -// Custom event types - callers observe these via WatchStreamAsync -// ═══════════════════════════════════════════════════════════════════════════════ - -internal sealed class OrderLookupStartedEvent(string orderId) : WorkflowEvent(orderId) -{ - public string OrderId { get; } = orderId; -} - -internal sealed class OrderFoundEvent(string customerName) : WorkflowEvent(customerName) -{ - public string CustomerName { get; } = customerName; -} - -internal sealed class CancellationProgressEvent(int percentComplete, string status) : WorkflowEvent(status) -{ - public int PercentComplete { get; } = percentComplete; - public string Status { get; } = status; -} - -internal sealed class OrderCancelledEvent() : WorkflowEvent("Order cancelled"); - -internal sealed class EmailSentEvent(string email) : WorkflowEvent(email) -{ - public string Email { get; } = email; -} - -// ═══════════════════════════════════════════════════════════════════════════════ -// Domain models -// ═══════════════════════════════════════════════════════════════════════════════ - -internal sealed record Order(string Id, DateTime OrderDate, bool IsCancelled, string? CancelReason, Customer Customer); - -internal sealed record Customer(string Name, string Email); - -// ═══════════════════════════════════════════════════════════════════════════════ -// Executors - emit events via AddEventAsync and YieldOutputAsync -// ═══════════════════════════════════════════════════════════════════════════════ - -/// -/// Looks up an order by ID, emitting progress events. -/// -internal sealed class OrderLookup() : Executor("OrderLookup") -{ - public override async ValueTask HandleAsync( - string message, - IWorkflowContext context, - CancellationToken cancellationToken = default) - { - await context.AddEventAsync(new OrderLookupStartedEvent(message), cancellationToken); - - // Simulate database lookup - await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); - - Order order = new( - Id: message, - OrderDate: DateTime.UtcNow.AddDays(-1), - IsCancelled: false, - CancelReason: "Customer requested cancellation", - Customer: new Customer(Name: "Jerry", Email: "jerry@example.com")); - - await context.AddEventAsync(new OrderFoundEvent(order.Customer.Name), cancellationToken); - - // YieldOutputAsync emits a WorkflowOutputEvent observable via streaming - await context.YieldOutputAsync(order, cancellationToken); - - return order; - } -} - -/// -/// Cancels an order, emitting progress events during the multi-step process. -/// -internal sealed class OrderCancel() : Executor("OrderCancel") -{ - public override async ValueTask HandleAsync( - Order message, - IWorkflowContext context, - CancellationToken cancellationToken = default) - { - await context.AddEventAsync(new CancellationProgressEvent(0, "Starting cancellation"), cancellationToken); - - // Simulate a multi-step cancellation process - await Task.Delay(TimeSpan.FromMilliseconds(500), cancellationToken); - await context.AddEventAsync(new CancellationProgressEvent(33, "Contacting payment provider"), cancellationToken); - - await Task.Delay(TimeSpan.FromMilliseconds(500), cancellationToken); - await context.AddEventAsync(new CancellationProgressEvent(66, "Processing refund"), cancellationToken); - - await Task.Delay(TimeSpan.FromMilliseconds(500), cancellationToken); - - Order cancelledOrder = message with { IsCancelled = true }; - await context.AddEventAsync(new CancellationProgressEvent(100, "Complete"), cancellationToken); - await context.AddEventAsync(new OrderCancelledEvent(), cancellationToken); - - await context.YieldOutputAsync(cancelledOrder, cancellationToken); - - return cancelledOrder; - } -} - -/// -/// Sends a cancellation confirmation email, emitting an event on completion. -/// -internal sealed class SendEmail() : Executor("SendEmail") -{ - public override async ValueTask HandleAsync( - Order message, - IWorkflowContext context, - CancellationToken cancellationToken = default) - { - // Simulate sending email - await Task.Delay(TimeSpan.FromMilliseconds(500), cancellationToken); - - string result = $"Cancellation email sent for order {message.Id} to {message.Customer.Email}."; - - await context.AddEventAsync(new EmailSentEvent(message.Customer.Email), cancellationToken); - - await context.YieldOutputAsync(result, cancellationToken); - - return result; - } -} diff --git a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/05_WorkflowEvents/Program.cs b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/05_WorkflowEvents/Program.cs deleted file mode 100644 index 3ddec1db376..00000000000 --- a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/05_WorkflowEvents/Program.cs +++ /dev/null @@ -1,138 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -// ═══════════════════════════════════════════════════════════════════════════════ -// SAMPLE: Workflow Events and Streaming -// ═══════════════════════════════════════════════════════════════════════════════ -// -// This sample demonstrates how to use IWorkflowContext event methods in executors -// and stream events from the caller side: -// -// 1. AddEventAsync - Emit custom events that callers can observe in real-time -// 2. StreamAsync - Start a workflow and obtain a streaming handle -// 3. WatchStreamAsync - Observe events as they occur (custom, framework, and terminal) -// -// The sample uses IWorkflowClient.StreamAsync to start a workflow and -// WatchStreamAsync to observe events as they occur in real-time. -// -// Workflow: OrderLookup -> OrderCancel -> SendEmail -// ═══════════════════════════════════════════════════════════════════════════════ - -using Microsoft.Agents.AI.DurableTask; -using Microsoft.Agents.AI.DurableTask.Workflows; -using Microsoft.Agents.AI.Workflows; -using Microsoft.DurableTask.Client.AzureManaged; -using Microsoft.DurableTask.Worker.AzureManaged; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using WorkflowEvents; - -// Get DTS connection string from environment variable -string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING") - ?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None"; - -// Define executors and build workflow -OrderLookup orderLookup = new(); -OrderCancel orderCancel = new(); -SendEmail sendEmail = new(); - -Workflow cancelOrder = new WorkflowBuilder(orderLookup) - .WithName("CancelOrder") - .WithDescription("Cancel an order and notify the customer") - .AddEdge(orderLookup, orderCancel) - .AddEdge(orderCancel, sendEmail) - .Build(); - -// Configure host with durable workflow support -IHost host = Host.CreateDefaultBuilder(args) - .ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Warning)) - .ConfigureServices(services => - { - services.ConfigureDurableWorkflows( - workflowOptions => workflowOptions.AddWorkflow(cancelOrder), - workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString), - clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString)); - }) - .Build(); - -await host.StartAsync(); - -IWorkflowClient workflowClient = host.Services.GetRequiredService(); - -Console.WriteLine("Workflow Events Demo - Enter order ID (or 'exit'):"); - -while (true) -{ - Console.Write("> "); - string? input = Console.ReadLine(); - if (string.IsNullOrWhiteSpace(input) || input.Equals("exit", StringComparison.OrdinalIgnoreCase)) - { - break; - } - - try - { - await RunWorkflowWithStreamingAsync(input, cancelOrder, workflowClient); - } - catch (Exception ex) - { - Console.WriteLine($"Error: {ex.Message}"); - } - - Console.WriteLine(); -} - -await host.StopAsync(); - -// Runs a workflow and streams events as they occur -static async Task RunWorkflowWithStreamingAsync(string orderId, Workflow workflow, IWorkflowClient client) -{ - // StreamAsync starts the workflow and returns a streaming handle for observing events - IStreamingWorkflowRun run = await client.StreamAsync(workflow, orderId); - Console.WriteLine($"Started run: {run.RunId}"); - - // WatchStreamAsync yields events as they're emitted by executors - await foreach (WorkflowEvent evt in run.WatchStreamAsync()) - { - Console.WriteLine($" New event received at {DateTime.Now:HH:mm:ss.ffff} ({evt.GetType().Name})"); - - switch (evt) - { - // Custom domain events (emitted via AddEventAsync) - case OrderLookupStartedEvent e: - WriteColored($" [Lookup] Looking up order {e.OrderId}", ConsoleColor.Cyan); - break; - case OrderFoundEvent e: - WriteColored($" [Lookup] Found: {e.CustomerName}", ConsoleColor.Cyan); - break; - case CancellationProgressEvent e: - WriteColored($" [Cancel] {e.PercentComplete}% - {e.Status}", ConsoleColor.Yellow); - break; - case OrderCancelledEvent: - WriteColored(" [Cancel] Done", ConsoleColor.Yellow); - break; - case EmailSentEvent e: - WriteColored($" [Email] Sent to {e.Email}", ConsoleColor.Magenta); - break; - - case WorkflowOutputEvent e: - WriteColored($" [Output] {e.ExecutorId}", ConsoleColor.DarkGray); - break; - - // Workflow completion - case DurableWorkflowCompletedEvent e: - WriteColored($" Completed: {e.Result}", ConsoleColor.Green); - break; - case DurableWorkflowFailedEvent e: - WriteColored($" Failed: {e.ErrorMessage}", ConsoleColor.Red); - break; - } - } -} - -static void WriteColored(string message, ConsoleColor color) -{ - Console.ForegroundColor = color; - Console.WriteLine(message); - Console.ResetColor(); -} diff --git a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/05_WorkflowEvents/README.md b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/05_WorkflowEvents/README.md deleted file mode 100644 index b519ec8d5c0..00000000000 --- a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/05_WorkflowEvents/README.md +++ /dev/null @@ -1,127 +0,0 @@ -# Workflow Events Sample - -This sample demonstrates how to use workflow events and streaming in durable workflows. - -## What it demonstrates - -1. **Custom Events** (`AddEventAsync`) — Executors emit domain-specific events during execution -2. **Event Streaming** (`StreamAsync` / `WatchStreamAsync`) — Callers observe events in real-time as the workflow progresses -3. **Framework Events** — Automatic `ExecutorInvokedEvent`, `ExecutorCompletedEvent`, and `WorkflowOutputEvent` events emitted by the framework - -## Emitting Custom Events - -Executors can emit custom domain events during execution using the `IWorkflowContext` instance passed to `HandleAsync`. These events are streamed to callers in real-time via `WatchStreamAsync`. - -### Defining a custom event - -Create a class that inherits from `WorkflowEvent`. Pass any data payload to the base constructor: - -```csharp -public class CancellationProgressEvent(int percentComplete, string status) : WorkflowEvent(status) -{ - public int PercentComplete { get; } = percentComplete; - public string Status { get; } = status; -} -``` - -### Emitting the event from an executor - -Call `AddEventAsync` on the `IWorkflowContext` inside your executor's `HandleAsync` method: - -```csharp -public override async ValueTask HandleAsync( - Order message, - IWorkflowContext context, - CancellationToken cancellationToken = default) -{ - await context.AddEventAsync(new CancellationProgressEvent(33, "Processing refund"), cancellationToken); - // ... rest of the executor logic -} -``` - -### Observing events from the caller - -Use `StreamAsync` to start the workflow and `WatchStreamAsync` to observe events. Pattern match on your custom event types: - -```csharp -IStreamingWorkflowRun run = await workflowClient.StreamAsync(workflow, input); - -await foreach (WorkflowEvent evt in run.WatchStreamAsync()) -{ - switch (evt) - { - case CancellationProgressEvent e: - Console.WriteLine($"{e.PercentComplete}% - {e.Status}"); - break; - } -} -``` - -## Workflow Structure - -``` -OrderLookup → OrderCancel → SendEmail -``` - -Each executor emits custom events during execution: -- `OrderLookup` emits `OrderLookupStartedEvent` and `OrderFoundEvent` -- `OrderCancel` emits `CancellationProgressEvent` (with percentage) and `OrderCancelledEvent` -- `SendEmail` emits `EmailSentEvent` - -## Prerequisites - -- [Durable Task Scheduler](https://learn.microsoft.com/en-us/azure/azure-functions/durable/durable-task-scheduler/durable-task-scheduler) running locally or in Azure -- Set the `DURABLE_TASK_SCHEDULER_CONNECTION_STRING` environment variable (defaults to local emulator) - -## 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 - -```bash -dotnet run -``` - -Enter an order ID at the prompt to start a workflow and watch events stream in real-time: - -```text -> order-42 -Started run: b6ba4d19... - New event received at 13:27:41.4956 (ExecutorInvokedEvent) - New event received at 13:27:41.5019 (OrderLookupStartedEvent) - [Lookup] Looking up order order-42 - New event received at 13:27:41.5025 (OrderFoundEvent) - [Lookup] Found: Jerry - New event received at 13:27:41.5026 (ExecutorCompletedEvent) - New event received at 13:27:41.5026 (WorkflowOutputEvent) - [Output] OrderLookup - New event received at 13:27:43.0772 (ExecutorInvokedEvent) - New event received at 13:27:43.0773 (CancellationProgressEvent) - [Cancel] 0% - Starting cancellation - New event received at 13:27:43.0775 (CancellationProgressEvent) - [Cancel] 33% - Contacting payment provider - New event received at 13:27:43.0776 (CancellationProgressEvent) - [Cancel] 66% - Processing refund - New event received at 13:27:43.0777 (CancellationProgressEvent) - [Cancel] 100% - Complete - New event received at 13:27:43.0779 (OrderCancelledEvent) - [Cancel] Done - New event received at 13:27:43.0780 (ExecutorCompletedEvent) - New event received at 13:27:43.0780 (WorkflowOutputEvent) - [Output] OrderCancel - New event received at 13:27:43.6610 (ExecutorInvokedEvent) - New event received at 13:27:43.6611 (EmailSentEvent) - [Email] Sent to jerry@example.com - New event received at 13:27:43.6613 (ExecutorCompletedEvent) - New event received at 13:27:43.6613 (WorkflowOutputEvent) - [Output] SendEmail - New event received at 13:27:43.6619 (DurableWorkflowCompletedEvent) - Completed: Cancellation email sent for order order-42 to jerry@example.com. -``` - -### Viewing Workflows in the DTS Dashboard - -After running a workflow, you can navigate to the Durable Task Scheduler (DTS) dashboard to inspect the workflow execution and events. - -If you are using the DTS emulator, the dashboard is available at `http://localhost:8082`. diff --git a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/06_WorkflowSharedState/06_WorkflowSharedState.csproj b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/06_WorkflowSharedState/06_WorkflowSharedState.csproj deleted file mode 100644 index c7efbb7d1bb..00000000000 --- a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/06_WorkflowSharedState/06_WorkflowSharedState.csproj +++ /dev/null @@ -1,29 +0,0 @@ - - - net10.0 - Exe - enable - enable - WorkflowSharedState - WorkflowSharedState - - - - - - - - - - - - - - - - diff --git a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/06_WorkflowSharedState/Executors.cs b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/06_WorkflowSharedState/Executors.cs deleted file mode 100644 index 57d2964c0cc..00000000000 --- a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/06_WorkflowSharedState/Executors.cs +++ /dev/null @@ -1,184 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Agents.AI.Workflows; - -namespace WorkflowSharedState; - -// ═══════════════════════════════════════════════════════════════════════════════ -// Domain models -// ═══════════════════════════════════════════════════════════════════════════════ - -/// -/// The primary order data passed through the pipeline via return values. -/// -internal sealed record OrderDetails(string OrderId, string CustomerName, decimal Amount, DateTime OrderDate); - -/// -/// Cross-cutting audit trail accumulated in shared state across executors. -/// Each executor appends its step name and timestamp. This data does not flow -/// through return values — it lives only in shared state. -/// -internal sealed record AuditEntry(string Step, string Timestamp, string Detail); - -// ═══════════════════════════════════════════════════════════════════════════════ -// Executors -// ═══════════════════════════════════════════════════════════════════════════════ - -/// -/// Validates the order and writes the initial audit entry and tax rate to shared state. -/// The order details are returned as the executor output (normal message flow), -/// while the audit trail and tax rate are stored in shared state (side-channel). -/// If the order ID starts with "INVALID", the executor halts the workflow early -/// using . -/// -[YieldsOutput(typeof(string))] -internal sealed class ValidateOrder() : Executor("ValidateOrder") -{ - public override async ValueTask HandleAsync( - string message, - IWorkflowContext context, - CancellationToken cancellationToken = default) - { - await Task.Delay(TimeSpan.FromMilliseconds(200), cancellationToken); - - // Halt the workflow early if the order ID is invalid. - // No downstream executors will run after this. - if (message.StartsWith("INVALID", StringComparison.OrdinalIgnoreCase)) - { - await context.YieldOutputAsync($"Order '{message}' failed validation. Halting workflow.", cancellationToken); - await context.RequestHaltAsync(); - return new OrderDetails(message, "Unknown", 0, DateTime.UtcNow); - } - - OrderDetails details = new(message, "Jerry", 249.99m, DateTime.UtcNow); - - // Store the tax rate in shared state — downstream ProcessPayment reads it - // without needing it in the message chain. - await context.QueueStateUpdateAsync("taxRate", 0.085m, cancellationToken: cancellationToken); - Console.WriteLine(" Wrote to shared state: taxRate = 8.5%"); - - // Start the audit trail in shared state - AuditEntry audit = new("ValidateOrder", DateTime.UtcNow.ToString("o"), $"Validated order {message}"); - await context.QueueStateUpdateAsync("auditValidate", audit, cancellationToken: cancellationToken); - Console.WriteLine(" Wrote to shared state: auditValidate"); - - await context.YieldOutputAsync($"Order '{message}' validated. Customer: {details.CustomerName}, Amount: {details.Amount:C}", cancellationToken); - - return details; - } -} - -/// -/// Enriches the order with shipping information. -/// Reads the audit trail from shared state and appends its own entry. -/// Uses ReadOrInitStateAsync to lazily initialize a shipping tier. -/// Demonstrates custom scopes by writing shipping details under the "shipping" scope. -/// -[YieldsOutput(typeof(string))] -internal sealed class EnrichOrder() : Executor("EnrichOrder") -{ - public override async ValueTask HandleAsync( - OrderDetails message, - IWorkflowContext context, - CancellationToken cancellationToken = default) - { - await Task.Delay(TimeSpan.FromMilliseconds(200), cancellationToken); - - // Use ReadOrInitStateAsync — only initializes if no value exists yet - string shippingTier = await context.ReadOrInitStateAsync( - "shippingTier", - () => "Express", - cancellationToken: cancellationToken); - Console.WriteLine($" Read from shared state: shippingTier = {shippingTier}"); - - // Write carrier under a custom "shipping" scope. - // This keeps the key separate from keys written without a scope, - // so "carrier" here won't collide with a "carrier" key written elsewhere. - await context.QueueStateUpdateAsync("carrier", "Contoso Express", scopeName: "shipping", cancellationToken: cancellationToken); - Console.WriteLine(" Wrote to shared state: carrier = Contoso Express (scope: shipping)"); - - // Verify we can read the audit entry from the previous step - AuditEntry? previousAudit = await context.ReadStateAsync("auditValidate", cancellationToken: cancellationToken); - string auditStatus = previousAudit is not null ? $"(previous step: {previousAudit.Step})" : "(no prior audit)"; - Console.WriteLine($" Read from shared state: auditValidate {auditStatus}"); - - // Append our own audit entry - AuditEntry audit = new("EnrichOrder", DateTime.UtcNow.ToString("o"), $"Enriched with {shippingTier} shipping {auditStatus}"); - await context.QueueStateUpdateAsync("auditEnrich", audit, cancellationToken: cancellationToken); - Console.WriteLine(" Wrote to shared state: auditEnrich"); - - await context.YieldOutputAsync($"Order enriched. Shipping: {shippingTier} {auditStatus}", cancellationToken); - - return message; - } -} - -/// -/// Processes payment using the tax rate from shared state (written by ValidateOrder). -/// The tax rate is side-channel data — it doesn't flow through return values. -/// -internal sealed class ProcessPayment() : Executor("ProcessPayment") -{ - public override async ValueTask HandleAsync( - OrderDetails message, - IWorkflowContext context, - CancellationToken cancellationToken = default) - { - await Task.Delay(TimeSpan.FromMilliseconds(300), cancellationToken); - - // Read tax rate written by ValidateOrder — not available in the message chain - decimal taxRate = await context.ReadOrInitStateAsync("taxRate", () => 0.0m, cancellationToken: cancellationToken); - Console.WriteLine($" Read from shared state: taxRate = {taxRate:P1}"); - - decimal tax = message.Amount * taxRate; - decimal total = message.Amount + tax; - string paymentRef = $"PAY-{Guid.NewGuid():N}"[..16]; - - // Append audit entry - AuditEntry audit = new("ProcessPayment", DateTime.UtcNow.ToString("o"), $"Charged {total:C} (tax: {tax:C})"); - await context.QueueStateUpdateAsync("auditPayment", audit, cancellationToken: cancellationToken); - Console.WriteLine(" Wrote to shared state: auditPayment"); - - await context.YieldOutputAsync($"Payment processed. Total: {total:C} (tax: {tax:C}). Ref: {paymentRef}", cancellationToken); - - return paymentRef; - } -} - -/// -/// Generates the final invoice by reading the full audit trail from shared state. -/// Demonstrates reading multiple state entries written by different executors -/// and clearing a scope with . -/// -internal sealed class GenerateInvoice() : Executor("GenerateInvoice") -{ - public override async ValueTask HandleAsync( - string message, - IWorkflowContext context, - CancellationToken cancellationToken = default) - { - await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken); - - // Read the full audit trail from shared state — each step wrote its own entry - AuditEntry? validateAudit = await context.ReadStateAsync("auditValidate", cancellationToken: cancellationToken); - AuditEntry? enrichAudit = await context.ReadStateAsync("auditEnrich", cancellationToken: cancellationToken); - AuditEntry? paymentAudit = await context.ReadStateAsync("auditPayment", cancellationToken: cancellationToken); - int auditCount = new[] { validateAudit, enrichAudit, paymentAudit }.Count(a => a is not null); - Console.WriteLine($" Read from shared state: {auditCount} audit entries"); - - // Read carrier from the "shipping" scope (written by EnrichOrder) - string? carrier = await context.ReadStateAsync("carrier", scopeName: "shipping", cancellationToken: cancellationToken); - Console.WriteLine($" Read from shared state: carrier = {carrier} (scope: shipping)"); - - // Clear the "shipping" scope — no longer needed after invoice generation. - await context.QueueClearScopeAsync("shipping", cancellationToken); - Console.WriteLine(" Cleared shared state scope: shipping"); - - string auditSummary = string.Join(" → ", new[] - { - validateAudit?.Step, enrichAudit?.Step, paymentAudit?.Step - }.Where(s => s is not null)); - - return $"Invoice complete. Payment: {message}. Audit trail: [{auditSummary}]"; - } -} diff --git a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/06_WorkflowSharedState/Program.cs b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/06_WorkflowSharedState/Program.cs deleted file mode 100644 index 2513cc2dad4..00000000000 --- a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/06_WorkflowSharedState/Program.cs +++ /dev/null @@ -1,117 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -// ═══════════════════════════════════════════════════════════════════════════════ -// SAMPLE: Shared State During Workflow Execution -// ═══════════════════════════════════════════════════════════════════════════════ -// -// This sample demonstrates how executors in a durable workflow can share state -// via IWorkflowContext. State is persisted across supersteps and survives -// process restarts because the orchestration passes it to each activity. -// -// Key concepts: -// 1. QueueStateUpdateAsync - Write a value to shared state -// 2. ReadStateAsync - Read a value written by a previous executor -// 3. ReadOrInitStateAsync - Read or lazily initialize a state value -// 4. QueueClearScopeAsync - Clear all entries under a scope -// 5. RequestHaltAsync - Stop the workflow early (e.g., validation failure) -// -// Workflow: ValidateOrder -> EnrichOrder -> ProcessPayment -> GenerateInvoice -// -// Return values carry primary business data through the pipeline (OrderDetails, -// payment ref). Shared state carries side-channel data that doesn't belong in -// the message chain: a tax rate (set by ValidateOrder, read by ProcessPayment) -// and an audit trail (each executor appends its own entry). -// ═══════════════════════════════════════════════════════════════════════════════ - -using Microsoft.Agents.AI.DurableTask; -using Microsoft.Agents.AI.DurableTask.Workflows; -using Microsoft.Agents.AI.Workflows; -using Microsoft.DurableTask.Client.AzureManaged; -using Microsoft.DurableTask.Worker.AzureManaged; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using WorkflowSharedState; - -// Get DTS connection string from environment variable -string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING") - ?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None"; - -// Define executors -ValidateOrder validateOrder = new(); -EnrichOrder enrichOrder = new(); -ProcessPayment processPayment = new(); -GenerateInvoice generateInvoice = new(); - -// Build the workflow: ValidateOrder -> EnrichOrder -> ProcessPayment -> GenerateInvoice -Workflow orderPipeline = new WorkflowBuilder(validateOrder) - .WithName("OrderPipeline") - .WithDescription("Order processing pipeline with shared state across executors") - .AddEdge(validateOrder, enrichOrder) - .AddEdge(enrichOrder, processPayment) - .AddEdge(processPayment, generateInvoice) - .Build(); - -// Configure host with durable workflow support -IHost host = Host.CreateDefaultBuilder(args) - .ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Warning)) - .ConfigureServices(services => - { - services.ConfigureDurableWorkflows( - workflowOptions => workflowOptions.AddWorkflow(orderPipeline), - workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString), - clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString)); - }) - .Build(); - -await host.StartAsync(); - -IWorkflowClient workflowClient = host.Services.GetRequiredService(); - -Console.WriteLine("Shared State Workflow Demo"); -Console.WriteLine("Workflow: ValidateOrder -> EnrichOrder -> ProcessPayment -> GenerateInvoice"); -Console.WriteLine(); -Console.WriteLine("Enter an order ID (or 'exit'):"); - -while (true) -{ - Console.Write("> "); - string? input = Console.ReadLine(); - if (string.IsNullOrWhiteSpace(input) || input.Equals("exit", StringComparison.OrdinalIgnoreCase)) - { - break; - } - - try - { - // Start the workflow and stream events to see shared state in action - IStreamingWorkflowRun run = await workflowClient.StreamAsync(orderPipeline, input); - Console.WriteLine($"Started run: {run.RunId}"); - - await foreach (WorkflowEvent evt in run.WatchStreamAsync()) - { - switch (evt) - { - case WorkflowOutputEvent e: - Console.WriteLine($" [Output] {e.ExecutorId}: {e.Data}"); - break; - - case DurableWorkflowCompletedEvent e: - Console.WriteLine($" Completed: {e.Result}"); - break; - - case DurableWorkflowFailedEvent e: - Console.WriteLine($" Failed: {e.ErrorMessage}"); - break; - } - } - } - catch (Exception ex) - { - Console.WriteLine($"Error: {ex.Message}"); - } - - Console.WriteLine(); -} - -await host.StopAsync(); diff --git a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/06_WorkflowSharedState/README.md b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/06_WorkflowSharedState/README.md deleted file mode 100644 index 31ff55ce84d..00000000000 --- a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/06_WorkflowSharedState/README.md +++ /dev/null @@ -1,71 +0,0 @@ -# Shared State Workflow Sample - -This sample demonstrates how executors in a durable workflow can share state via `IWorkflowContext`. State written by one executor is accessible to all downstream executors, persisted across supersteps, and survives process restarts. - -## Key Concepts Demonstrated - -- Writing state with `QueueStateUpdateAsync` — executors store data for downstream executors -- Reading state with `ReadStateAsync` — executors access data written by earlier executors -- Lazy initialization with `ReadOrInitStateAsync` — initialize state only if not already present -- Custom scopes with `scopeName` — partition state into isolated namespaces (e.g., `"shipping"`) -- Clearing scopes with `QueueClearScopeAsync` — remove all entries under a scope when no longer needed -- Early termination with `RequestHaltAsync` — halt the workflow when validation fails -- State persistence across supersteps — the orchestration passes shared state to each executor -- Event streaming with `IStreamingWorkflowRun` — observe executor progress in real time - -## Workflow - -**OrderPipeline**: `ValidateOrder` → `EnrichOrder` → `ProcessPayment` → `GenerateInvoice` - -Return values carry primary business data through the pipeline (`OrderDetails` → `OrderDetails` → payment ref → invoice string). Shared state carries side-channel data that doesn't belong in the message chain: - -| Executor | Returns (message flow) | Reads from State | Writes to State | -|----------|----------------------|-----------------|-----------------| -| **ValidateOrder** | `OrderDetails` | — | `taxRate`, `auditValidate` | -| **EnrichOrder** | `OrderDetails` (pass-through) | `auditValidate` | `shippingTier`, `auditEnrich`, `carrier` (scope: shipping) | -| **ProcessPayment** | payment ref string | `taxRate` | `auditPayment` | -| **GenerateInvoice** | invoice string | `auditValidate`, `auditEnrich`, `auditPayment`, `carrier` (scope: shipping) | clears `shipping` scope | - -> [!NOTE] -> `EnrichOrder` writes `carrier` under the `"shipping"` scope using `scopeName: "shipping"`. This keeps the key separate from keys written without a scope, so `"carrier"` in the `"shipping"` scope won't collide with a `"carrier"` key written elsewhere. - -## 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 - -```bash -dotnet run -``` - -Enter an order ID when prompted. The workflow will process the order through all four executors, streaming events as they occur: - -```text -> ORD-001 -Started run: abc123 - Wrote to shared state: taxRate = 8.5% - Wrote to shared state: auditValidate - [Output] ValidateOrder: Order 'ORD-001' validated. Customer: Jerry, Amount: $249.99 - Read from shared state: shippingTier = Express - Wrote to shared state: carrier = Contoso Express (scope: shipping) - Read from shared state: auditValidate (previous step: ValidateOrder) - Wrote to shared state: auditEnrich - [Output] EnrichOrder: Order enriched. Shipping: Express (previous step: ValidateOrder) - Read from shared state: taxRate = 8.5% - Wrote to shared state: auditPayment - [Output] ProcessPayment: Payment processed. Total: $271.24 (tax: $21.25). Ref: PAY-abc123def456 - Read from shared state: 3 audit entries - Read from shared state: carrier = Contoso Express (scope: shipping) - Cleared shared state scope: shipping - [Output] GenerateInvoice: Invoice complete. Payment: "PAY-abc123def456". Audit trail: [ValidateOrder → EnrichOrder → ProcessPayment] - Completed: Invoice complete. Payment: "PAY-abc123def456". Audit trail: [ValidateOrder → EnrichOrder → ProcessPayment] -``` - -### Viewing Workflows in the DTS Dashboard - -After running a workflow, you can navigate to the Durable Task Scheduler (DTS) dashboard to inspect the orchestration status, executor inputs/outputs, and events. - -If you are using the DTS emulator, the dashboard is available at `http://localhost:8082`. - -To inspect shared state in the dashboard, click on an executor to view its input and output. The input contains a snapshot of the shared state the executor ran with, and the output includes any state updates it made (as `stateUpdates` with scoped keys). diff --git a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/07_SubWorkflows/07_SubWorkflows.csproj b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/07_SubWorkflows/07_SubWorkflows.csproj deleted file mode 100644 index d8d36ead015..00000000000 --- a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/07_SubWorkflows/07_SubWorkflows.csproj +++ /dev/null @@ -1,28 +0,0 @@ - - - net10.0 - Exe - enable - enable - SubWorkflows - SubWorkflows - - - - - - - - - - - - - - - diff --git a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/07_SubWorkflows/Executors.cs b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/07_SubWorkflows/Executors.cs deleted file mode 100644 index 121db7af67e..00000000000 --- a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/07_SubWorkflows/Executors.cs +++ /dev/null @@ -1,232 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Agents.AI.Workflows; - -namespace SubWorkflows; - -/// -/// Event emitted when the fraud check risk score is calculated. -/// -internal sealed class FraudRiskAssessedEvent(int riskScore) : WorkflowEvent($"Risk score: {riskScore}/100") -{ - public int RiskScore => riskScore; -} - -/// -/// Represents an order being processed through the workflow. -/// -internal sealed class OrderInfo -{ - public required string OrderId { get; set; } - - public decimal Amount { get; set; } - - public string? PaymentTransactionId { get; set; } - - public string? TrackingNumber { get; set; } - - public string? Carrier { get; set; } -} - -// Main workflow executors - -/// -/// Entry point executor that receives the order ID and creates an OrderInfo object. -/// -internal sealed class OrderReceived() : Executor("OrderReceived") -{ - public override ValueTask HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default) - { - Console.WriteLine(); - Console.ForegroundColor = ConsoleColor.Cyan; - Console.WriteLine($"[OrderReceived] Processing order '{message}'"); - Console.ResetColor(); - - OrderInfo order = new() - { - OrderId = message, - Amount = 99.99m // Simulated order amount - }; - - return ValueTask.FromResult(order); - } -} - -/// -/// Final executor that outputs the completed order summary. -/// -internal sealed class OrderCompleted() : Executor("OrderCompleted") -{ - public override ValueTask HandleAsync(OrderInfo message, IWorkflowContext context, CancellationToken cancellationToken = default) - { - Console.WriteLine(); - Console.ForegroundColor = ConsoleColor.Green; - Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐"); - Console.WriteLine($"│ [OrderCompleted] Order '{message.OrderId}' successfully processed!"); - Console.WriteLine($"│ Payment: {message.PaymentTransactionId}"); - Console.WriteLine($"│ Shipping: {message.Carrier} - {message.TrackingNumber}"); - Console.WriteLine("└─────────────────────────────────────────────────────────────────┘"); - Console.ResetColor(); - - return ValueTask.FromResult($"Order {message.OrderId} completed. Tracking: {message.TrackingNumber}"); - } -} - -// Payment sub-workflow executors - -/// -/// Validates payment information for an order. -/// -internal sealed class ValidatePayment() : Executor("ValidatePayment") -{ - public override async ValueTask HandleAsync(OrderInfo message, IWorkflowContext context, CancellationToken cancellationToken = default) - { - Console.WriteLine(); - Console.ForegroundColor = ConsoleColor.Yellow; - Console.WriteLine($" [Payment/ValidatePayment] Validating payment for order '{message.OrderId}'..."); - Console.ResetColor(); - - await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken); - - Console.ForegroundColor = ConsoleColor.Yellow; - Console.WriteLine($" [Payment/ValidatePayment] Payment validated for ${message.Amount}"); - Console.ResetColor(); - - return message; - } -} - -/// -/// Charges the payment for an order. -/// -internal sealed class ChargePayment() : Executor("ChargePayment") -{ - public override async ValueTask HandleAsync(OrderInfo message, IWorkflowContext context, CancellationToken cancellationToken = default) - { - Console.ForegroundColor = ConsoleColor.Yellow; - Console.WriteLine($" [Payment/ChargePayment] Charging ${message.Amount} for order '{message.OrderId}'..."); - Console.ResetColor(); - - await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken); - - message.PaymentTransactionId = $"TXN-{Guid.NewGuid().ToString("N")[..8].ToUpperInvariant()}"; - - Console.ForegroundColor = ConsoleColor.Yellow; - Console.WriteLine($" [Payment/ChargePayment] ✓ Payment processed: {message.PaymentTransactionId}"); - Console.ResetColor(); - - return message; - } -} - -// FraudCheck sub-sub-workflow executors (nested inside Payment) - -/// -/// Analyzes transaction patterns for potential fraud. -/// -internal sealed class AnalyzePatterns() : Executor("AnalyzePatterns") -{ - public override async ValueTask HandleAsync(OrderInfo message, IWorkflowContext context, CancellationToken cancellationToken = default) - { - Console.ForegroundColor = ConsoleColor.DarkYellow; - Console.WriteLine($" [Payment/FraudCheck/AnalyzePatterns] Analyzing patterns for order '{message.OrderId}'..."); - Console.ResetColor(); - - await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken); - - // Store analysis results in shared state for the next executor in this sub-workflow - int patternsFound = new Random().Next(0, 5); - await context.QueueStateUpdateAsync("patternsFound", patternsFound, cancellationToken: cancellationToken); - - Console.ForegroundColor = ConsoleColor.DarkYellow; - Console.WriteLine($" [Payment/FraudCheck/AnalyzePatterns] ✓ Pattern analysis complete ({patternsFound} suspicious patterns)"); - Console.ResetColor(); - - return message; - } -} - -/// -/// Calculates a risk score for the transaction. -/// -internal sealed class CalculateRiskScore() : Executor("CalculateRiskScore") -{ - public override async ValueTask HandleAsync(OrderInfo message, IWorkflowContext context, CancellationToken cancellationToken = default) - { - Console.ForegroundColor = ConsoleColor.DarkYellow; - Console.WriteLine($" [Payment/FraudCheck/CalculateRiskScore] Calculating risk score for order '{message.OrderId}'..."); - Console.ResetColor(); - - await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken); - - // Read the pattern count from shared state (written by AnalyzePatterns) - int patternsFound = await context.ReadStateAsync("patternsFound", cancellationToken: cancellationToken); - int riskScore = Math.Min(patternsFound * 20 + new Random().Next(1, 20), 100); - - // Emit a workflow event from within a nested sub-workflow - await context.AddEventAsync(new FraudRiskAssessedEvent(riskScore), cancellationToken); - - Console.ForegroundColor = ConsoleColor.DarkYellow; - Console.WriteLine($" [Payment/FraudCheck/CalculateRiskScore] ✓ Risk score: {riskScore}/100 (based on {patternsFound} patterns)"); - Console.ResetColor(); - - return message; - } -} - -// Shipping sub-workflow executors - -/// -/// Selects a shipping carrier for an order. -/// -/// -/// This executor uses (void return) combined with -/// to forward the order to the next -/// connected executor (CreateShipment). This demonstrates explicit typed message passing -/// as an alternative to returning a value from the handler. -/// -internal sealed class SelectCarrier() : Executor("SelectCarrier") -{ - public override async ValueTask HandleAsync(OrderInfo message, IWorkflowContext context, CancellationToken cancellationToken = default) - { - Console.WriteLine(); - Console.ForegroundColor = ConsoleColor.Blue; - Console.WriteLine($" [Shipping/SelectCarrier] Selecting carrier for order '{message.OrderId}'..."); - Console.ResetColor(); - - await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken); - - message.Carrier = message.Amount > 50 ? "Express" : "Standard"; - - Console.ForegroundColor = ConsoleColor.Blue; - Console.WriteLine($" [Shipping/SelectCarrier] ✓ Selected carrier: {message.Carrier}"); - Console.ResetColor(); - - // Use SendMessageAsync to forward the updated order to connected executors. - // With a void-return executor, this is the mechanism for passing data downstream. - await context.SendMessageAsync(message, cancellationToken: cancellationToken); - } -} - -/// -/// Creates shipment and generates tracking number. -/// -internal sealed class CreateShipment() : Executor("CreateShipment") -{ - public override async ValueTask HandleAsync(OrderInfo message, IWorkflowContext context, CancellationToken cancellationToken = default) - { - Console.ForegroundColor = ConsoleColor.Blue; - Console.WriteLine($" [Shipping/CreateShipment] Creating shipment for order '{message.OrderId}'..."); - Console.ResetColor(); - - await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken); - - message.TrackingNumber = $"TRACK-{Guid.NewGuid().ToString("N")[..10].ToUpperInvariant()}"; - - Console.ForegroundColor = ConsoleColor.Blue; - Console.WriteLine($" [Shipping/CreateShipment] ✓ Shipment created: {message.TrackingNumber}"); - Console.ResetColor(); - - return message; - } -} diff --git a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/07_SubWorkflows/Program.cs b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/07_SubWorkflows/Program.cs deleted file mode 100644 index d542f4aba57..00000000000 --- a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/07_SubWorkflows/Program.cs +++ /dev/null @@ -1,146 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -// This sample demonstrates nested sub-workflows. A sub-workflow can act as an executor -// within another workflow, including multi-level nesting (sub-workflow within sub-workflow). - -using Microsoft.Agents.AI.DurableTask; -using Microsoft.Agents.AI.DurableTask.Workflows; -using Microsoft.Agents.AI.Workflows; -using Microsoft.DurableTask.Client.AzureManaged; -using Microsoft.DurableTask.Worker.AzureManaged; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using SubWorkflows; - -// Get DTS connection string from environment variable -string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING") - ?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None"; - -// Build the FraudCheck sub-workflow (this will be nested inside the Payment sub-workflow) -AnalyzePatterns analyzePatterns = new(); -CalculateRiskScore calculateRiskScore = new(); - -Workflow fraudCheckWorkflow = new WorkflowBuilder(analyzePatterns) - .WithName("SubFraudCheck") - .WithDescription("Analyzes transaction patterns and calculates risk score") - .AddEdge(analyzePatterns, calculateRiskScore) - .Build(); - -// Build the Payment sub-workflow: ValidatePayment -> FraudCheck (sub-workflow) -> ChargePayment -ValidatePayment validatePayment = new(); -ExecutorBinding fraudCheckExecutor = fraudCheckWorkflow.BindAsExecutor("FraudCheck"); -ChargePayment chargePayment = new(); - -Workflow paymentWorkflow = new WorkflowBuilder(validatePayment) - .WithName("SubPaymentProcessing") - .WithDescription("Validates and processes payment for an order") - .AddEdge(validatePayment, fraudCheckExecutor) - .AddEdge(fraudCheckExecutor, chargePayment) - .Build(); - -// Build the Shipping sub-workflow: SelectCarrier -> CreateShipment -SelectCarrier selectCarrier = new(); -CreateShipment createShipment = new(); - -Workflow shippingWorkflow = new WorkflowBuilder(selectCarrier) - .WithName("SubShippingArrangement") - .WithDescription("Selects carrier and creates shipment") - .AddEdge(selectCarrier, createShipment) - .Build(); - -// Build the main workflow using sub-workflows as executors -// OrderReceived -> Payment (sub-workflow) -> Shipping (sub-workflow) -> OrderCompleted -OrderReceived orderReceived = new(); -OrderCompleted orderCompleted = new(); -ExecutorBinding paymentExecutor = paymentWorkflow.BindAsExecutor("Payment"); -ExecutorBinding shippingExecutor = shippingWorkflow.BindAsExecutor("Shipping"); - -Workflow orderProcessingWorkflow = new WorkflowBuilder(orderReceived) - .WithName("OrderProcessing") - .WithDescription("Processes an order through payment and shipping") - .AddEdge(orderReceived, paymentExecutor) - .AddEdge(paymentExecutor, shippingExecutor) - .AddEdge(shippingExecutor, orderCompleted) - .Build(); - -// Configure and start the host -// Register only the main workflow - sub-workflows are discovered automatically! -IHost host = Host.CreateDefaultBuilder(args) - .ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Warning)) - .ConfigureServices(services => - { - services.ConfigureDurableWorkflows( - workflowOptions => workflowOptions.AddWorkflow(orderProcessingWorkflow), - workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString), - clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString)); - }) - .Build(); - -await host.StartAsync(); - -IWorkflowClient workflowClient = host.Services.GetRequiredService(); - -Console.WriteLine("Durable Sub-Workflows Sample"); -Console.WriteLine("Workflow: OrderReceived -> Payment(sub) -> Shipping(sub) -> OrderCompleted"); -Console.WriteLine(" Payment contains nested FraudCheck sub-workflow (Level 2 nesting)"); -Console.WriteLine(); -Console.WriteLine("Enter an order ID (or 'exit'):"); - -while (true) -{ - Console.Write("> "); - string? input = Console.ReadLine(); - if (string.IsNullOrWhiteSpace(input) || input.Equals("exit", StringComparison.OrdinalIgnoreCase)) - { - break; - } - - try - { - await StartNewWorkflowAsync(input, orderProcessingWorkflow, workflowClient); - } - catch (Exception ex) - { - Console.WriteLine($"Error: {ex.Message}"); - } - - Console.WriteLine(); -} - -await host.StopAsync(); - -// Start a new workflow using streaming to observe events (including from sub-workflows) -static async Task StartNewWorkflowAsync(string orderId, Workflow workflow, IWorkflowClient client) -{ - Console.WriteLine($"\nStarting order processing for '{orderId}'..."); - - IStreamingWorkflowRun run = await client.StreamAsync(workflow, orderId); - Console.WriteLine($"Run ID: {run.RunId}"); - Console.WriteLine(); - - await foreach (WorkflowEvent evt in run.WatchStreamAsync()) - { - switch (evt) - { - // Custom event emitted from the FraudCheck sub-sub-workflow - case FraudRiskAssessedEvent e: - Console.ForegroundColor = ConsoleColor.DarkYellow; - Console.WriteLine($" [Event from sub-workflow] {e.GetType().Name}: Risk score {e.RiskScore}/100"); - Console.ResetColor(); - break; - - case DurableWorkflowCompletedEvent e: - Console.ForegroundColor = ConsoleColor.Green; - Console.WriteLine($"✓ Order completed: {e.Result}"); - Console.ResetColor(); - break; - - case DurableWorkflowFailedEvent e: - Console.ForegroundColor = ConsoleColor.Red; - Console.WriteLine($"✗ Failed: {e.ErrorMessage}"); - Console.ResetColor(); - break; - } - } -} diff --git a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/07_SubWorkflows/README.md b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/07_SubWorkflows/README.md deleted file mode 100644 index 83968eee0ea..00000000000 --- a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/07_SubWorkflows/README.md +++ /dev/null @@ -1,105 +0,0 @@ -# Sub-Workflows Sample (Nested Workflows) - -This sample demonstrates how to compose complex workflows from simpler, reusable sub-workflows. Sub-workflows are built using `WorkflowBuilder` and embedded as executors via `BindAsExecutor()`. Unlike the in-process workflow runner, the durable workflow backend persists execution state across process restarts — each sub-workflow runs as a separate orchestration instance on the Durable Task Scheduler, providing independent checkpointing, fault tolerance, and hierarchical visualization in the DTS dashboard. - -## Key Concepts Demonstrated - -- **Sub-workflows**: Using `Workflow.BindAsExecutor()` to embed a workflow as an executor in another workflow -- **Multi-level nesting**: Sub-workflows within sub-workflows (Level 2 nesting) -- **Automatic discovery**: Registering only the main workflow; sub-workflows are discovered automatically -- **Failure isolation**: Each sub-workflow runs as a separate orchestration instance on the DTS backend -- **Hierarchical visualization**: Parent-child orchestration hierarchy visible in the DTS dashboard -- **Event propagation**: Custom workflow events (`FraudRiskAssessedEvent`) bubble up from nested sub-workflows to the streaming client -- **Message passing**: Using `Executor` (void return) with `SendMessageAsync` to forward typed messages to connected executors (`SelectCarrier`) -- **Shared state within sub-workflows**: Using `QueueStateUpdateAsync`/`ReadStateAsync` to share data between executors within a sub-workflow (`AnalyzePatterns` → `CalculateRiskScore`) - -## Overview - -The sample implements an order processing workflow composed of two sub-workflows, one of which contains its own nested sub-workflow: - -``` -OrderProcessing (main workflow) -├── OrderReceived -├── Payment (sub-workflow) -│ ├── ValidatePayment -│ ├── FraudCheck (sub-sub-workflow) ← Level 2 nesting! -│ │ ├── AnalyzePatterns -│ │ └── CalculateRiskScore -│ └── ChargePayment -├── Shipping (sub-workflow) -│ ├── SelectCarrier ← Uses SendMessageAsync (void-return executor) -│ └── CreateShipment -└── OrderCompleted -``` - -| Executor | Sub-Workflow | Description | -|----------|-------------|-------------| -| OrderReceived | Main | Receives order ID and creates order info | -| ValidatePayment | Payment | Validates payment information | -| AnalyzePatterns | FraudCheck (nested in Payment) | Analyzes transaction patterns, stores results in shared state | -| CalculateRiskScore | FraudCheck (nested in Payment) | Reads shared state, calculates risk score, emits `FraudRiskAssessedEvent` | -| ChargePayment | Payment | Charges payment amount | -| SelectCarrier | Shipping | Selects carrier using `SendMessageAsync` (void-return executor) | -| CreateShipment | Shipping | Creates shipment with tracking | -| OrderCompleted | Main | Outputs completed order summary | - -## How Sub-Workflows Work - -For an introduction to sub-workflows and the `BindAsExecutor()` API, see the [Sub-Workflows foundational sample](../../../../03-workflows/_StartHere/05_SubWorkflows). - -This durable sample extends the same pattern — the key difference is that each sub-workflow runs as a **separate orchestration instance** on the Durable Task Scheduler, providing independent checkpointing, fault tolerance, and hierarchical visualization in the DTS dashboard. - -## Environment Setup - -See the [README.md](../../README.md) file in the parent directory for information on configuring the environment, including how to install and run the Durable Task Scheduler. - -## Running the Sample - -```bash -cd dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/07_SubWorkflows -dotnet run --framework net10.0 -``` - -### Sample Output - -```text -Durable Sub-Workflows Sample -Workflow: OrderReceived -> Payment(sub) -> Shipping(sub) -> OrderCompleted - Payment contains nested FraudCheck sub-workflow (Level 2 nesting) - -Enter an order ID (or 'exit'): -> ORD-001 -Starting order processing for 'ORD-001'... -Run ID: abc123... - -[OrderReceived] Processing order 'ORD-001' - [Payment/ValidatePayment] Validating payment for order 'ORD-001'... - [Payment/ValidatePayment] Payment validated for $99.99 - [Payment/FraudCheck/AnalyzePatterns] Analyzing patterns for order 'ORD-001'... - [Payment/FraudCheck/AnalyzePatterns] ✓ Pattern analysis complete (2 suspicious patterns) - [Payment/FraudCheck/CalculateRiskScore] Calculating risk score for order 'ORD-001'... - [Payment/FraudCheck/CalculateRiskScore] ✓ Risk score: 53/100 (based on 2 patterns) - [Event from sub-workflow] FraudRiskAssessedEvent: Risk score 53/100 - [Payment/ChargePayment] Charging $99.99 for order 'ORD-001'... - [Payment/ChargePayment] ✓ Payment processed: TXN-A1B2C3D4 - [Shipping/SelectCarrier] Selecting carrier for order 'ORD-001'... - [Shipping/SelectCarrier] ✓ Selected carrier: Express - [Shipping/CreateShipment] Creating shipment for order 'ORD-001'... - [Shipping/CreateShipment] ✓ Shipment created: TRACK-I9J0K1L2M3 -┌─────────────────────────────────────────────────────────────────┐ -│ [OrderCompleted] Order 'ORD-001' successfully processed! -│ Payment: TXN-A1B2C3D4 -│ Shipping: Express - TRACK-I9J0K1L2M3 -└─────────────────────────────────────────────────────────────────┘ -✓ Order completed: Order ORD-001 completed. Tracking: TRACK-I9J0K1L2M3 - -> exit -``` - -### Viewing Workflows in the DTS Dashboard - -After running the workflow, you can navigate to the Durable Task Scheduler (DTS) dashboard to inspect the orchestration hierarchy, including sub-orchestrations. - -If you are using the DTS emulator, the dashboard is available at `http://localhost:8082`. - -Because each sub-workflow runs as a separate orchestration instance, the dashboard shows a parent-child hierarchy: the top-level `OrderProcessing` orchestration with `Payment` and `Shipping` as child orchestrations, and `FraudCheck` nested under `Payment`. You can click into each orchestration to inspect its executor inputs/outputs, events, and execution timeline independently. diff --git a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/08_WorkflowHITL/08_WorkflowHITL.csproj b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/08_WorkflowHITL/08_WorkflowHITL.csproj deleted file mode 100644 index a9103b6e48e..00000000000 --- a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/08_WorkflowHITL/08_WorkflowHITL.csproj +++ /dev/null @@ -1,28 +0,0 @@ - - - net10.0 - Exe - enable - enable - WorkflowHITL - WorkflowHITL - - - - - - - - - - - - - - - diff --git a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/08_WorkflowHITL/Executors.cs b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/08_WorkflowHITL/Executors.cs deleted file mode 100644 index 2006b1cd193..00000000000 --- a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/08_WorkflowHITL/Executors.cs +++ /dev/null @@ -1,81 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Agents.AI.Workflows; - -namespace WorkflowHITL; - -/// -/// Represents an expense approval request. -/// -/// The unique identifier of the expense. -/// The amount of the expense. -/// The name of the employee submitting the expense. -public record ApprovalRequest(string ExpenseId, decimal Amount, string EmployeeName); - -/// -/// Represents the response to an approval request. -/// -/// Whether the expense was approved. -/// Optional comments from the approver. -public record ApprovalResponse(bool Approved, string? Comments); - -/// -/// Retrieves expense details and creates an approval request. -/// -internal sealed class CreateApprovalRequest() : Executor("RetrieveRequest") -{ - /// - public override ValueTask HandleAsync( - string message, - IWorkflowContext context, - CancellationToken cancellationToken = default) - { - // In a real scenario, this would look up expense details from a database - return new ValueTask(new ApprovalRequest(message, 1500.00m, "Jerry")); - } -} - -/// -/// Prepares the approval request for finance review after manager approval. -/// -internal sealed class PrepareFinanceReview() : Executor("PrepareFinanceReview") -{ - /// - public override ValueTask HandleAsync( - ApprovalResponse message, - IWorkflowContext context, - CancellationToken cancellationToken = default) - { - if (!message.Approved) - { - throw new InvalidOperationException("Cannot proceed to finance review — manager denied the expense."); - } - - // In a real scenario, this would retrieve the original expense details - return new ValueTask(new ApprovalRequest("EXP-2025-001", 1500.00m, "Jerry")); - } -} - -/// -/// Processes the expense reimbursement based on the parallel approval responses from budget and compliance. -/// -internal sealed class ExpenseReimburse() : Executor("Reimburse") -{ - /// - public override async ValueTask HandleAsync( - ApprovalResponse[] message, - IWorkflowContext context, - CancellationToken cancellationToken = default) - { - // Check that all parallel approvals passed - ApprovalResponse? denied = Array.Find(message, r => !r.Approved); - if (denied is not null) - { - return $"Expense reimbursement denied. Comments: {denied.Comments}"; - } - - // Simulate payment processing - await Task.Delay(1000, cancellationToken); - return $"Expense reimbursed at {DateTime.UtcNow:O}"; - } -} diff --git a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/08_WorkflowHITL/Program.cs b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/08_WorkflowHITL/Program.cs deleted file mode 100644 index bc8fe00341f..00000000000 --- a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/08_WorkflowHITL/Program.cs +++ /dev/null @@ -1,98 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -// This sample demonstrates a Human-in-the-Loop (HITL) workflow using Durable Tasks. -// -// ┌──────────────────────┐ ┌────────────────┐ ┌─────────────────────┐ ┌────────────────────┐ -// │ CreateApprovalRequest│──►│ManagerApproval │──►│PrepareFinanceReview │──┬►│ BudgetApproval │──┐ -// └──────────────────────┘ │ (RequestPort) │ └─────────────────────┘ │ │ (RequestPort) │ │ -// └────────────────┘ │ └────────────────────┘ │ ┌─────────────────┐ -// │ ├─►│ExpenseReimburse │ -// │ ┌────────────────────┐ │ └─────────────────┘ -// └►│ComplianceApproval │──┘ -// │ (RequestPort) │ -// └────────────────────┘ -// -// The workflow pauses at three RequestPorts — one for the manager, then two in parallel for finance. -// After manager approval, BudgetApproval and ComplianceApproval run concurrently via fan-out/fan-in. - -using Microsoft.Agents.AI.DurableTask; -using Microsoft.Agents.AI.DurableTask.Workflows; -using Microsoft.Agents.AI.Workflows; -using Microsoft.DurableTask.Client.AzureManaged; -using Microsoft.DurableTask.Worker.AzureManaged; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using WorkflowHITL; - -string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING") - ?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None"; - -// Define executors and RequestPorts for the three HITL pause points -CreateApprovalRequest createRequest = new(); -RequestPort managerApproval = RequestPort.Create("ManagerApproval"); -PrepareFinanceReview prepareFinanceReview = new(); -RequestPort budgetApproval = RequestPort.Create("BudgetApproval"); -RequestPort complianceApproval = RequestPort.Create("ComplianceApproval"); -ExpenseReimburse reimburse = new(); - -// Build the workflow: CreateApprovalRequest -> ManagerApproval -> PrepareFinanceReview -> [BudgetApproval AND ComplianceApproval] -> ExpenseReimburse -Workflow expenseApproval = new WorkflowBuilder(createRequest) - .WithName("ExpenseReimbursement") - .WithDescription("Expense reimbursement with manager and parallel finance approvals") - .AddEdge(createRequest, managerApproval) - .AddEdge(managerApproval, prepareFinanceReview) - .AddFanOutEdge(prepareFinanceReview, [budgetApproval, complianceApproval]) - .AddFanInBarrierEdge([budgetApproval, complianceApproval], reimburse) - .Build(); - -IHost host = Host.CreateDefaultBuilder(args) - .ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Warning)) - .ConfigureServices(services => - { - services.ConfigureDurableWorkflows( - options => options.AddWorkflow(expenseApproval), - workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString), - clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString)); - }) - .Build(); - -await host.StartAsync(); - -IWorkflowClient workflowClient = host.Services.GetRequiredService(); - -// Start the workflow with streaming to observe events including HITL pauses -string expenseId = "EXP-2025-001"; -Console.WriteLine($"Starting expense reimbursement workflow for expense: {expenseId}"); -IStreamingWorkflowRun run = await workflowClient.StreamAsync(expenseApproval, expenseId); -Console.WriteLine($"Workflow started with instance ID: {run.RunId}\n"); - -// Watch for workflow events — handle HITL requests as they arrive -await foreach (WorkflowEvent evt in run.WatchStreamAsync()) -{ - switch (evt) - { - case DurableWorkflowWaitingForInputEvent requestEvent: - Console.WriteLine($"Workflow paused at RequestPort: {requestEvent.RequestPort.Id}"); - Console.WriteLine($" Input: {requestEvent.Input}"); - - // In a real scenario, this would involve human interaction (UI, email, Teams, etc.) - ApprovalRequest? request = requestEvent.GetInputAs(); - Console.WriteLine($" Approval for: {request?.EmployeeName}, Amount: {request?.Amount:C}"); - - ApprovalResponse approvalResponse = new(Approved: true, Comments: "Approved by manager."); - await run.SendResponseAsync(requestEvent, approvalResponse); - Console.WriteLine($" Response sent: Approved={approvalResponse.Approved}\n"); - break; - - case DurableWorkflowCompletedEvent completedEvent: - Console.WriteLine($"Workflow completed: {completedEvent.Result}"); - break; - - case DurableWorkflowFailedEvent failedEvent: - Console.WriteLine($"Workflow failed: {failedEvent.ErrorMessage}"); - break; - } -} - -await host.StopAsync(); diff --git a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/08_WorkflowHITL/README.md b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/08_WorkflowHITL/README.md deleted file mode 100644 index f659077371f..00000000000 --- a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/08_WorkflowHITL/README.md +++ /dev/null @@ -1,106 +0,0 @@ -# Workflow Human-in-the-Loop (HITL) Sample - -This sample demonstrates a **Human-in-the-Loop** pattern in durable workflows using `RequestPort`. The workflow pauses execution at a manager approval point, then fans out to two parallel finance approval points — budget and compliance — before resuming. - -## Key Concepts Demonstrated - -- Using `RequestPort` to define external input points in a workflow -- Sequential and parallel HITL pause points in a single workflow using fan-out/fan-in -- Streaming workflow events with `IStreamingWorkflowRun` -- Handling `DurableWorkflowWaitingForInputEvent` to detect HITL pauses -- Using `SendResponseAsync` to provide responses and resume the workflow -- **Durability**: The workflow survives process restarts while waiting for human input - -## Workflow - -This sample implements the following workflow: - -``` -┌──────────────────────┐ ┌────────────────┐ ┌─────────────────────┐ ┌────────────────────┐ -│ CreateApprovalRequest│──►│ManagerApproval │──►│PrepareFinanceReview │──┬►│ BudgetApproval │──┐ -└──────────────────────┘ │ (RequestPort) │ └─────────────────────┘ │ │ (RequestPort) │ │ - └────────────────┘ │ └────────────────────┘ │ ┌─────────────────┐ - │ ├─►│ExpenseReimburse │ - │ ┌────────────────────┐ │ └─────────────────┘ - └►│ComplianceApproval │──┘ - │ (RequestPort) │ - └────────────────────┘ -``` - -| Step | Description | -|------|-------------| -| CreateApprovalRequest | Retrieves expense details and creates an approval request | -| ManagerApproval (RequestPort) | **PAUSES** the workflow and waits for manager approval | -| PrepareFinanceReview | Prepares the request for finance review after manager approval | -| BudgetApproval (RequestPort) | **PAUSES** the workflow and waits for budget approval (parallel) | -| ComplianceApproval (RequestPort) | **PAUSES** the workflow and waits for compliance approval (parallel) | -| ExpenseReimburse | Processes the reimbursement after all approvals pass | - -## How It Works - -A `RequestPort` defines a typed external input point in the workflow: - -```csharp -RequestPort managerApproval = - RequestPort.Create("ManagerApproval"); -``` - -Use `WatchStreamAsync` to observe events. When the workflow reaches a `RequestPort`, a `DurableWorkflowWaitingForInputEvent` is emitted. Call `SendResponseAsync` to provide the response and resume the workflow: - -```csharp -await foreach (WorkflowEvent evt in run.WatchStreamAsync()) -{ - switch (evt) - { - case DurableWorkflowWaitingForInputEvent requestEvent: - ApprovalRequest? request = requestEvent.GetInputAs(); - await run.SendResponseAsync(requestEvent, new ApprovalResponse(Approved: true, Comments: "Approved.")); - break; - } -} -``` - -## Environment Setup - -See the [README.md](../../README.md) file in the parent directory for information on configuring the environment, including how to install and run the Durable Task Scheduler. - -## Running the Sample - -```bash -cd dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/08_WorkflowHITL -dotnet run --framework net10.0 -``` - -### Sample Output - -```text -Starting expense reimbursement workflow for expense: EXP-2025-001 -Workflow started with instance ID: abc123... - -Workflow paused at RequestPort: ManagerApproval - Input: {"expenseId":"EXP-2025-001","amount":1500.00,"employeeName":"Jerry"} - Approval for: Jerry, Amount: $1,500.00 - Response sent: Approved=True - -Workflow paused at RequestPort: BudgetApproval - Input: {"expenseId":"EXP-2025-001","amount":1500.00,"employeeName":"Jerry"} - Approval for: Jerry, Amount: $1,500.00 - Response sent: Approved=True - -Workflow paused at RequestPort: ComplianceApproval - Input: {"expenseId":"EXP-2025-001","amount":1500.00,"employeeName":"Jerry"} - Approval for: Jerry, Amount: $1,500.00 - Response sent: Approved=True - -Workflow completed: Expense reimbursed at 2025-01-23T17:30:00.0000000Z -``` - -### Viewing Workflows in the DTS Dashboard - -After running the sample, you can navigate to the Durable Task Scheduler (DTS) dashboard to visualize the completed orchestration and inspect its execution history. - -If you are using the DTS emulator, the dashboard is available at `http://localhost:8082`. - -1. Open the dashboard and look for the orchestration instance matching the instance ID logged in the console output (e.g., `abc123...`). -2. Click into the instance to see the execution timeline, which shows each executor activity and the `WaitForExternalEvent` pauses where the workflow waited for human input — including the two parallel finance approvals. -3. Expand individual activity steps to inspect inputs and outputs — for example, the `ManagerApproval`, `BudgetApproval`, and `ComplianceApproval` external events will show the approval request sent and the response received. diff --git a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/README.md b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/README.md new file mode 100644 index 00000000000..1410b27f24e --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/README.md @@ -0,0 +1,3 @@ +# Durable Workflow Console Samples Have Moved + +These samples are now maintained in the [Durable Agent Framework extension repository](https://github.com/microsoft/agent-framework-durable-extension/tree/main/dotnet/samples/DurableWorkflows/ConsoleApps). diff --git a/dotnet/samples/04-hosting/DurableWorkflows/Directory.Build.props b/dotnet/samples/04-hosting/DurableWorkflows/Directory.Build.props deleted file mode 100644 index 3723bee3cc6..00000000000 --- a/dotnet/samples/04-hosting/DurableWorkflows/Directory.Build.props +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/dotnet/samples/04-hosting/DurableWorkflows/README.md b/dotnet/samples/04-hosting/DurableWorkflows/README.md index f2386380d77..1599eab92da 100644 --- a/dotnet/samples/04-hosting/DurableWorkflows/README.md +++ b/dotnet/samples/04-hosting/DurableWorkflows/README.md @@ -1,51 +1,3 @@ -# Durable Workflow Samples +# Durable Workflow Samples Have Moved -This directory contains samples demonstrating how to build durable workflows using the Microsoft Agent Framework. - -## Environment Setup - -### Prerequisites - -- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) or later -- [Durable Task Scheduler](https://learn.microsoft.com/en-us/azure/azure-functions/durable/durable-task-scheduler/durable-task-scheduler) running locally or in Azure - -### Running the Durable Task Scheduler Emulator - -To run the emulator locally using Docker: - -```bash -docker run -d -p 8080:8080 --name durabletask-emulator mcr.microsoft.com/durabletask/emulator:latest -``` - -Set the connection string environment variable to point to the local emulator: - -```bash -# Linux/macOS -export DURABLE_TASK_SCHEDULER_CONNECTION_STRING="AccountEndpoint=http://localhost:8080" - -# Windows (PowerShell) -$env:DURABLE_TASK_SCHEDULER_CONNECTION_STRING = "AccountEndpoint=http://localhost:8080" -``` - -## Samples - -### Console Apps - -| Sample | Description | -|--------|-------------| -| [01_SequentialWorkflow](ConsoleApps/01_SequentialWorkflow/) | Basic sequential workflow with ordered executor steps | -| [02_ConcurrentWorkflow](ConsoleApps/02_ConcurrentWorkflow/) | Fan-out/fan-in concurrent workflow execution | -| [03_ConditionalEdges](ConsoleApps/03_ConditionalEdges/) | Workflows with conditional routing between executors | -| [05_WorkflowEvents](ConsoleApps/05_WorkflowEvents/) | Publishing and subscribing to workflow events | -| [06_WorkflowSharedState](ConsoleApps/06_WorkflowSharedState/) | Sharing state across workflow executors | -| [07_SubWorkflows](ConsoleApps/07_SubWorkflows/) | Nested sub-workflow composition | -| [08_WorkflowHITL](ConsoleApps/08_WorkflowHITL/) | Human-in-the-loop workflow with approval gates | - -### Azure Functions - -| Sample | Description | -|--------|-------------| -| [01_SequentialWorkflow](AzureFunctions/01_SequentialWorkflow/) | Sequential workflow hosted in Azure Functions | -| [02_ConcurrentWorkflow](AzureFunctions/02_ConcurrentWorkflow/) | Concurrent workflow hosted in Azure Functions | -| [03_WorkflowHITL](AzureFunctions/03_WorkflowHITL/) | Human-in-the-loop workflow hosted in Azure Functions | -| [04_WorkflowMcpTool](AzureFunctions/04_WorkflowMcpTool/) | Workflow exposed as an MCP tool | +The .NET Durable Workflow samples are now maintained in the [Durable Agent Framework extension repository](https://github.com/microsoft/agent-framework-durable-extension/tree/main/dotnet/samples/DurableWorkflows). diff --git a/dotnet/samples/AGENTS.md b/dotnet/samples/AGENTS.md index e5432596eb1..958401edcef 100644 --- a/dotnet/samples/AGENTS.md +++ b/dotnet/samples/AGENTS.md @@ -7,13 +7,12 @@ ``` dotnet/samples/ -├── 01-get-started/ # Progressive tutorial (steps 01–06) +├── 01-get-started/ # Progressive tutorial (steps 01–05) │ ├── 01_hello_agent/ # Create and run your first agent │ ├── 02_add_tools/ # Add function tools │ ├── 03_multi_turn/ # Multi-turn conversations with AgentSession │ ├── 04_memory/ # Agent memory with AIContextProvider -│ ├── 05_first_workflow/ # Build a workflow with executors and edges -│ └── 06_host_your_agent/ # Host your agent via Azure Functions +│ └── 05_first_workflow/ # Build a workflow with executors and edges ├── 02-agents/ # Deep-dive concept samples │ ├── Agents/ # Core agent patterns (tools, structured output, │ │ # conversations, middleware, plugins, MCP, etc.) @@ -50,9 +49,7 @@ dotnet/samples/ │ └── Visualization/ # Workflow visualization ├── 04-hosting/ # Deployment & hosting │ ├── A2A/ # Agent-to-Agent protocol -│ └── DurableAgents/ # Durable task framework -│ ├── AzureFunctions/ # Azure Functions hosting -│ └── ConsoleApps/ # Console app hosting +│ └── FoundryHostedAgents/ # Foundry hosted agents ├── 05-end-to-end/ # Complete applications │ ├── A2AClientServer/ # A2A client/server demo │ ├── AgentWebChat/ # Aspire-based web chat @@ -66,7 +63,7 @@ dotnet/samples/ ## Design principles 1. **Progressive complexity**: Sections 01→05 build from "hello world" to - production. Within 01-get-started, projects are numbered 01–06 and each step + production. Within 01-get-started, projects are numbered 01–05 and each step adds exactly one concept. 2. **One concept per project** in 01-get-started. Each step is a standalone @@ -133,5 +130,6 @@ dotnet run - `AgentSession` manages multi-turn conversation state - `AIContextProvider` injects memory and context - Prefer `AIProjectClient.AsAIAgent(...)` for Foundry-backed canonical samples -- Azure Functions hosting uses `ConfigureDurableAgents(options => options.AddAIAgent(agent))` - Workflows use `WorkflowBuilder` with `Executor` and edge connections + +Durable agent and Azure Functions samples are maintained in the [Durable Agent Framework extension](https://github.com/microsoft/agent-framework-durable-extension/tree/main/dotnet/samples). diff --git a/dotnet/samples/README.md b/dotnet/samples/README.md index 063e5cfc3f3..2359edf9f49 100644 --- a/dotnet/samples/README.md +++ b/dotnet/samples/README.md @@ -13,10 +13,10 @@ were local agents. These are supported using various `AIAgent` subclasses. | Folder | Description | |--------|-------------| -| [`01-get-started/`](./01-get-started/) | Progressive tutorial: hello agent → hosting | +| [`01-get-started/`](./01-get-started/) | Progressive tutorial: hello agent → first workflow | | [`02-agents/`](./02-agents/) | Deep-dive by concept: tools, middleware, providers, orchestrations | | [`03-workflows/`](./03-workflows/) | Workflow patterns: sequential, concurrent, state, declarative | -| [`04-hosting/`](./04-hosting/) | Deployment: Azure Functions, Durable Tasks | +| [`04-hosting/`](./04-hosting/) | Deployment: A2A and Foundry hosted agents | | [`05-end-to-end/`](./05-end-to-end/) | Full applications, evaluation, demos | ## Getting Started @@ -28,7 +28,6 @@ Start with `01-get-started/` and work through the numbered files: 3. **[03_multi_turn](./01-get-started/03_multi_turn/Program.cs)** — Multi-turn conversations with `AgentSession` 4. **[04_memory](./01-get-started/04_memory/Program.cs)** — Agent memory with `AIContextProvider` 5. **[05_first_workflow](./01-get-started/05_first_workflow/Program.cs)** — Build a workflow with executors and edges -6. **[06_host_your_agent](./01-get-started/06_host_your_agent/Program.cs)** — Host your agent via Azure Functions ## Additional Samples @@ -39,8 +38,7 @@ Some additional samples of note include: `AIAgent` and can be used with any underlying service that provides an `AIAgent` implementation. - [Agent Providers](./02-agents/AgentProviders/README.md): Shows how to create an AIAgent instance for a selection of providers. - [Agent Telemetry](./02-agents/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. -- [Durable Agents - Azure Functions](./04-hosting/DurableAgents/AzureFunctions/README.md): Samples for using the Microsoft Agent Framework with Azure Functions via the durable task extension. -- [Durable Agents - Console Apps](./04-hosting/DurableAgents/ConsoleApps/README.md): Samples demonstrating durable agents in console applications. +- [Durable Agent Framework extension](https://github.com/microsoft/agent-framework-durable-extension/tree/main/dotnet/samples): Durable agents and workflows for console applications and Azure Functions. ## Migration from Semantic Kernel diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/AIAgentExtensions.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/AIAgentExtensions.cs deleted file mode 100644 index 5eac1b84e04..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/AIAgentExtensions.cs +++ /dev/null @@ -1,47 +0,0 @@ -// 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 instance or if the agent has no name. - /// - /// - /// Thrown if does not contain an - /// or if durable agents have not been configured on the service collection. - /// - /// - /// Thrown when the agent with the specified name has not been registered. - /// - 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)); - - // Validate that the agent is registered - ServiceCollectionExtensions.ValidateAgentIsRegistered(services, agentName); - - 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 deleted file mode 100644 index e87f17be684..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/AgentEntity.cs +++ /dev/null @@ -1,233 +0,0 @@ -// 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 DurableAgentsOptions _options = services.GetRequiredService(); - private readonly CancellationToken _cancellationToken = cancellationToken != default - ? cancellationToken - : services.GetService()?.ApplicationStopping ?? CancellationToken.None; - - public Task RunAgentAsync(RunRequest request) - { - return this.Run(request); - } - - // IDE1006 and VSTHRD200 disabled to allow method name to match the common cross-platform entity operation name. -#pragma warning disable IDE1006 -#pragma warning disable VSTHRD200 - public async Task Run(RunRequest request) -#pragma warning restore VSTHRD200 -#pragma warning restore IDE1006 - { - AgentSessionId sessionId = this.Context.Id; - AIAgent agent = this.GetAgent(sessionId); - EntityAgentWrapper agentWrapper = new(agent, this.Context, request, this._services); - - // Logger category is Microsoft.DurableTask.Agents.{agentName}.{sessionId} - ILogger logger = this.GetLogger(agent.Name!, sessionId.Key); - - if (request.Messages.Count == 0) - { - logger.LogInformation("Ignoring empty request"); - return new AgentResponse(); - } - - 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()), - await agentWrapper.CreateSessionAsync(cancellationToken).ConfigureAwait(false), - options: null, - this._cancellationToken); - - AgentResponse 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.ToAgentResponseAsync(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 (AgentResponseUpdate 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.ToAgentResponse(); - } - - // Persist the agent response to the entity state for client polling - this.State.Data.ConversationHistory.Add( - DurableAgentStateResponse.FromResponse(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); - } - - // Update TTL expiration time. Only schedule deletion check on first interaction. - // Subsequent interactions just update the expiration time; CheckAndDeleteIfExpiredAsync - // will reschedule the deletion check when it runs. - TimeSpan? timeToLive = this._options.GetTimeToLive(sessionId.Name); - if (timeToLive.HasValue) - { - DateTime newExpirationTime = DateTime.UtcNow.Add(timeToLive.Value); - bool isFirstInteraction = this.State.Data.ExpirationTimeUtc is null; - - this.State.Data.ExpirationTimeUtc = newExpirationTime; - logger.LogTTLExpirationTimeUpdated(sessionId, newExpirationTime); - - // Only schedule deletion check on the first interaction when entity is created. - // On subsequent interactions, we just update the expiration time. The scheduled - // CheckAndDeleteIfExpiredAsync will reschedule itself if the entity hasn't expired. - if (isFirstInteraction) - { - this.ScheduleDeletionCheck(sessionId, logger, timeToLive.Value); - } - } - else - { - // TTL is disabled. Clear the expiration time if it was previously set. - if (this.State.Data.ExpirationTimeUtc.HasValue) - { - logger.LogTTLExpirationTimeCleared(sessionId); - this.State.Data.ExpirationTimeUtc = null; - } - } - - return response; - } - finally - { - // Clear the current agent context - DurableAgentContext.ClearCurrent(); - } - } - - /// - /// Checks if the entity has expired and deletes it if so, otherwise reschedules the deletion check. - /// - /// - /// This method is called by the durable task runtime when a CheckAndDeleteIfExpired signal is received. - /// - public void CheckAndDeleteIfExpired() - { - AgentSessionId sessionId = this.Context.Id; - AIAgent agent = this.GetAgent(sessionId); - ILogger logger = this.GetLogger(agent.Name!, sessionId.Key); - - DateTime currentTime = DateTime.UtcNow; - DateTime? expirationTime = this.State.Data.ExpirationTimeUtc; - - logger.LogTTLDeletionCheck(sessionId, expirationTime, currentTime); - - if (expirationTime.HasValue) - { - if (currentTime >= expirationTime.Value) - { - // Entity has expired, delete it - logger.LogTTLEntityExpired(sessionId, expirationTime.Value); - this.State = null!; - } - else - { - // Entity hasn't expired yet, reschedule the deletion check - TimeSpan? timeToLive = this._options.GetTimeToLive(sessionId.Name); - if (timeToLive.HasValue) - { - this.ScheduleDeletionCheck(sessionId, logger, timeToLive.Value); - } - } - } - } - - private void ScheduleDeletionCheck(AgentSessionId sessionId, ILogger logger, TimeSpan timeToLive) - { - DateTime currentTime = DateTime.UtcNow; - DateTime expirationTime = this.State.Data.ExpirationTimeUtc ?? currentTime.Add(timeToLive); - TimeSpan minimumDelay = this._options.MinimumTimeToLiveSignalDelay; - - // To avoid excessive scheduling, we schedule the deletion check for no less than the minimum delay. - DateTime scheduledTime = expirationTime > currentTime.Add(minimumDelay) - ? expirationTime - : currentTime.Add(minimumDelay); - - logger.LogTTLDeletionScheduled(sessionId, scheduledTime); - - // Schedule a signal to self to check for expiration - this.Context.SignalEntity( - this.Context.Id, - nameof(CheckAndDeleteIfExpired), // self-signal - options: new SignalEntityOptions { SignalTime = scheduledTime }); - } - - private AIAgent GetAgent(AgentSessionId sessionId) - { - IReadOnlyDictionary> agents = - this._services.GetRequiredService>>(); - if (!agents.TryGetValue(sessionId.Name, out Func? agentFactory)) - { - throw new InvalidOperationException($"Agent '{sessionId.Name}' not found"); - } - - return agentFactory(this._services); - } - - private ILogger GetLogger(string agentName, string sessionKey) - { - return this._loggerFactory.CreateLogger($"Microsoft.DurableTask.Agents.{agentName}.{sessionKey}"); - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/AgentNotRegisteredException.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/AgentNotRegisteredException.cs deleted file mode 100644 index fc051fa0b26..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/AgentNotRegisteredException.cs +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -namespace Microsoft.Agents.AI.DurableTask; - -/// -/// Exception thrown when an agent with the specified name has not been registered. -/// -public sealed class AgentNotRegisteredException : InvalidOperationException -{ - // Not used, but required by static analysis. - private AgentNotRegisteredException() - { - this.AgentName = string.Empty; - } - - /// - /// Initializes a new instance of the class with the agent name. - /// - /// The name of the agent that was not registered. - public AgentNotRegisteredException(string agentName) - : base(GetMessage(agentName)) - { - this.AgentName = agentName; - } - - /// - /// Initializes a new instance of the class with the agent name and an inner exception. - /// - /// The name of the agent that was not registered. - /// The exception that is the cause of the current exception. - public AgentNotRegisteredException(string agentName, Exception? innerException) - : base(GetMessage(agentName), innerException) - { - this.AgentName = agentName; - } - - /// - /// Gets the name of the agent that was not registered. - /// - public string AgentName { get; } - - private static string GetMessage(string agentName) - { - ArgumentException.ThrowIfNullOrEmpty(agentName); - return $"No agent named '{agentName}' was registered. Ensure the agent is registered using {nameof(ServiceCollectionExtensions.ConfigureDurableAgents)} before using it in an orchestration."; - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/AgentRunHandle.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/AgentRunHandle.cs deleted file mode 100644 index 0ff329153f4..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/AgentRunHandle.cs +++ /dev/null @@ -1,83 +0,0 @@ -// 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.ToResponse(); - } - } - - // 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 deleted file mode 100644 index 6d603e1491f..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/AgentSessionId.cs +++ /dev/null @@ -1,169 +0,0 @@ -// 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); - } - - /// - /// 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; - - /// - /// Converts an agent name to its underlying entity name representation. - /// - /// The agent name. - /// The entity name used by Durable Task for this agent. - internal static string ToEntityName(string name) => $"{EntityNamePrefix}{name}"; - - /// - /// Converts the to an . - /// - /// The representation of the . - 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 deleted file mode 100644 index 2264d994280..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md +++ /dev/null @@ -1,63 +0,0 @@ -# Release History - -## [Unreleased] - -- Fix issue with resuming checkpoint after package version upgrade ([#6670](https://github.com/microsoft/agent-framework/pull/6670)) -- Bind MCP threadId to the current agent and guard cross-agent session dispatch ([#6531](https://github.com/microsoft/agent-framework/pull/6531)) -- Added support for durable workflows ([#4436](https://github.com/microsoft/agent-framework/pull/4436)) - -## v1.0.0-preview.260219.1 - -- [BREAKING] Changed ChatHistory and AIContext Providers to have pipeline semantics ([#3806](https://github.com/microsoft/agent-framework/pull/3806)) -- Marked all `RunAsync` overloads as `new`, added missing ones, and added support for primitives and arrays #3803 -- Improve session cast error message quality and consistency ([#3973](https://github.com/microsoft/agent-framework/pull/3973)) - -## v1.0.0-preview.260212.1 - -- [BREAKING] Changed AIAgent.SerializeSession to AIAgent.SerializeSessionAsync ([#3879](https://github.com/microsoft/agent-framework/pull/3879)) - -## v1.0.0-preview.260209.1 - -- [BREAKING] Introduce Core method pattern for Session management methods on AIAgent ([#3699](https://github.com/microsoft/agent-framework/pull/3699)) - -## v1.0.0-preview.260205.1 - -- [BREAKING] Moved AgentSession.Serialize to AIAgent.SerializeSession ([#3650](https://github.com/microsoft/agent-framework/pull/3650)) -- [BREAKING] Renamed serializedSession parameter to serializedState on DeserializeSessionAsync for consistency ([#3681](https://github.com/microsoft/agent-framework/pull/3681)) - -## v1.0.0-preview.260127.1 - -- [BREAKING] Renamed AgentThread to AgentSession ([#3430](https://github.com/microsoft/agent-framework/pull/3430)) - -## v1.0.0-preview.260108.1 - -- [BREAKING] Removed AgentThreadMetadata and used AgentSessionId directly instead ([#3067](https://github.com/microsoft/agent-framework/pull/3067)) - -## v1.0.0-preview.251219.1 - -- Filter empty `AIContent` from durable agent state responses ([#4670](https://github.com/microsoft/agent-framework/pull/4670)) - -## v1.0.0-preview.260311.1 - -### Changed - -- Added TTL configuration for durable agent entities ([#2679](https://github.com/microsoft/agent-framework/pull/2679)) -- Switch to new "Run" method name ([#2843](https://github.com/microsoft/agent-framework/pull/2843)) - -NOTE: Some of the above changes may have been part of earlier releases not mentioned in this file. - -## v1.0.0-preview.251204.1 - -- Added orchestration ID to durable agent entity state ([#2137](https://github.com/microsoft/agent-framework/pull/2137)) - -## v1.0.0-preview.251125.1 - -- Added support for .NET 10 ([#2128](https://github.com/microsoft/agent-framework/pull/2128)) - -## v1.0.0-preview.251114.1 - -- Added friendly error message when running durable agent that isn't registered ([#2214](https://github.com/microsoft/agent-framework/pull/2214)) - -## v1.0.0-preview.251112.1 - -- Initial public release ([#1916](https://github.com/microsoft/agent-framework/pull/1916)) diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DefaultDurableAgentClient.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DefaultDurableAgentClient.cs deleted file mode 100644 index 9005641860f..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/DefaultDurableAgentClient.cs +++ /dev/null @@ -1,31 +0,0 @@ -// 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.Run), - 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 deleted file mode 100644 index 599ea3703f3..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAIAgent.cs +++ /dev/null @@ -1,296 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Runtime.CompilerServices; -using System.Text.Json; -using Microsoft.DurableTask; -using Microsoft.DurableTask.Entities; -using Microsoft.Extensions.AI; -using Microsoft.Shared.Diagnostics; - -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 session for this agent using a random session ID. - /// - /// The cancellation token. - /// A value task that represents the asynchronous operation. The task result contains a new agent session. - protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) - { - AgentSessionId sessionId = this._context.NewAgentSessionId(this._agentName); - return ValueTask.FromResult(new DurableAgentSession(sessionId)); - } - - /// - /// Serializes an agent session to JSON. - /// - /// The session to serialize. - /// Optional JSON serializer options. - /// The cancellation token. - /// A containing the serialized session state. - protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) - { - if (session is null) - { - throw new ArgumentNullException(nameof(session)); - } - - if (session is not DurableAgentSession durableSession) - { - throw new InvalidOperationException($"The provided session type '{session.GetType().Name}' is not compatible with this agent. Only sessions of type '{nameof(DurableAgentSession)}' can be serialized by this agent."); - } - - return new(durableSession.Serialize(jsonSerializerOptions)); - } - - /// - /// Deserializes an agent session from JSON. - /// - /// The serialized session data. - /// Optional JSON serializer options. - /// The cancellation token. - /// A value task that represents the asynchronous operation. The task result contains the deserialized agent session. - protected override ValueTask DeserializeSessionCoreAsync( - JsonElement serializedState, - JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) - { - return ValueTask.FromResult(DurableAgentSession.Deserialize(serializedState, jsonSerializerOptions)); - } - - /// - /// Runs the agent with messages and returns the response. - /// - /// The messages to send to the agent. - /// The agent session to use. - /// Optional run options. - /// The cancellation token. - /// The response from the agent. - /// Thrown when the agent has not been registered. - /// Thrown when the provided session is not valid for a durable agent. - /// Thrown when cancellation is requested (cancellation is not supported for durable agents). - protected override async Task RunCoreAsync( - IEnumerable messages, - AgentSession? session = null, - AgentRunOptions? options = null, - CancellationToken cancellationToken = default) - { - if (cancellationToken != default && cancellationToken.CanBeCanceled) - { - throw new NotSupportedException("Cancellation is not supported for durable agents."); - } - - session ??= await this.CreateSessionAsync(cancellationToken).ConfigureAwait(false); - if (session is not DurableAgentSession durableSession) - { - throw new ArgumentException( - "The provided session is not valid for a durable agent. " + - "Create a new session using CreateSessionAsync or provide a session previously created by this agent.", - paramName: nameof(session)); - } - - IList? enableToolNames = null; - bool enableToolCalls = true; - ChatResponseFormat? responseFormat = null; - if (options is DurableAgentRunOptions durableOptions) - { - enableToolCalls = durableOptions.EnableToolCalls; - enableToolNames = durableOptions.EnableToolNames; - } - 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; - } - - // Override the response format if specified in the agent run options - if (options?.ResponseFormat is { } format) - { - responseFormat = format; - } - - RunRequest request = new([.. messages], responseFormat, enableToolCalls, enableToolNames) - { - OrchestrationId = this._context.InstanceId - }; - - try - { - return await this._context.Entities.CallEntityAsync( - durableSession.SessionId, - nameof(AgentEntity.Run), - request); - } - catch (EntityOperationFailedException e) when (e.FailureDetails.ErrorType == "EntityTaskNotFound") - { - throw new AgentNotRegisteredException(this._agentName, e); - } - } - - /// - /// 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 session to use. - /// Optional run options. - /// The cancellation token. - /// A streaming response enumerable. - protected override async IAsyncEnumerable RunCoreStreamingAsync( - IEnumerable messages, - AgentSession? session = 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. - AgentResponse response = await this.RunAsync(messages, session, options, cancellationToken); - foreach (AgentResponseUpdate update in response.ToAgentResponseUpdates()) - { - yield return update; - } - } - - /// - /// Run the agent with no message assuming that all required instructions are already provided to the agent or on the session, and requesting a response of the specified type . - /// - /// The type of structured output to request. - /// - /// The conversation session to use for this invocation. If , a new session will be created. - /// The session will be updated with any response messages generated during invocation. - /// - /// Optional JSON serializer options to use for deserializing the response. - /// Optional configuration parameters for controlling the agent's invocation behavior. - /// The to monitor for cancellation requests. The default is . - /// A task that represents the asynchronous operation. The task result contains an with the agent's output. - /// - /// This method is specific to durable agents because the Durable Task Framework uses a custom - /// synchronization context for orchestration execution, and all continuations must run on the - /// orchestration thread to avoid breaking the durable orchestration and potential deadlocks. - /// - public new Task> RunAsync( - AgentSession? session = null, - JsonSerializerOptions? serializerOptions = null, - AgentRunOptions? options = null, - CancellationToken cancellationToken = default) => - this.RunAsync([], session, serializerOptions, options, cancellationToken); - - /// - /// Runs the agent with a text message from the user, requesting a response of the specified type . - /// - /// The type of structured output to request. - /// The user message to send to the agent. - /// - /// The conversation session to use for this invocation. If , a new session will be created. - /// The session will be updated with the input message and any response messages generated during invocation. - /// - /// Optional JSON serializer options to use for deserializing the response. - /// Optional configuration parameters for controlling the agent's invocation behavior. - /// The to monitor for cancellation requests. The default is . - /// A task that represents the asynchronous operation. The task result contains an with the agent's output. - /// is , empty, or contains only whitespace. - /// - /// - /// - public new Task> RunAsync( - string message, - AgentSession? session = null, - JsonSerializerOptions? serializerOptions = null, - AgentRunOptions? options = null, - CancellationToken cancellationToken = default) - { - _ = Throw.IfNull(message); - - return this.RunAsync(new ChatMessage(ChatRole.User, message), session, serializerOptions, options, cancellationToken); - } - - /// - /// Runs the agent with a single chat message, requesting a response of the specified type . - /// - /// The type of structured output to request. - /// The chat message to send to the agent. - /// - /// The conversation session to use for this invocation. If , a new session will be created. - /// The session will be updated with the input message and any response messages generated during invocation. - /// - /// Optional JSON serializer options to use for deserializing the response. - /// Optional configuration parameters for controlling the agent's invocation behavior. - /// The to monitor for cancellation requests. The default is . - /// A task that represents the asynchronous operation. The task result contains an with the agent's output. - /// is . - /// - /// - /// - public new Task> RunAsync( - ChatMessage message, - AgentSession? session = null, - JsonSerializerOptions? serializerOptions = null, - AgentRunOptions? options = null, - CancellationToken cancellationToken = default) - { - _ = Throw.IfNull(message); - - return this.RunAsync([message], session, serializerOptions, options, cancellationToken); - } - - /// - /// Runs the agent with a collection of chat messages, requesting a response of the specified type . - /// - /// The type of structured output to request. - /// The collection of messages to send to the agent for processing. - /// - /// The conversation session to use for this invocation. If , a new session will be created. - /// The session will be updated with the input messages and any response messages generated during invocation. - /// - /// Optional JSON serializer options to use for deserializing the response. - /// Optional configuration parameters for controlling the agent's invocation behavior. - /// The to monitor for cancellation requests. The default is . - /// A task that represents the asynchronous operation. The task result contains an with the agent's output. - /// - /// - /// - public new async Task> RunAsync( - IEnumerable messages, - AgentSession? session = null, - JsonSerializerOptions? serializerOptions = null, - AgentRunOptions? options = null, - CancellationToken cancellationToken = default) - { - serializerOptions ??= AgentAbstractionsJsonUtilities.DefaultOptions; - - var responseFormat = ChatResponseFormat.ForJsonSchema(serializerOptions); - - (responseFormat, bool isWrappedInObject) = StructuredOutputSchemaUtilities.WrapNonObjectSchema(responseFormat); - - options = options?.Clone() ?? new DurableAgentRunOptions(); - options.ResponseFormat = responseFormat; - - // ConfigureAwait(false) cannot be used here because the Durable Task Framework uses - // a custom synchronization context that requires all continuations to execute on the - // orchestration thread. Scheduling the continuation on an arbitrary thread would break - // the orchestration. - AgentResponse response = await this.RunAsync(messages, session, options, cancellationToken); - - return new AgentResponse(response, serializerOptions) { IsWrappedInObject = isWrappedInObject }; - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAIAgentProxy.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAIAgentProxy.cs deleted file mode 100644 index da8911a5693..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAIAgentProxy.cs +++ /dev/null @@ -1,110 +0,0 @@ -// 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; - - protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) - { - if (session is null) - { - throw new ArgumentNullException(nameof(session)); - } - - if (session is not DurableAgentSession durableSession) - { - throw new InvalidOperationException($"The provided session type '{session.GetType().Name}' is not compatible with this agent. Only sessions of type '{nameof(DurableAgentSession)}' can be serialized by this agent."); - } - - return new(durableSession.Serialize(jsonSerializerOptions)); - } - - protected override ValueTask DeserializeSessionCoreAsync( - JsonElement serializedState, - JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) - { - return ValueTask.FromResult(DurableAgentSession.Deserialize(serializedState, jsonSerializerOptions)); - } - - protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) - { - return ValueTask.FromResult(new DurableAgentSession(AgentSessionId.WithRandomKey(this.Name!))); - } - - protected override async Task RunCoreAsync( - IEnumerable messages, - AgentSession? session = null, - AgentRunOptions? options = null, - CancellationToken cancellationToken = default) - { - session ??= await this.CreateSessionAsync(cancellationToken).ConfigureAwait(false); - if (session is not DurableAgentSession durableSession) - { - throw new ArgumentException( - "The provided session is not valid for a durable agent. " + - "Create a new session using CreateSessionAsync or provide a session previously created by this agent.", - paramName: nameof(session)); - } - - IList? enableToolNames = null; - bool enableToolCalls = true; - ChatResponseFormat? responseFormat = null; - bool isFireAndForget = false; - - if (options is DurableAgentRunOptions durableOptions) - { - enableToolCalls = durableOptions.EnableToolCalls; - enableToolNames = durableOptions.EnableToolNames; - isFireAndForget = durableOptions.IsFireAndForget; - } - else if (options is ChatClientAgentRunOptions chatClientOptions) - { - // Honor the response format from the chat client options if specified - responseFormat = chatClientOptions.ChatOptions?.ResponseFormat; - } - - // Override the response format if specified in the agent run options - if (options?.ResponseFormat is { } format) - { - responseFormat = format; - } - - RunRequest request = new([.. messages], responseFormat, enableToolCalls, enableToolNames); - AgentSessionId sessionId = durableSession.SessionId; - - // The session must belong to this agent. - if (!string.Equals(sessionId.Name, this.Name, StringComparison.OrdinalIgnoreCase)) - { - throw new ArgumentException( - $"The provided session belongs to agent '{sessionId.Name}' but was passed to agent '{this.Name}'. " + - "Sessions cannot be reused across agents.", - paramName: nameof(session)); - } - - AgentRunHandle agentRunHandle = await this._agentClient.RunAgentAsync(sessionId, request, cancellationToken); - - if (isFireAndForget) - { - // If the request is fire and forget, return an empty response. - return new AgentResponse(); - } - - return await agentRunHandle.ReadAgentResponseAsync(cancellationToken); - } - - protected override IAsyncEnumerable RunCoreStreamingAsync( - IEnumerable messages, - AgentSession? session = 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 deleted file mode 100644 index 209d27ceabc..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentContext.cs +++ /dev/null @@ -1,161 +0,0 @@ -// 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.CurrentSession = new DurableAgentSession(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 DurableAgentSession CurrentSession { 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 deleted file mode 100644 index 7670b9e147a..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentJsonUtilities.cs +++ /dev/null @@ -1,99 +0,0 @@ -// 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(DurableAgentSession))] - - // 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 deleted file mode 100644 index f698eab3d8d..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentRunOptions.cs +++ /dev/null @@ -1,51 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -namespace Microsoft.Agents.AI.DurableTask; - -/// -/// Options for running a durable agent. -/// -public sealed class DurableAgentRunOptions : AgentRunOptions -{ - /// - /// Initializes a new instance of the class. - /// - public DurableAgentRunOptions() - { - } - - /// - /// Initializes a new instance of the class by copying values from the specified options. - /// - /// The options instance from which to copy values. - private DurableAgentRunOptions(DurableAgentRunOptions options) - : base(options) - { - this.EnableToolCalls = options.EnableToolCalls; - this.EnableToolNames = options.EnableToolNames is not null ? new List(options.EnableToolNames) : null; - this.IsFireAndForget = options.IsFireAndForget; - } - - /// - /// 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 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; } - - /// - public override AgentRunOptions Clone() => new DurableAgentRunOptions(this); -} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentSession.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentSession.cs deleted file mode 100644 index ba33c15d320..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentSession.cs +++ /dev/null @@ -1,83 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Diagnostics; -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace Microsoft.Agents.AI.DurableTask; - -/// -/// An implementation for durable agents. -/// -[DebuggerDisplay("{DebuggerDisplay,nq}")] -public sealed class DurableAgentSession : AgentSession -{ - internal DurableAgentSession(AgentSessionId sessionId) - { - this.SessionId = sessionId; - } - - [JsonConstructor] - internal DurableAgentSession(AgentSessionId sessionId, AgentSessionStateBag stateBag) : base(stateBag) - { - this.SessionId = sessionId; - } - - /// - /// Gets the agent session ID. - /// - [JsonInclude] - [JsonPropertyName("sessionId")] - internal AgentSessionId SessionId { get; } - - /// - internal JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null) - { - var jso = jsonSerializerOptions ?? DurableAgentJsonUtilities.DefaultOptions; - return JsonSerializer.SerializeToElement(this, jso.GetTypeInfo(typeof(DurableAgentSession))); - } - - /// - /// Deserializes a DurableAgentSession from JSON. - /// - /// The serialized thread data. - /// Optional JSON serializer options. - /// The deserialized DurableAgentSession. - internal static DurableAgentSession Deserialize(JsonElement serializedSession, JsonSerializerOptions? jsonSerializerOptions = null) - { - if (!serializedSession.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); - AgentSessionStateBag stateBag = serializedSession.TryGetProperty("stateBag", out JsonElement stateBagElement) - ? AgentSessionStateBag.Deserialize(stateBagElement) - : new AgentSessionStateBag(); - - return new DurableAgentSession(sessionId, stateBag); - } - - /// - public override object? GetService(Type serviceType, object? serviceKey = null) - { - if (serviceType == typeof(AgentSessionId)) - { - return this.SessionId; - } - - return base.GetService(serviceType, serviceKey); - } - - /// - public override string ToString() - { - return this.SessionId.ToString(); - } - - [DebuggerBrowsable(DebuggerBrowsableState.Never)] - private string DebuggerDisplay => - $"SessionId = {this.SessionId}, StateBag Count = {this.StateBag.Count}"; -} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentsOptions.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentsOptions.cs deleted file mode 100644 index 1b84f9f49f2..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentsOptions.cs +++ /dev/null @@ -1,155 +0,0 @@ -// 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); - private readonly Dictionary _agentTimeToLive = new(StringComparer.OrdinalIgnoreCase); - - internal DurableAgentsOptions() - { - } - - /// - /// Gets or sets the default time-to-live (TTL) for agent entities. - /// - /// - /// If an agent entity is idle for this duration, it will be automatically deleted. - /// Defaults to 14 days. Set to to disable TTL for agents without explicit TTL configuration. - /// - public TimeSpan? DefaultTimeToLive { get; set; } = TimeSpan.FromDays(14); - - /// - /// Gets or sets the minimum delay for scheduling TTL deletion signals. Defaults to 5 minutes. - /// - /// - /// This property is primarily useful for testing (where shorter delays are needed) or for - /// shorter-lived agents in workflows that need more rapid cleanup. The maximum allowed value is 5 minutes. - /// Reducing the minimum deletion delay below 5 minutes can be useful for testing or for ensuring rapid cleanup of short-lived agent sessions. - /// However, this can also increase the load on the system and should be used with caution. - /// - /// Thrown when the value exceeds 5 minutes. - public TimeSpan MinimumTimeToLiveSignalDelay - { - get; - set - { - const int MaximumDelayMinutes = 5; - if (value > TimeSpan.FromMinutes(MaximumDelayMinutes)) - { - throw new ArgumentOutOfRangeException( - nameof(value), - value, - $"The minimum time-to-live signal delay cannot exceed {MaximumDelayMinutes} minutes."); - } - - field = value; - } - } = TimeSpan.FromMinutes(5); - - /// - /// Adds an AI agent factory to the options. - /// - /// The name of the agent. - /// The factory function to create the agent. - /// Optional time-to-live for this agent's entities. If not specified, uses . - /// The options instance. - /// Thrown when or is null. - public DurableAgentsOptions AddAIAgentFactory(string name, Func factory, TimeSpan? timeToLive = null) - { - ArgumentNullException.ThrowIfNull(name); - ArgumentNullException.ThrowIfNull(factory); - this._agentFactories.Add(name, factory); - if (timeToLive.HasValue) - { - this._agentTimeToLive[name] = timeToLive; - } - - 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. - /// Optional time-to-live for this agent's entities. If not specified, uses . - /// 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, TimeSpan? timeToLive = null) - { - 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); - if (timeToLive.HasValue) - { - this._agentTimeToLive[agent.Name] = timeToLive; - } - - 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(); - } - - /// - /// Gets the time-to-live for a specific agent, or the default TTL if not specified. - /// - /// The name of the agent. - /// The time-to-live for the agent, or the default TTL if not specified. - internal TimeSpan? GetTimeToLive(string agentName) - { - return this._agentTimeToLive.TryGetValue(agentName, out TimeSpan? ttl) ? ttl : this.DefaultTimeToLive; - } - - /// - /// Determines whether an agent with the specified name is registered. - /// - /// The name of the agent to locate. Cannot be null. - /// true if an agent with the specified name is registered; otherwise, false. - internal bool ContainsAgent(string agentName) - { - ArgumentNullException.ThrowIfNull(agentName); - return this._agentFactories.ContainsKey(agentName); - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableDataConverter.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableDataConverter.cs deleted file mode 100644 index 08dddf68522..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableDataConverter.cs +++ /dev/null @@ -1,66 +0,0 @@ -// 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; - -namespace Microsoft.Agents.AI.DurableTask; - -/// -/// Custom data converter for durable agents and workflows that ensures proper JSON serialization. -/// -/// -/// This converter handles special cases like using source-generated -/// JSON contexts for AOT compatibility, and falls back to reflection-based serialization for other types. -/// -internal sealed class DurableDataConverter : DataConverter -{ - private static readonly JsonSerializerOptions s_options = new(DurableAgentJsonUtilities.DefaultOptions) - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - PropertyNameCaseInsensitive = true, - }; - - [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Fallback uses reflection when metadata unavailable.")] - [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Fallback 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); - return typeInfo is not null - ? JsonSerializer.Deserialize(data, typeInfo) - : JsonSerializer.Deserialize(data, targetType, s_options); - } - - [return: NotNullIfNotNull(nameof(value))] - [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Fallback uses reflection when metadata unavailable.")] - [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Fallback 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()); - return typeInfo is not null - ? JsonSerializer.Serialize(value, typeInfo) - : JsonSerializer.Serialize(value, s_options); - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableOptions.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableOptions.cs deleted file mode 100644 index d7f289b2232..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableOptions.cs +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Diagnostics; -using Microsoft.Agents.AI.DurableTask.Workflows; - -namespace Microsoft.Agents.AI.DurableTask; - -/// -/// Provides configuration options for durable agents and workflows. -/// -[DebuggerDisplay("Workflows = {Workflows.Workflows.Count}, Agents = {Agents.AgentCount}")] -public class DurableOptions -{ - /// - /// Initializes a new instance of the class. - /// - internal DurableOptions() - { - this.Workflows = new DurableWorkflowOptions(this); - } - - /// - /// Gets the configuration options for durable agents. - /// - public DurableAgentsOptions Agents { get; } = new(); - - /// - /// Gets the configuration options for durable workflows. - /// - public DurableWorkflowOptions Workflows { get; } -} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableServicesMarker.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableServicesMarker.cs deleted file mode 100644 index 58dea9b20ff..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableServicesMarker.cs +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -namespace Microsoft.Agents.AI.DurableTask; - -/// -/// Marker class used to track whether core durable task services have been registered. -/// -/// -/// -/// Problem it solves: Users may call configuration methods multiple times: -/// -/// services.ConfigureDurableOptions(...); // 1st call - registers agent A -/// services.ConfigureDurableOptions(...); // 2nd call - registers workflow X -/// services.ConfigureDurableOptions(...); // 3rd call - registers agent B and workflow Y -/// -/// Each call invokes EnsureDurableServicesRegistered. Without this marker, core services like -/// AddDurableTaskWorker and AddDurableTaskClient would be registered multiple times, -/// causing runtime errors or unexpected behavior. -/// -/// -/// How it works: -/// -/// First call: No marker in services → register marker + all core services -/// Subsequent calls: Marker exists → early return, skip core service registration -/// -/// -/// -/// Why not use TryAddSingleton for everything? -/// While TryAddSingleton prevents duplicate simple service registrations, it doesn't work for -/// complex registrations like AddDurableTaskWorker which have side effects and configure -/// internal builders. The marker pattern provides a clean, explicit guard for the entire registration block. -/// -/// -internal sealed class DurableServicesMarker; diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/EntityAgentWrapper.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/EntityAgentWrapper.cs deleted file mode 100644 index ce4eef8668c..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/EntityAgentWrapper.cs +++ /dev/null @@ -1,125 +0,0 @@ -// 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. - protected override string? IdCore => this._entityContext.Id.ToString(); - - protected override async Task RunCoreAsync( - IEnumerable messages, - AgentSession? session = null, - AgentRunOptions? options = null, - CancellationToken cancellationToken = default) - { - AgentResponse response = await base.RunCoreAsync( - messages, - session, - this.GetAgentEntityRunOptions(options), - cancellationToken); - - response.AgentId = this.Id; - return response; - } - - protected override async IAsyncEnumerable RunCoreStreamingAsync( - IEnumerable messages, - AgentSession? session = null, - AgentRunOptions? options = null, - [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - await foreach (AgentResponseUpdate update in base.RunCoreStreamingAsync( - messages, - session, - 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 deleted file mode 100644 index c12a765e00b..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/IAgentResponseHandler.cs +++ /dev/null @@ -1,35 +0,0 @@ -// 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( - AgentResponse message, - CancellationToken cancellationToken); -} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/IDurableAgentClient.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/IDurableAgentClient.cs deleted file mode 100644 index d49999cbbe5..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/IDurableAgentClient.cs +++ /dev/null @@ -1,21 +0,0 @@ -// 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 deleted file mode 100644 index 57ef010a2f9..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Logs.cs +++ /dev/null @@ -1,230 +0,0 @@ -// 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); - - [LoggerMessage( - EventId = 6, - Level = LogLevel.Information, - Message = "[{SessionId}] TTL expiration time updated to {ExpirationTime:O}")] - public static partial void LogTTLExpirationTimeUpdated( - this ILogger logger, - AgentSessionId sessionId, - DateTime expirationTime); - - [LoggerMessage( - EventId = 7, - Level = LogLevel.Information, - Message = "[{SessionId}] TTL deletion signal scheduled for {ScheduledTime:O}")] - public static partial void LogTTLDeletionScheduled( - this ILogger logger, - AgentSessionId sessionId, - DateTime scheduledTime); - - [LoggerMessage( - EventId = 8, - Level = LogLevel.Information, - Message = "[{SessionId}] TTL deletion check running. Expiration time: {ExpirationTime:O}, Current time: {CurrentTime:O}")] - public static partial void LogTTLDeletionCheck( - this ILogger logger, - AgentSessionId sessionId, - DateTime? expirationTime, - DateTime currentTime); - - [LoggerMessage( - EventId = 9, - Level = LogLevel.Information, - Message = "[{SessionId}] Entity expired and deleted due to TTL. Expiration time: {ExpirationTime:O}")] - public static partial void LogTTLEntityExpired( - this ILogger logger, - AgentSessionId sessionId, - DateTime expirationTime); - - [LoggerMessage( - EventId = 10, - Level = LogLevel.Information, - Message = "[{SessionId}] TTL deletion signal rescheduled for {ScheduledTime:O}")] - public static partial void LogTTLRescheduled( - this ILogger logger, - AgentSessionId sessionId, - DateTime scheduledTime); - - [LoggerMessage( - EventId = 11, - Level = LogLevel.Information, - Message = "[{SessionId}] TTL expiration time cleared (TTL disabled)")] - public static partial void LogTTLExpirationTimeCleared( - this ILogger logger, - AgentSessionId sessionId); - - // Durable workflow logs (EventIds 100-199) - - [LoggerMessage( - EventId = 100, - Level = LogLevel.Information, - Message = "Starting workflow '{WorkflowName}' with instance '{InstanceId}'")] - public static partial void LogWorkflowStarting( - this ILogger logger, - string workflowName, - string instanceId); - - [LoggerMessage( - EventId = 101, - Level = LogLevel.Information, - Message = "Superstep {Step}: {Count} active executor(s)")] - public static partial void LogSuperstepStarting( - this ILogger logger, - int step, - int count); - - [LoggerMessage( - EventId = 102, - Level = LogLevel.Debug, - Message = "Superstep {Step} executors: [{Executors}]")] - public static partial void LogSuperstepExecutors( - this ILogger logger, - int step, - string executors); - - [LoggerMessage( - EventId = 103, - Level = LogLevel.Information, - Message = "Workflow completed")] - public static partial void LogWorkflowCompleted( - this ILogger logger); - - [LoggerMessage( - EventId = 104, - Level = LogLevel.Warning, - Message = "Workflow '{InstanceId}' terminated early: reached maximum superstep limit ({MaxSupersteps}) with {RemainingExecutors} executor(s) still queued")] - public static partial void LogWorkflowMaxSuperstepsExceeded( - this ILogger logger, - string instanceId, - int maxSupersteps, - int remainingExecutors); - - [LoggerMessage( - EventId = 105, - Level = LogLevel.Debug, - Message = "Fan-In executor {ExecutorId}: aggregated {Count} messages from [{Sources}]")] - public static partial void LogFanInAggregated( - this ILogger logger, - string executorId, - int count, - string sources); - - [LoggerMessage( - EventId = 106, - Level = LogLevel.Debug, - Message = "Executor '{ExecutorId}' returned result (length: {Length}, messages: {MessageCount})")] - public static partial void LogExecutorResultReceived( - this ILogger logger, - string executorId, - int length, - int messageCount); - - [LoggerMessage( - EventId = 107, - Level = LogLevel.Debug, - Message = "Dispatching executor '{ExecutorId}' (agentic: {IsAgentic})")] - public static partial void LogDispatchingExecutor( - this ILogger logger, - string executorId, - bool isAgentic); - - [LoggerMessage( - EventId = 108, - Level = LogLevel.Warning, - Message = "Agent '{AgentName}' not found")] - public static partial void LogAgentNotFound( - this ILogger logger, - string agentName); - - [LoggerMessage( - EventId = 109, - Level = LogLevel.Debug, - Message = "Edge {Source} -> {Sink}: condition returned false, skipping")] - public static partial void LogEdgeConditionFalse( - this ILogger logger, - string source, - string sink); - - [LoggerMessage( - EventId = 110, - Level = LogLevel.Warning, - Message = "Failed to evaluate condition for edge {Source} -> {Sink}, skipping")] - public static partial void LogEdgeConditionEvaluationFailed( - this ILogger logger, - Exception ex, - string source, - string sink); - - [LoggerMessage( - EventId = 111, - Level = LogLevel.Debug, - Message = "Edge {Source} -> {Sink}: routing message")] - public static partial void LogEdgeRoutingMessage( - this ILogger logger, - string source, - string sink); - - [LoggerMessage( - EventId = 112, - Level = LogLevel.Information, - Message = "Workflow waiting for external input at RequestPort '{RequestPortId}'")] - public static partial void LogWaitingForExternalEvent( - this ILogger logger, - string requestPortId); - - [LoggerMessage( - EventId = 113, - Level = LogLevel.Information, - Message = "Received external event for RequestPort '{RequestPortId}'")] - public static partial void LogReceivedExternalEvent( - this ILogger logger, - string requestPortId); -} 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 deleted file mode 100644 index 77c877939ec..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Microsoft.Agents.AI.DurableTask.csproj +++ /dev/null @@ -1,43 +0,0 @@ - - - - $(TargetFrameworksCore) - enable - - $(NoWarn);CA2007 - - - - - - - Durable Task extensions for Microsoft Agent Framework - Provides distributed durable execution capabilities for agents built with Microsoft Agent Framework. - README.md - - - - true - - - - - - - - - - - - - - - - - - - - - - - diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/README.md b/dotnet/src/Microsoft.Agents.AI.DurableTask/README.md deleted file mode 100644 index 85686cce69f..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/README.md +++ /dev/null @@ -1,42 +0,0 @@ -# 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 deleted file mode 100644 index 0fc7ffc7b45..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/RunRequest.cs +++ /dev/null @@ -1,83 +0,0 @@ -// 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"); - - /// - /// Gets or sets the ID of the orchestration that initiated this request (if any). - /// - [JsonInclude] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - internal string? OrchestrationId { get; set; } - - /// - /// 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 deleted file mode 100644 index 456e4ae98dc..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/ServiceCollectionExtensions.cs +++ /dev/null @@ -1,381 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Agents.AI.DurableTask.Workflows; -using Microsoft.Agents.AI.Workflows; -using Microsoft.DurableTask; -using Microsoft.DurableTask.Client; -using Microsoft.DurableTask.Worker; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; -using Microsoft.Extensions.Logging; - -namespace Microsoft.Agents.AI.DurableTask; - -/// -/// Extension methods for configuring durable agents and workflows with dependency injection. -/// -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 durable agents, automatically registering agent entities. - /// - /// - /// - /// This method provides an agent-focused configuration experience. - /// If you need to configure both agents and workflows, consider using - /// instead. - /// - /// - /// Multiple calls to this method are supported and configurations are composed additively. - /// - /// - /// The service collection. - /// A delegate to configure the durable agents. - /// Optional delegate to configure the Durable Task worker. - /// Optional delegate to configure the Durable Task client. - /// The service collection for chaining. - public static IServiceCollection ConfigureDurableAgents( - this IServiceCollection services, - Action configure, - Action? workerBuilder = null, - Action? clientBuilder = null) - { - return services.ConfigureDurableOptions( - options => configure(options.Agents), - workerBuilder, - clientBuilder); - } - - /// - /// Configures durable workflows, automatically registering orchestrations and activities. - /// - /// - /// - /// This method provides a workflow-focused configuration experience. - /// If you need to configure both agents and workflows, consider using - /// instead. - /// - /// - /// Multiple calls to this method are supported and configurations are composed additively. - /// - /// - /// The service collection to configure. - /// A delegate to configure the workflow options. - /// Optional delegate to configure the durable task worker. - /// Optional delegate to configure the durable task client. - /// The service collection for chaining. - public static IServiceCollection ConfigureDurableWorkflows( - this IServiceCollection services, - Action configure, - Action? workerBuilder = null, - Action? clientBuilder = null) - { - return services.ConfigureDurableOptions( - options => configure(options.Workflows), - workerBuilder, - clientBuilder); - } - - /// - /// Configures durable agents and workflows, automatically registering orchestrations, activities, and agent entities. - /// - /// - /// - /// This is the recommended entry point for configuring durable functionality. It provides unified configuration - /// for both agents and workflows through a single instance, ensuring agents - /// referenced in workflows are automatically registered. - /// - /// - /// Multiple calls to this method (or to - /// and ) are supported and configurations are composed additively. - /// - /// - /// The service collection to configure. - /// A delegate to configure the durable options for both agents and workflows. - /// Optional delegate to configure the durable task worker. - /// Optional delegate to configure the durable task client. - /// The service collection for chaining. - /// - /// - /// services.ConfigureDurableOptions(options => - /// { - /// // Register agents not part of workflows - /// options.Agents.AddAIAgent(standaloneAgent); - /// - /// // Register workflows - agents in workflows are auto-registered - /// options.Workflows.AddWorkflow(myWorkflow); - /// }, - /// workerBuilder: builder => builder.UseDurableTaskScheduler(connectionString), - /// clientBuilder: builder => builder.UseDurableTaskScheduler(connectionString)); - /// - /// - public static IServiceCollection ConfigureDurableOptions( - this IServiceCollection services, - Action configure, - Action? workerBuilder = null, - Action? clientBuilder = null) - { - ArgumentNullException.ThrowIfNull(services); - ArgumentNullException.ThrowIfNull(configure); - - // Get or create the shared DurableOptions instance for configuration - DurableOptions sharedOptions = GetOrCreateSharedOptions(services); - - // Apply the configuration immediately to capture agent names for keyed service registration - configure(sharedOptions); - - // Register keyed services for any new agents - RegisterAgentKeyedServices(services, sharedOptions); - - // Register core services only once - EnsureDurableServicesRegistered(services, sharedOptions, workerBuilder, clientBuilder); - - return services; - } - - private static DurableOptions GetOrCreateSharedOptions(IServiceCollection services) - { - // Look for an existing DurableOptions registration - ServiceDescriptor? existingDescriptor = services.FirstOrDefault( - d => d.ServiceType == typeof(DurableOptions) && d.ImplementationInstance is not null); - - if (existingDescriptor?.ImplementationInstance is DurableOptions existing) - { - return existing; - } - - // Create a new shared options instance - DurableOptions options = new(); - services.AddSingleton(options); - return options; - } - - private static void RegisterAgentKeyedServices(IServiceCollection services, DurableOptions options) - { - foreach (KeyValuePair> factory in options.Agents.GetAgentFactories()) - { - // Only add if not already registered (to support multiple Configure* calls) - if (!services.Any(d => d.ServiceType == typeof(AIAgent) && d.IsKeyedService && Equals(d.ServiceKey, factory.Key))) - { - services.AddKeyedSingleton(factory.Key, (sp, _) => factory.Value(sp).AsDurableAgentProxy(sp)); - } - } - } - - /// - /// Ensures that the core durable services are registered only once, regardless of how many - /// times the configuration methods are called. - /// - private static void EnsureDurableServicesRegistered( - IServiceCollection services, - DurableOptions sharedOptions, - Action? workerBuilder, - Action? clientBuilder) - { - // Use a marker to ensure we only register core services once - if (services.Any(d => d.ServiceType == typeof(DurableServicesMarker))) - { - return; - } - - services.AddSingleton(); - - services.TryAddSingleton(); - - // Configure Durable Task Worker - capture sharedOptions reference in closure. - // The options object is populated by all Configure* calls before the worker starts. - - if (workerBuilder is not null) - { - services.AddDurableTaskWorker(builder => - { - workerBuilder?.Invoke(builder); - - builder.AddTasks(registry => RegisterTasksFromOptions(registry, sharedOptions)); - }); - } - - // Configure Durable Task Client - if (clientBuilder is not null) - { - services.AddDurableTaskClient(clientBuilder); - services.TryAddSingleton(); - services.TryAddSingleton(); - } - - // Register workflow and agent services - services.TryAddSingleton(); - - // Register agent factories resolver - returns factories from the shared options - services.TryAddSingleton( - sp => sp.GetRequiredService().Agents.GetAgentFactories()); - - // Register DurableAgentsOptions resolver - services.TryAddSingleton(sp => sp.GetRequiredService().Agents); - } - - private static void RegisterTasksFromOptions(DurableTaskRegistry registry, DurableOptions durableOptions) - { - // Build registrations for all workflows including sub-workflows - List registrations = []; - HashSet registeredActivities = []; - HashSet registeredOrchestrations = []; - - DurableWorkflowOptions workflowOptions = durableOptions.Workflows; - foreach (Workflow workflow in workflowOptions.Workflows.Values.ToList()) - { - BuildWorkflowRegistrationRecursive( - workflow, - workflowOptions, - registrations, - registeredActivities, - registeredOrchestrations); - } - - IReadOnlyDictionary> agentFactories = - durableOptions.Agents.GetAgentFactories(); - - // Register orchestrations and activities - foreach (WorkflowRegistrationInfo registration in registrations) - { - // Register with DurableWorkflowInput - the DataConverter handles serialization/deserialization - registry.AddOrchestratorFunc, DurableWorkflowResult>( - registration.OrchestrationName, - (context, input) => RunWorkflowOrchestrationAsync(context, input, durableOptions)); - - foreach (ActivityRegistrationInfo activity in registration.Activities) - { - ExecutorBinding binding = activity.Binding; - registry.AddActivityFunc( - activity.ActivityName, - (context, input) => DurableActivityExecutor.ExecuteAsync(binding, input)); - } - } - - // Register agent entities - foreach (string agentName in agentFactories.Keys) - { - registry.AddEntity(AgentSessionId.ToEntityName(agentName)); - } - } - - private static void BuildWorkflowRegistrationRecursive( - Workflow workflow, - DurableWorkflowOptions workflowOptions, - List registrations, - HashSet registeredActivities, - HashSet registeredOrchestrations) - { - string orchestrationName = WorkflowNamingHelper.ToOrchestrationFunctionName(workflow.Name!); - - if (!registeredOrchestrations.Add(orchestrationName)) - { - return; - } - - registrations.Add(BuildWorkflowRegistration(workflow, registeredActivities)); - - // Process subworkflows recursively to register them as separate orchestrations - foreach (SubworkflowBinding subworkflowBinding in workflow.ReflectExecutors() - .Select(e => e.Value) - .OfType()) - { - Workflow subWorkflow = subworkflowBinding.WorkflowInstance; - workflowOptions.AddWorkflow(subWorkflow); - - BuildWorkflowRegistrationRecursive( - subWorkflow, - workflowOptions, - registrations, - registeredActivities, - registeredOrchestrations); - } - } - - private static WorkflowRegistrationInfo BuildWorkflowRegistration( - Workflow workflow, - HashSet registeredActivities) - { - string orchestrationName = WorkflowNamingHelper.ToOrchestrationFunctionName(workflow.Name!); - Dictionary executorBindings = workflow.ReflectExecutors(); - List activities = []; - - foreach (KeyValuePair entry in executorBindings - .Where(e => IsActivityBinding(e.Value))) - { - string executorName = WorkflowNamingHelper.GetExecutorName(entry.Key); - string activityName = WorkflowNamingHelper.ToOrchestrationFunctionName(executorName); - - if (registeredActivities.Add(activityName)) - { - activities.Add(new ActivityRegistrationInfo(activityName, entry.Value)); - } - } - - return new WorkflowRegistrationInfo(orchestrationName, activities); - } - - /// - /// Returns for bindings that should be registered as Durable Task activities. - /// (Durable Entities), (sub-orchestrations), - /// and (human-in-the-loop via external events) use specialized dispatch - /// and are excluded. - /// - private static bool IsActivityBinding(ExecutorBinding binding) - => binding is not AIAgentBinding - and not SubworkflowBinding - and not RequestPortBinding; - - private static async Task RunWorkflowOrchestrationAsync( - TaskOrchestrationContext context, - DurableWorkflowInput workflowInput, - DurableOptions durableOptions) - { - ILogger logger = context.CreateReplaySafeLogger("DurableWorkflow"); - DurableWorkflowRunner runner = new(durableOptions); - - // ConfigureAwait(true) is required in orchestration code for deterministic replay. - return await runner.RunWorkflowOrchestrationAsync(context, workflowInput, logger).ConfigureAwait(true); - } - - private sealed record WorkflowRegistrationInfo(string OrchestrationName, List Activities); - - private sealed record ActivityRegistrationInfo(string ActivityName, ExecutorBinding Binding); - - /// - /// Validates that an agent with the specified name has been registered. - /// - /// The service provider. - /// The name of the agent to validate. - /// - /// Thrown when the agent dictionary is not registered in the service provider. - /// - /// - /// Thrown when the agent with the specified name has not been registered. - /// - internal static void ValidateAgentIsRegistered(IServiceProvider services, string agentName) - { - IReadOnlyDictionary>? agents = - services.GetService>>() - ?? throw new InvalidOperationException( - $"Durable agents have not been configured. Ensure {nameof(ConfigureDurableAgents)} has been called on the service collection."); - - if (!agents.ContainsKey(agentName)) - { - throw new AgentNotRegisteredException(agentName); - } - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentState.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentState.cs deleted file mode 100644 index 35aef335444..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentState.cs +++ /dev/null @@ -1,27 +0,0 @@ -// 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.1.0"; -} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateContent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateContent.cs deleted file mode 100644 index 62f9f18d60c..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateContent.cs +++ /dev/null @@ -1,60 +0,0 @@ -// 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 deleted file mode 100644 index 745f619f483..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateData.cs +++ /dev/null @@ -1,32 +0,0 @@ -// 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 or sets the expiration time (UTC) for this agent entity. - /// If the entity is idle beyond this time, it will be automatically deleted. - /// - [JsonPropertyName("expirationTimeUtc")] - public DateTime? ExpirationTimeUtc { get; set; } - - /// - /// 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 deleted file mode 100644 index 9954213bd77..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateDataContent.cs +++ /dev/null @@ -1,45 +0,0 @@ -// 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 deleted file mode 100644 index 2f04c90097d..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateEntry.cs +++ /dev/null @@ -1,44 +0,0 @@ -// 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 deleted file mode 100644 index 17e5fea75f7..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateErrorContent.cs +++ /dev/null @@ -1,59 +0,0 @@ -// 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 deleted file mode 100644 index 1deccc8a774..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateFunctionCallContent.cs +++ /dev/null @@ -1,63 +0,0 @@ -// 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?.ToDictionary() ?? [], - 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 deleted file mode 100644 index 9237fdfa764..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateFunctionResultContent.cs +++ /dev/null @@ -1,49 +0,0 @@ -// 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 deleted file mode 100644 index c6fc860ac06..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateHostedFileContent.cs +++ /dev/null @@ -1,39 +0,0 @@ -// 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 deleted file mode 100644 index f7b615564b8..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateHostedVectorStoreContent.cs +++ /dev/null @@ -1,39 +0,0 @@ -// 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 deleted file mode 100644 index 4ad9a62835c..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonContext.cs +++ /dev/null @@ -1,40 +0,0 @@ -// 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 deleted file mode 100644 index 4c7796b36c6..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonConverter.cs +++ /dev/null @@ -1,71 +0,0 @@ -// 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 deleted file mode 100644 index 294453c149a..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateMessage.cs +++ /dev/null @@ -1,76 +0,0 @@ -// 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 deleted file mode 100644 index 6349b97c615..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateRequest.cs +++ /dev/null @@ -1,57 +0,0 @@ -// 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 ID of the orchestration that initiated this request (if any). - /// - [JsonPropertyName("orchestrationId")] - public string? OrchestrationId { get; init; } - - /// - /// 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, - OrchestrationId = request.OrchestrationId, - 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 deleted file mode 100644 index fb9f23df955..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateResponse.cs +++ /dev/null @@ -1,67 +0,0 @@ -// 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 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 FromResponse(string correlationId, AgentResponse response) - { - return new DurableAgentStateResponse() - { - CorrelationId = correlationId, - CreatedAt = response.CreatedAt ?? response.Messages.Max(m => m.CreatedAt) ?? DateTimeOffset.UtcNow, - Messages = response.Messages - .Where(HasSerializableContent) - .Select(DurableAgentStateMessage.FromChatMessage) - .ToList(), - Usage = DurableAgentStateUsage.FromUsage(response.Usage) - }; - } - - /// - /// Converts this back to an . - /// - /// A representing this response. - public AgentResponse ToResponse() - { - return new AgentResponse() - { - CreatedAt = this.CreatedAt, - Messages = this.Messages.Select(m => m.ToChatMessage()).ToList(), - Usage = this.Usage?.ToUsageDetails(), - }; - } - - // Checks whether a ChatMessage has any content that will produce meaningful serialized data. - // Known derived AIContent types (TextContent, FunctionCallContent, etc.) are always serializable. - // Base AIContent instances only carry RawRepresentation (which is [JsonIgnore]), Annotations, and - // AdditionalProperties. We keep the message if any base AIContent has annotations or additional - // properties set. NOTE: if AIContent gains new serializable properties in the future, this check - // should be updated accordingly. - private static bool HasSerializableContent(ChatMessage message) - { - return message.Contents.Any(c => - c.GetType() != typeof(AIContent) || - c.Annotations?.Count > 0 || - c.AdditionalProperties?.Count > 0); - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTextContent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTextContent.cs deleted file mode 100644 index 0f3085465a3..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTextContent.cs +++ /dev/null @@ -1,38 +0,0 @@ -// 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 deleted file mode 100644 index 9b5d6eba340..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTextReasoningContent.cs +++ /dev/null @@ -1,38 +0,0 @@ -// 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 deleted file mode 100644 index 00a180bba37..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUnknownContent.cs +++ /dev/null @@ -1,43 +0,0 @@ -// 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 deleted file mode 100644 index 8c6bbb8f24f..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUriContent.cs +++ /dev/null @@ -1,44 +0,0 @@ -// 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 deleted file mode 100644 index 1b3714faca3..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUsage.cs +++ /dev/null @@ -1,71 +0,0 @@ -// 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 deleted file mode 100644 index bdad860e621..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUsageContent.cs +++ /dev/null @@ -1,37 +0,0 @@ -// 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 deleted file mode 100644 index 09bb13c51e8..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/README.md +++ /dev/null @@ -1,147 +0,0 @@ -# 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 deleted file mode 100644 index 63f491cf48f..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/TaskOrchestrationContextExtensions.cs +++ /dev/null @@ -1,47 +0,0 @@ -// 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.DurableTask/Workflows/DurableActivityExecutor.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableActivityExecutor.cs deleted file mode 100644 index 116c6aa1e6f..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableActivityExecutor.cs +++ /dev/null @@ -1,180 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Diagnostics.CodeAnalysis; -using System.Text.Json; -using Microsoft.Agents.AI.Workflows; -using Microsoft.Agents.AI.Workflows.Checkpointing; -using Microsoft.Agents.AI.Workflows.Observability; - -namespace Microsoft.Agents.AI.DurableTask.Workflows; - -/// -/// Executes workflow activities by invoking executor bindings and handling serialization. -/// -[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Workflow and executor types are registered at startup.")] -[UnconditionalSuppressMessage("Trimming", "IL2057", Justification = "Workflow and executor types are registered at startup.")] -[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Workflow and executor types are registered at startup.")] -internal static class DurableActivityExecutor -{ - /// - /// Executes an activity using the provided executor binding. - /// - /// The executor binding to invoke. - /// The serialized input string. - /// A token to cancel the operation. - /// The serialized activity output. - /// Thrown when is null. - /// Thrown when the executor factory is not configured. - internal static async Task ExecuteAsync( - ExecutorBinding binding, - string input, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(binding); - - if (binding.FactoryAsync is null) - { - throw new InvalidOperationException($"Executor binding for '{binding.Id}' does not have a factory configured."); - } - - DurableActivityInput? inputWithState = TryDeserializeActivityInput(input); - string executorInput = inputWithState?.Input ?? input; - Dictionary sharedState = inputWithState?.State ?? []; - - Executor executor = await binding.FactoryAsync(binding.Id).ConfigureAwait(false); - Type inputType = ResolveInputType(inputWithState?.InputTypeName, executor.InputTypes); - object typedInput = DeserializeInput(executorInput, inputType); - - DurableWorkflowContext workflowContext = new(sharedState, executor); - object? result = await executor.ExecuteCoreAsync( - typedInput, - new TypeId(inputType), - workflowContext, - WorkflowTelemetryContext.Disabled, - cancellationToken).ConfigureAwait(false); - - return SerializeActivityOutput(result, workflowContext); - } - - private static string SerializeActivityOutput(object? result, DurableWorkflowContext context) - { - DurableExecutorOutput output = new() - { - Result = SerializeResult(result), - StateUpdates = context.StateUpdates, - ClearedScopes = [.. context.ClearedScopes], - Events = context.OutboundEvents.ConvertAll(SerializeEvent), - SentMessages = context.SentMessages, - HaltRequested = context.HaltRequested - }; - - return JsonSerializer.Serialize(output, DurableWorkflowJsonContext.Default.DurableExecutorOutput); - } - - /// - /// Serializes a workflow event with type information for proper deserialization. - /// - private static string SerializeEvent(WorkflowEvent evt) - { - Type eventType = evt.GetType(); - TypedPayload wrapper = new() - { - TypeName = eventType.AssemblyQualifiedName, - Data = JsonSerializer.Serialize(evt, eventType, DurableSerialization.Options) - }; - - return JsonSerializer.Serialize(wrapper, DurableWorkflowJsonContext.Default.TypedPayload); - } - - private static string SerializeResult(object? result) - { - if (result is null) - { - return string.Empty; - } - - if (result is string str) - { - return str; - } - - return JsonSerializer.Serialize(result, result.GetType(), DurableSerialization.Options); - } - - private static DurableActivityInput? TryDeserializeActivityInput(string input) - { - try - { - return JsonSerializer.Deserialize(input, DurableWorkflowJsonContext.Default.DurableActivityInput); - } - catch (JsonException) - { - return null; - } - } - - internal static object DeserializeInput(string input, Type targetType) - { - if (targetType == typeof(string)) - { - return input; - } - - // Fan-in aggregation serializes results as a JSON array of strings (e.g., ["{...}", "{...}"]). - // When the target type is a non-string array, deserialize each element individually. - if (targetType.IsArray && targetType != typeof(string[])) - { - Type elementType = targetType.GetElementType()!; - string[]? stringArray = JsonSerializer.Deserialize(input, DurableSerialization.Options); - if (stringArray is not null) - { - Array result = Array.CreateInstance(elementType, stringArray.Length); - for (int i = 0; i < stringArray.Length; i++) - { - object element = JsonSerializer.Deserialize(stringArray[i], elementType, DurableSerialization.Options) - ?? throw new InvalidOperationException($"Failed to deserialize element {i} to type '{elementType.Name}'."); - result.SetValue(element, i); - } - - return result; - } - } - - return JsonSerializer.Deserialize(input, targetType, DurableSerialization.Options) - ?? throw new InvalidOperationException($"Failed to deserialize input to type '{targetType.Name}'."); - } - - internal static Type ResolveInputType(string? inputTypeName, ISet supportedTypes) - { - if (string.IsNullOrEmpty(inputTypeName)) - { - return supportedTypes.FirstOrDefault() ?? typeof(string); - } - - Type? loadedType = DurableTaskTypeResolver.Resolve(inputTypeName); - if (loadedType is not null && supportedTypes.Contains(loadedType)) - { - return loadedType; - } - - Type? matchedType = supportedTypes.FirstOrDefault(t => - t.FullName == inputTypeName || - t.Name == inputTypeName); - - if (matchedType is not null) - { - return matchedType; - } - - // Fall back if type is string or string[] but executor doesn't support it - if (loadedType is not null && !supportedTypes.Contains(loadedType)) - { - if (loadedType == typeof(string) || loadedType == typeof(string[])) - { - return supportedTypes.FirstOrDefault() ?? typeof(string); - } - } - - return loadedType ?? supportedTypes.FirstOrDefault() ?? typeof(string); - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableActivityInput.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableActivityInput.cs deleted file mode 100644 index b49306bf9e2..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableActivityInput.cs +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -namespace Microsoft.Agents.AI.DurableTask.Workflows; - -/// -/// Input payload for activity execution, containing the input and other metadata. -/// -internal sealed class DurableActivityInput -{ - /// - /// Gets or sets the serialized executor input. - /// - public string? Input { get; set; } - - /// - /// Gets or sets the assembly-qualified type name of the input, used for proper deserialization. - /// - public string? InputTypeName { get; set; } - - /// - /// Gets or sets the shared state dictionary (scope-prefixed key -> serialized value). - /// - public Dictionary State { get; set; } = []; -} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableExecutorDispatcher.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableExecutorDispatcher.cs deleted file mode 100644 index b2440cfd831..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableExecutorDispatcher.cs +++ /dev/null @@ -1,216 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -// ConfigureAwait Usage in Orchestration Code: -// This file uses ConfigureAwait(true) because it runs within orchestration context. -// Durable Task orchestrations require deterministic replay - the same code must execute -// identically across replays. ConfigureAwait(true) ensures continuations run on the -// orchestration's synchronization context, which is essential for replay correctness. -// Using ConfigureAwait(false) here could cause non-deterministic behavior during replay. - -using System.Text.Json; -using Microsoft.Agents.AI.Workflows; -using Microsoft.DurableTask; -using Microsoft.Extensions.Logging; - -namespace Microsoft.Agents.AI.DurableTask.Workflows; - -/// -/// Dispatches workflow executors to activities, AI agents, sub-orchestrations, or external events (human-in-the-loop). -/// -/// -/// Called during the dispatch phase of each superstep by -/// DurableWorkflowRunner.DispatchExecutorsInParallelAsync. For each executor that has -/// pending input, this dispatcher determines whether the executor is an AI agent (stateful, -/// backed by Durable Entities), a request port (human-in-the-loop, backed by external events), -/// a sub-workflow (dispatched as a sub-orchestration), or a regular activity, and invokes the -/// appropriate Durable Task API. -/// The serialised string result is returned to the runner for the routing phase. -/// -internal static class DurableExecutorDispatcher -{ - /// - /// Dispatches an executor based on its type (activity, AI agent, request port, or sub-workflow). - /// - /// The task orchestration context. - /// Information about the executor to dispatch. - /// The message envelope containing input and type information. - /// The shared state dictionary to pass to the executor. - /// The live workflow status used to publish events and pending request port state. - /// The logger for tracing. - /// The result from the executor. - internal static async Task DispatchAsync( - TaskOrchestrationContext context, - WorkflowExecutorInfo executorInfo, - DurableMessageEnvelope envelope, - Dictionary sharedState, - DurableWorkflowLiveStatus liveStatus, - ILogger logger) - { - logger.LogDispatchingExecutor(executorInfo.ExecutorId, executorInfo.IsAgenticExecutor); - - if (executorInfo.IsRequestPortExecutor) - { - return await ExecuteRequestPortAsync(context, executorInfo, envelope.Message, liveStatus, logger).ConfigureAwait(true); - } - - if (executorInfo.IsAgenticExecutor) - { - return await ExecuteAgentAsync(context, executorInfo, logger, envelope.Message).ConfigureAwait(true); - } - - if (executorInfo.IsSubworkflowExecutor) - { - return await ExecuteSubWorkflowAsync(context, executorInfo, envelope.Message).ConfigureAwait(true); - } - - return await ExecuteActivityAsync(context, executorInfo, envelope.Message, envelope.InputTypeName, sharedState).ConfigureAwait(true); - } - - private static async Task ExecuteActivityAsync( - TaskOrchestrationContext context, - WorkflowExecutorInfo executorInfo, - string input, - string? inputTypeName, - Dictionary sharedState) - { - string executorName = WorkflowNamingHelper.GetExecutorName(executorInfo.ExecutorId); - string activityName = WorkflowNamingHelper.ToOrchestrationFunctionName(executorName); - - DurableActivityInput activityInput = new() - { - Input = input, - InputTypeName = inputTypeName, - State = sharedState - }; - - string serializedInput = JsonSerializer.Serialize(activityInput, DurableWorkflowJsonContext.Default.DurableActivityInput); - - return await context.CallActivityAsync(activityName, serializedInput).ConfigureAwait(true); - } - - /// - /// Executes a request port executor by waiting for an external event (human-in-the-loop). - /// - /// - /// When the workflow reaches a executor, the orchestration publishes - /// the pending request to and waits for an external actor - /// (e.g., a UI or API) to raise the corresponding event via - /// . - /// Multiple RequestPorts may be dispatched in parallel during a fan-out superstep. - /// Each adds its pending request to . - /// The wait has no built-in timeout; for time-limited approvals, callers can combine - /// context.CreateTimer with Task.WhenAny in a wrapper executor. - /// - private static async Task ExecuteRequestPortAsync( - TaskOrchestrationContext context, - WorkflowExecutorInfo executorInfo, - string input, - DurableWorkflowLiveStatus liveStatus, - ILogger logger) - { - RequestPort requestPort = executorInfo.RequestPort!; - string eventName = requestPort.Id; - - logger.LogWaitingForExternalEvent(eventName); - - // Publish pending request so external clients can discover what input is needed - liveStatus.PendingEvents.Add(new PendingRequestPortStatus(EventName: eventName, Input: input)); - context.SetCustomStatus(liveStatus); - - // Wait until the external actor raises the event - string response = await context.WaitForExternalEvent(eventName).ConfigureAwait(true); - - // Remove this pending request after receiving the response - liveStatus.PendingEvents.RemoveAll(p => p.EventName == eventName); - context.SetCustomStatus(liveStatus.Events.Count > 0 || liveStatus.PendingEvents.Count > 0 ? liveStatus : null); - - logger.LogReceivedExternalEvent(eventName); - - return response; - } - - /// - /// Executes an AI agent executor through Durable Entities. - /// - /// - /// AI agents are stateful and maintain conversation history. They use Durable Entities - /// to persist state across orchestration replays. - /// - private static async Task ExecuteAgentAsync( - TaskOrchestrationContext context, - WorkflowExecutorInfo executorInfo, - ILogger logger, - string input) - { - string agentName = WorkflowNamingHelper.GetExecutorName(executorInfo.ExecutorId); - DurableAIAgent agent = context.GetAgent(agentName); - - if (agent is null) - { - logger.LogAgentNotFound(agentName); - return $"Agent '{agentName}' not found"; - } - - AgentSession session = await agent.CreateSessionAsync().ConfigureAwait(true); - AgentResponse response = await agent.RunAsync(input, session).ConfigureAwait(true); - - return response.Text; - } - - /// - /// Dispatches a sub-workflow executor as a sub-orchestration. - /// - /// - /// Sub-workflows run as separate orchestration instances, providing independent - /// checkpointing, replay, and hierarchical visualization in the DTS dashboard. - /// The input is wrapped in so the sub-orchestration - /// can extract it using the same envelope structure. The sub-orchestration returns a - /// directly (deserialized by the Durable Task SDK), - /// which this method converts to a so the parent - /// workflow's result processing picks up both the result and any accumulated events. - /// - private static async Task ExecuteSubWorkflowAsync( - TaskOrchestrationContext context, - WorkflowExecutorInfo executorInfo, - string input) - { - string orchestrationName = WorkflowNamingHelper.ToOrchestrationFunctionName(executorInfo.SubWorkflow!.Name!); - - DurableWorkflowInput workflowInput = new() { Input = input }; - - DurableWorkflowResult? workflowResult = await context.CallSubOrchestratorAsync( - orchestrationName, - workflowInput).ConfigureAwait(true); - - return ConvertWorkflowResultToExecutorOutput(workflowResult); - } - - /// - /// Converts a from a sub-orchestration - /// into a JSON string. This bridges the sub-workflow's - /// output format to the parent workflow's result processing, preserving both the result - /// and any accumulated events from the sub-workflow. - /// - private static string ConvertWorkflowResultToExecutorOutput(DurableWorkflowResult? workflowResult) - { - if (workflowResult is null) - { - return string.Empty; - } - - // Propagate the result, events, and sent messages from the sub-workflow. - // SentMessages carry the sub-workflow's output for typed routing in the parent, - // matching the in-process WorkflowHostExecutor behavior. - // Shared state is not included because each workflow instance maintains its own - // independent shared state; it is not shared between parent and sub-workflows. - DurableExecutorOutput executorOutput = new() - { - Result = workflowResult.Result, - Events = workflowResult.Events ?? [], - SentMessages = workflowResult.SentMessages ?? [], - HaltRequested = workflowResult.HaltRequested, - }; - - return JsonSerializer.Serialize(executorOutput, DurableWorkflowJsonContext.Default.DurableExecutorOutput); - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableExecutorOutput.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableExecutorOutput.cs deleted file mode 100644 index ce3f26c14bf..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableExecutorOutput.cs +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -namespace Microsoft.Agents.AI.DurableTask.Workflows; - -/// -/// Output payload from executor execution, containing the result, state updates, and emitted events. -/// -internal sealed class DurableExecutorOutput -{ - /// - /// Gets the executor result. - /// - public string? Result { get; init; } - - /// - /// Gets the state updates (scope-prefixed key to value; null indicates deletion). - /// - public Dictionary StateUpdates { get; init; } = []; - - /// - /// Gets the scope names that were cleared. - /// - public List ClearedScopes { get; init; } = []; - - /// - /// Gets the workflow events emitted during execution. - /// - public List Events { get; init; } = []; - - /// - /// Gets the typed messages sent to downstream executors. - /// - public List SentMessages { get; init; } = []; - - /// - /// Gets a value indicating whether the executor requested a workflow halt. - /// - public bool HaltRequested { get; init; } -} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableHaltRequestedEvent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableHaltRequestedEvent.cs deleted file mode 100644 index 6c7aacfc481..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableHaltRequestedEvent.cs +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Agents.AI.Workflows; - -namespace Microsoft.Agents.AI.DurableTask.Workflows; - -/// -/// Event raised when an executor requests the workflow to halt via . -/// -public sealed class DurableHaltRequestedEvent : WorkflowEvent -{ - /// - /// Initializes a new instance of the class. - /// - /// The ID of the executor that requested the halt. - public DurableHaltRequestedEvent(string executorId) : base($"Halt requested by {executorId}") - { - this.ExecutorId = executorId; - } - - /// - /// Gets the ID of the executor that requested the halt. - /// - public string ExecutorId { get; } -} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableMessageEnvelope.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableMessageEnvelope.cs deleted file mode 100644 index 56f560a31c4..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableMessageEnvelope.cs +++ /dev/null @@ -1,51 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -namespace Microsoft.Agents.AI.DurableTask.Workflows; - -/// -/// Represents a message envelope for durable workflow message passing. -/// -/// -/// -/// This is the durable equivalent of MessageEnvelope in the in-process runner. -/// Unlike the in-process version which holds native .NET objects, this envelope -/// contains serialized JSON strings suitable for Durable Task activities. -/// -/// -internal sealed class DurableMessageEnvelope -{ - /// - /// Gets or sets the serialized JSON message content. - /// - public required string Message { get; init; } - - /// - /// Gets or sets the full type name of the message for deserialization. - /// - public string? InputTypeName { get; init; } - - /// - /// Gets or sets the ID of the executor that produced this message. - /// - /// - /// Used for tracing and debugging. Null for initial workflow input. - /// - public string? SourceExecutorId { get; init; } - - /// - /// Creates a new message envelope. - /// - /// The serialized JSON message content. - /// The full type name of the message for deserialization. - /// The ID of the executor that produced this message, or null for initial input. - /// A new instance. - internal static DurableMessageEnvelope Create(string message, string? inputTypeName, string? sourceExecutorId = null) - { - return new DurableMessageEnvelope - { - Message = message, - InputTypeName = inputTypeName, - SourceExecutorId = sourceExecutorId - }; - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableRunStatus.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableRunStatus.cs deleted file mode 100644 index cff00a84ca1..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableRunStatus.cs +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -namespace Microsoft.Agents.AI.DurableTask.Workflows; - -/// -/// Represents the execution status of a durable workflow run. -/// -public enum DurableRunStatus -{ - /// - /// The workflow instance was not found. - /// - NotFound, - - /// - /// The workflow is pending and has not started. - /// - Pending, - - /// - /// The workflow is currently running. - /// - Running, - - /// - /// The workflow completed successfully. - /// - Completed, - - /// - /// The workflow failed with an error. - /// - Failed, - - /// - /// The workflow was terminated. - /// - Terminated, - - /// - /// The workflow is suspended. - /// - Suspended, - - /// - /// The workflow status is unknown. - /// - Unknown -} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableSerialization.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableSerialization.cs deleted file mode 100644 index 245ec36fb83..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableSerialization.cs +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Text.Json; - -namespace Microsoft.Agents.AI.DurableTask.Workflows; - -/// -/// Shared serialization options for user-defined workflow types that are not known at compile time -/// and therefore cannot use the source-generated . -/// -internal static class DurableSerialization -{ - /// - /// Gets the shared for workflow serialization - /// with camelCase naming and case-insensitive deserialization. - /// - internal static JsonSerializerOptions Options { get; } = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - PropertyNameCaseInsensitive = true - }; -} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableStreamingWorkflowRun.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableStreamingWorkflowRun.cs deleted file mode 100644 index d05d01b4549..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableStreamingWorkflowRun.cs +++ /dev/null @@ -1,452 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Runtime.CompilerServices; -using System.Text.Json; -using Microsoft.Agents.AI.Workflows; -using Microsoft.DurableTask; -using Microsoft.DurableTask.Client; - -namespace Microsoft.Agents.AI.DurableTask.Workflows; - -/// -/// Represents a durable workflow run that supports streaming workflow events as they occur. -/// -/// -/// -/// Events are detected by monitoring the orchestration's custom status at regular intervals. -/// When executors emit events via or -/// , they are written to the orchestration's -/// custom status and picked up by this streaming run. -/// -/// -/// When the workflow reaches a executor, a -/// is yielded containing the request data. The caller should then call -/// -/// to provide the response and resume the workflow. -/// -/// -[DebuggerDisplay("{WorkflowName} ({RunId})")] -internal sealed class DurableStreamingWorkflowRun : IStreamingWorkflowRun -{ - private readonly DurableTaskClient _client; - private readonly Dictionary _requestPorts; - - /// - /// Initializes a new instance of the class. - /// - /// The durable task client for orchestration operations. - /// The unique instance ID for this orchestration run. - /// The workflow being executed. - internal DurableStreamingWorkflowRun(DurableTaskClient client, string instanceId, Workflow workflow) - { - this._client = client; - this.RunId = instanceId; - this.WorkflowName = workflow.Name ?? string.Empty; - this._requestPorts = ExtractRequestPorts(workflow); - } - - /// - public string RunId { get; } - - /// - /// Gets the name of the workflow being executed. - /// - public string WorkflowName { get; } - - /// - /// Gets the current execution status of the workflow run. - /// - /// A cancellation token to observe. - /// The current status of the durable run. - public async ValueTask GetStatusAsync(CancellationToken cancellationToken = default) - { - OrchestrationMetadata? metadata = await this._client.GetInstanceAsync( - this.RunId, - getInputsAndOutputs: false, - cancellation: cancellationToken).ConfigureAwait(false); - - if (metadata is null) - { - return DurableRunStatus.NotFound; - } - - return metadata.RuntimeStatus switch - { - OrchestrationRuntimeStatus.Pending => DurableRunStatus.Pending, - OrchestrationRuntimeStatus.Running => DurableRunStatus.Running, - OrchestrationRuntimeStatus.Completed => DurableRunStatus.Completed, - OrchestrationRuntimeStatus.Failed => DurableRunStatus.Failed, - OrchestrationRuntimeStatus.Terminated => DurableRunStatus.Terminated, - OrchestrationRuntimeStatus.Suspended => DurableRunStatus.Suspended, - _ => DurableRunStatus.Unknown - }; - } - - /// - public IAsyncEnumerable WatchStreamAsync(CancellationToken cancellationToken = default) - => this.WatchStreamAsync(pollingInterval: null, cancellationToken); - - /// - /// Asynchronously streams workflow events as they occur during workflow execution. - /// - /// The interval between status checks. Defaults to 100ms. - /// A cancellation token to observe. - /// An asynchronous stream of objects. - private async IAsyncEnumerable WatchStreamAsync( - TimeSpan? pollingInterval, - [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - TimeSpan minInterval = pollingInterval ?? TimeSpan.FromMilliseconds(100); - TimeSpan maxInterval = TimeSpan.FromSeconds(2); - TimeSpan currentInterval = minInterval; - - // Track how many events we've already read from the durable workflow status - int lastReadEventIndex = 0; - - // Track which pending events we've already yielded to avoid duplicates - HashSet yieldedPendingEvents = []; - - while (!cancellationToken.IsCancellationRequested) - { - // Poll with getInputsAndOutputs: true because SerializedCustomStatus - // (used for event streaming) is only populated when this flag is set. - OrchestrationMetadata? metadata = await this._client.GetInstanceAsync( - this.RunId, - getInputsAndOutputs: true, - cancellation: cancellationToken).ConfigureAwait(false); - - if (metadata is null) - { - yield break; - } - - bool hasNewEvents = false; - - // Always drain any unread events from the durable workflow status before checking terminal states. - // The orchestration may complete before the next poll, so events would be lost if we - // check terminal status first. - if (metadata.SerializedCustomStatus is not null) - { - if (DurableWorkflowLiveStatus.TryParse(metadata.SerializedCustomStatus, out DurableWorkflowLiveStatus liveStatus)) - { - (List events, lastReadEventIndex) = DrainNewEvents(liveStatus.Events, lastReadEventIndex); - foreach (WorkflowEvent evt in events) - { - hasNewEvents = true; - yield return evt; - } - - // Yield a DurableWorkflowWaitingForInputEvent for each new pending request port - foreach (PendingRequestPortStatus pending in liveStatus.PendingEvents) - { - if (yieldedPendingEvents.Add(pending.EventName)) - { - if (!this._requestPorts.TryGetValue(pending.EventName, out RequestPort? matchingPort)) - { - // RequestPort may not exist in the current workflow definition (e.g., during rolling deployments). - continue; - } - - hasNewEvents = true; - yield return new DurableWorkflowWaitingForInputEvent( - pending.Input, - matchingPort); - } - } - - // Sync tracking with current pending events so re-used RequestPort names can be yielded again - if (liveStatus.PendingEvents.Count == 0) - { - yieldedPendingEvents.Clear(); - } - else - { - yieldedPendingEvents.IntersectWith(liveStatus.PendingEvents.Select(p => p.EventName)); - } - } - } - - // Check terminal states after draining events from the durable workflow status - if (metadata.RuntimeStatus == OrchestrationRuntimeStatus.Completed) - { - // The framework clears the durable workflow status on completion, so events may be in - // SerializedOutput as a DurableWorkflowResult wrapper. - if (TryParseWorkflowResult(metadata.SerializedOutput, out DurableWorkflowResult? outputResult)) - { - (List events, _) = DrainNewEvents(outputResult.Events, lastReadEventIndex); - foreach (WorkflowEvent evt in events) - { - yield return evt; - } - - yield return new DurableWorkflowCompletedEvent(outputResult.Result); - } - else - { - // The runner always wraps output in DurableWorkflowResult, so a parse - // failure here indicates a bug. Yield a failed event so the consumer - // gets a visible, handleable signal without crashing. - yield return new DurableWorkflowFailedEvent( - $"Workflow '{this.WorkflowName}' (RunId: {this.RunId}) completed but its output could not be parsed as DurableWorkflowResult."); - } - - yield break; - } - - if (metadata.RuntimeStatus == OrchestrationRuntimeStatus.Failed) - { - string errorMessage = metadata.FailureDetails?.ErrorMessage ?? "Workflow execution failed."; - yield return new DurableWorkflowFailedEvent(errorMessage, metadata.FailureDetails); - yield break; - } - - if (metadata.RuntimeStatus == OrchestrationRuntimeStatus.Terminated) - { - yield return new DurableWorkflowFailedEvent("Workflow was terminated."); - yield break; - } - - // Adaptive backoff: reset to minimum when events were found, increase otherwise - currentInterval = hasNewEvents - ? minInterval - : TimeSpan.FromMilliseconds(Math.Min(currentInterval.TotalMilliseconds * 2, maxInterval.TotalMilliseconds)); - - try - { - await Task.Delay(currentInterval, cancellationToken).ConfigureAwait(false); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - yield break; - } - } - } - - /// - /// Sends a response to a to resume the workflow. - /// - /// The type of the response data. - /// The request event to respond to. - /// The response data to send. - /// A cancellation token to observe. - /// A representing the asynchronous operation. - [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing workflow types provided by the caller.")] - [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing workflow types provided by the caller.")] - public async ValueTask SendResponseAsync(DurableWorkflowWaitingForInputEvent requestEvent, TResponse response, CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(requestEvent); - - string serializedResponse = JsonSerializer.Serialize(response, DurableSerialization.Options); - await this._client.RaiseEventAsync( - this.RunId, - requestEvent.RequestPort.Id, - serializedResponse, - cancellationToken).ConfigureAwait(false); - } - - /// - /// Waits for the workflow to complete and returns the result. - /// - /// The expected result type. - /// A cancellation token to observe. - /// The result of the workflow execution. - /// Thrown when the workflow failed. - /// Thrown when the workflow was terminated or ended with an unexpected status. - public async ValueTask WaitForCompletionAsync(CancellationToken cancellationToken = default) - { - OrchestrationMetadata metadata = await this._client.WaitForInstanceCompletionAsync( - this.RunId, - getInputsAndOutputs: true, - cancellation: cancellationToken).ConfigureAwait(false); - - if (metadata.RuntimeStatus == OrchestrationRuntimeStatus.Completed) - { - return ExtractResult(metadata.SerializedOutput); - } - - if (metadata.RuntimeStatus == OrchestrationRuntimeStatus.Failed) - { - if (metadata.FailureDetails is not null) - { - throw new TaskFailedException( - taskName: this.WorkflowName, - taskId: -1, - failureDetails: metadata.FailureDetails); - } - - throw new InvalidOperationException( - $"Workflow '{this.WorkflowName}' (RunId: {this.RunId}) failed without failure details."); - } - - throw new InvalidOperationException( - $"Workflow '{this.WorkflowName}' (RunId: {this.RunId}) ended with unexpected status: {metadata.RuntimeStatus}"); - } - - /// - /// Deserializes and returns any events beyond from the list. - /// - private static (List Events, int UpdatedIndex) DrainNewEvents(List serializedEvents, int lastReadIndex) - { - List events = []; - while (lastReadIndex < serializedEvents.Count) - { - string serializedEvent = serializedEvents[lastReadIndex]; - lastReadIndex++; - - WorkflowEvent? workflowEvent = TryDeserializeEvent(serializedEvent); - if (workflowEvent is not null) - { - events.Add(workflowEvent); - } - } - - return (events, lastReadIndex); - } - - /// - /// Attempts to parse the orchestration output as a wrapper. - /// - /// - /// The orchestration returns a object directly. - /// The Durable Task framework's DataConverter serializes it as a JSON object - /// in SerializedOutput, so we deserialize it directly. - /// - [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow result wrapper.")] - [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow result wrapper.")] - private static bool TryParseWorkflowResult(string? serializedOutput, [NotNullWhen(true)] out DurableWorkflowResult? result) - { - if (serializedOutput is null) - { - result = default!; - return false; - } - - try - { - result = JsonSerializer.Deserialize(serializedOutput, DurableWorkflowJsonContext.Default.DurableWorkflowResult)!; - return result is not null; - } - catch (JsonException) - { - result = default!; - return false; - } - } - - /// - /// Extracts a typed result from the orchestration output by unwrapping the - /// wrapper. - /// - [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow result.")] - [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow result.")] - internal static TResult? ExtractResult(string? serializedOutput) - { - if (serializedOutput is null) - { - return default; - } - - if (!TryParseWorkflowResult(serializedOutput, out DurableWorkflowResult? workflowResult)) - { - throw new InvalidOperationException( - "Failed to parse orchestration output as DurableWorkflowResult. " + - "The orchestration runner should always wrap output in this format."); - } - - string? resultJson = workflowResult.Result; - - if (resultJson is null) - { - return default; - } - - if (typeof(TResult) == typeof(string)) - { - return (TResult)(object)resultJson; - } - - return JsonSerializer.Deserialize(resultJson, DurableSerialization.Options); - } - - [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow event types.")] - [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow event types.")] - [UnconditionalSuppressMessage("Trimming", "IL2057", Justification = "Event types are registered at startup.")] - private static WorkflowEvent? TryDeserializeEvent(string serializedEvent) - { - try - { - TypedPayload? wrapper = JsonSerializer.Deserialize( - serializedEvent, - DurableWorkflowJsonContext.Default.TypedPayload); - - if (wrapper?.TypeName is not null && wrapper.Data is not null) - { - Type? eventType = DurableTaskTypeResolver.Resolve(wrapper.TypeName); - if (eventType is not null) - { - return DeserializeEventByType(eventType, wrapper.Data); - } - } - - return null; - } - catch (JsonException) - { - return null; - } - } - - [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow event types.")] - [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow event types.")] - private static WorkflowEvent? DeserializeEventByType(Type eventType, string json) - { - // Types with internal constructors need manual deserialization - if (eventType == typeof(ExecutorInvokedEvent) - || eventType == typeof(ExecutorCompletedEvent) - || eventType == typeof(WorkflowOutputEvent)) - { - using JsonDocument doc = JsonDocument.Parse(json); - JsonElement root = doc.RootElement; - - if (eventType == typeof(ExecutorInvokedEvent)) - { - string executorId = root.GetProperty("executorId").GetString() ?? string.Empty; - JsonElement? data = GetDataProperty(root); - return new ExecutorInvokedEvent(executorId, data!); - } - - if (eventType == typeof(ExecutorCompletedEvent)) - { - string executorId = root.GetProperty("executorId").GetString() ?? string.Empty; - JsonElement? data = GetDataProperty(root); - return new ExecutorCompletedEvent(executorId, data); - } - - // WorkflowOutputEvent - string sourceId = root.GetProperty("sourceId").GetString() ?? string.Empty; - object? outputData = GetDataProperty(root); - return new WorkflowOutputEvent(outputData!, sourceId); - } - - return JsonSerializer.Deserialize(json, eventType, DurableSerialization.Options) as WorkflowEvent; - } - - private static JsonElement? GetDataProperty(JsonElement root) - { - if (!root.TryGetProperty("data", out JsonElement dataElement)) - { - return null; - } - - return dataElement.ValueKind == JsonValueKind.Null ? null : dataElement.Clone(); - } - - private static Dictionary ExtractRequestPorts(Workflow workflow) - { - return WorkflowAnalyzer.GetExecutorsFromWorkflowInOrder(workflow) - .Where(e => e.RequestPort is not null) - .ToDictionary(e => e.RequestPort!.Id, e => e.RequestPort!); - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableTaskTypeResolver.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableTaskTypeResolver.cs deleted file mode 100644 index c8dd74ef59b..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableTaskTypeResolver.cs +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Concurrent; -using System.Diagnostics.CodeAnalysis; -using Microsoft.Agents.AI.Workflows.Checkpointing; - -namespace Microsoft.Agents.AI.DurableTask.Workflows; - -/// -/// Resolves persisted assembly-qualified type-name strings to a loaded , -/// tolerating differences in assembly version, culture, and public key token between the -/// persisted name and the currently loaded assemblies. Results are cached. -/// -internal static class DurableTaskTypeResolver -{ - private static readonly ConcurrentDictionary s_cache = new(); - - /// - /// Resolves using a qualified - /// lookup, then a partial-name fallback that strips embedded version, culture, and public key - /// token qualifiers. - /// - [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access", Justification = "Workflow message and event types are registered at startup.")] - [UnconditionalSuppressMessage("Trimming", "IL2057:Unrecognized value passed to the parameter of method", Justification = "Workflow message and event types are registered at startup.")] - internal static Type? Resolve(string typeName) - => s_cache.GetOrAdd(typeName, static name => - { - Type? type = Type.GetType(name, throwOnError: false); - if (type is not null) - { - return type; - } - - string normalized = TypeId.NormalizeTypeName(name); - return ReferenceEquals(normalized, name) - ? null - : Type.GetType(normalized, throwOnError: false); - }); -} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowClient.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowClient.cs deleted file mode 100644 index 5944d578efd..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowClient.cs +++ /dev/null @@ -1,95 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Agents.AI.Workflows; -using Microsoft.DurableTask; -using Microsoft.DurableTask.Client; - -namespace Microsoft.Agents.AI.DurableTask.Workflows; - -/// -/// Provides a durable task-based implementation of for running -/// workflows as durable orchestrations. -/// -internal sealed class DurableWorkflowClient : IWorkflowClient -{ - private readonly DurableTaskClient _client; - - /// - /// Initializes a new instance of the class. - /// - /// The durable task client for orchestration operations. - /// Thrown when is null. - public DurableWorkflowClient(DurableTaskClient client) - { - ArgumentNullException.ThrowIfNull(client); - this._client = client; - } - - /// - public async ValueTask RunAsync( - Workflow workflow, - TInput input, - string? runId = null, - CancellationToken cancellationToken = default) - where TInput : notnull - { - ArgumentNullException.ThrowIfNull(workflow); - - if (string.IsNullOrEmpty(workflow.Name)) - { - throw new ArgumentException("Workflow must have a valid Name property.", nameof(workflow)); - } - - DurableWorkflowInput workflowInput = new() { Input = input }; - - string instanceId = await this._client.ScheduleNewOrchestrationInstanceAsync( - orchestratorName: WorkflowNamingHelper.ToOrchestrationFunctionName(workflow.Name), - input: workflowInput, - options: runId is not null ? new StartOrchestrationOptions(runId) : null, - cancellation: cancellationToken).ConfigureAwait(false); - - return new DurableWorkflowRun(this._client, instanceId, workflow.Name); - } - - /// - public ValueTask RunAsync( - Workflow workflow, - string input, - string? runId = null, - CancellationToken cancellationToken = default) - => this.RunAsync(workflow, input, runId, cancellationToken); - - /// - public async ValueTask StreamAsync( - Workflow workflow, - TInput input, - string? runId = null, - CancellationToken cancellationToken = default) - where TInput : notnull - { - ArgumentNullException.ThrowIfNull(workflow); - - if (string.IsNullOrEmpty(workflow.Name)) - { - throw new ArgumentException("Workflow must have a valid Name property.", nameof(workflow)); - } - - DurableWorkflowInput workflowInput = new() { Input = input }; - - string instanceId = await this._client.ScheduleNewOrchestrationInstanceAsync( - orchestratorName: WorkflowNamingHelper.ToOrchestrationFunctionName(workflow.Name), - input: workflowInput, - options: runId is not null ? new StartOrchestrationOptions(runId) : null, - cancellation: cancellationToken).ConfigureAwait(false); - - return new DurableStreamingWorkflowRun(this._client, instanceId, workflow); - } - - /// - public ValueTask StreamAsync( - Workflow workflow, - string input, - string? runId = null, - CancellationToken cancellationToken = default) - => this.StreamAsync(workflow, input, runId, cancellationToken); -} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowCompletedEvent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowCompletedEvent.cs deleted file mode 100644 index a4de6d1d50a..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowCompletedEvent.cs +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Diagnostics; -using Microsoft.Agents.AI.Workflows; - -namespace Microsoft.Agents.AI.DurableTask.Workflows; - -/// -/// Event raised when a durable workflow completes successfully. -/// -[DebuggerDisplay("Completed: {Result}")] -public sealed class DurableWorkflowCompletedEvent : WorkflowEvent -{ - /// - /// Initializes a new instance of the class. - /// - /// The serialized result of the workflow. - public DurableWorkflowCompletedEvent(string? result) : base(result) - { - this.Result = result; - } - - /// - /// Gets the serialized result of the workflow. - /// - public string? Result { get; } -} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowContext.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowContext.cs deleted file mode 100644 index 5f98f5dc594..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowContext.cs +++ /dev/null @@ -1,327 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Text.Json; -using Microsoft.Agents.AI.Workflows; - -namespace Microsoft.Agents.AI.DurableTask.Workflows; - -/// -/// A workflow context for durable workflow execution. -/// -/// -/// State is passed in from the orchestration and updates are collected for return. -/// Events emitted during execution are collected and returned to the orchestration -/// as part of the activity output for streaming to callers. -/// -[DebuggerDisplay("Executor = {_executor.Id}, StateEntries = {_initialState.Count}")] -internal sealed class DurableWorkflowContext : IWorkflowContext -{ - /// - /// The default scope name used when no explicit scope is specified. - /// Scopes partition shared state into logical namespaces so that different - /// parts of a workflow can manage their state keys independently. - /// - private const string DefaultScopeName = "__default__"; - - private readonly Dictionary _initialState; - private readonly Executor _executor; - - /// - /// Initializes a new instance of the class. - /// - /// The shared state passed from the orchestration. - /// The executor running in this context. - internal DurableWorkflowContext(Dictionary? initialState, Executor executor) - { - this._executor = executor; - this._initialState = initialState ?? []; - } - - /// - /// Gets the messages sent during activity execution via . - /// - internal List SentMessages { get; } = []; - - /// - /// Gets the outbound events that were added during activity execution. - /// - internal List OutboundEvents { get; } = []; - - /// - /// Gets the state updates made during activity execution. - /// - internal Dictionary StateUpdates { get; } = []; - - /// - /// Gets the scopes that were cleared during activity execution. - /// - internal HashSet ClearedScopes { get; } = []; - - /// - /// Gets a value indicating whether the executor requested a workflow halt. - /// - internal bool HaltRequested { get; private set; } - - /// - public ValueTask AddEventAsync( - WorkflowEvent workflowEvent, - CancellationToken cancellationToken = default) - { - if (workflowEvent is not null) - { - this.OutboundEvents.Add(workflowEvent); - } - - return default; - } - - /// - [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing workflow message types registered at startup.")] - [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing workflow message types registered at startup.")] - public ValueTask SendMessageAsync( - object message, - string? targetId = null, - CancellationToken cancellationToken = default) - { - if (message is not null) - { - Type messageType = message.GetType(); - this.SentMessages.Add(new TypedPayload - { - Data = JsonSerializer.Serialize(message, messageType, DurableSerialization.Options), - TypeName = messageType.AssemblyQualifiedName - }); - } - - return default; - } - - /// - public ValueTask YieldOutputAsync( - object output, - CancellationToken cancellationToken = default) - { - if (output is not null) - { - Type outputType = output.GetType(); - if (!this._executor.CanOutput(outputType)) - { - throw new InvalidOperationException( - $"Cannot output object of type {outputType.Name}. " + - $"Expecting one of [{string.Join(", ", this._executor.OutputTypes)}]."); - } - - this.OutboundEvents.Add(new WorkflowOutputEvent(output, this._executor.Id)); - } - - return default; - } - - /// - public ValueTask RequestHaltAsync() - { - this.HaltRequested = true; - this.OutboundEvents.Add(new DurableHaltRequestedEvent(this._executor.Id)); - return default; - } - - /// - public ValueTask ReadStateAsync( - string key, - string? scopeName = null, - CancellationToken cancellationToken = default) - { - ArgumentException.ThrowIfNullOrEmpty(key); - - string scopeKey = GetScopeKey(scopeName, key); - string normalizedScope = scopeName ?? DefaultScopeName; - bool scopeCleared = this.ClearedScopes.Contains(normalizedScope); - - // Local updates take priority over initial state. - if (this.StateUpdates.TryGetValue(scopeKey, out string? updated)) - { - return DeserializeStateAsync(updated); - } - - // If scope was cleared, ignore initial state - if (scopeCleared) - { - return ValueTask.FromResult(default); - } - - // Fall back to initial state passed from orchestration - if (this._initialState.TryGetValue(scopeKey, out string? initial)) - { - return DeserializeStateAsync(initial); - } - - return ValueTask.FromResult(default); - } - - /// - public async ValueTask ReadOrInitStateAsync( - string key, - Func initialStateFactory, - string? scopeName = null, - CancellationToken cancellationToken = default) - { - ArgumentException.ThrowIfNullOrEmpty(key); - ArgumentNullException.ThrowIfNull(initialStateFactory); - - // Cannot rely on `value is not null` because T? on an unconstrained generic - // parameter does not become Nullable for value types — the null check is - // always true for types like int. Instead, check key existence directly. - if (this.HasStateKey(key, scopeName)) - { - T? value = await this.ReadStateAsync(key, scopeName, cancellationToken).ConfigureAwait(false); - if (value is not null) - { - return value; - } - } - - T initialValue = initialStateFactory(); - await this.QueueStateUpdateAsync(key, initialValue, scopeName, cancellationToken).ConfigureAwait(false); - return initialValue; - } - - /// - public ValueTask> ReadStateKeysAsync( - string? scopeName = null, - CancellationToken cancellationToken = default) - { - string scopePrefix = GetScopePrefix(scopeName); - int scopePrefixLength = scopePrefix.Length; - HashSet keys = new(StringComparer.Ordinal); - - bool scopeCleared = scopeName is null - ? this.ClearedScopes.Contains(DefaultScopeName) - : this.ClearedScopes.Contains(scopeName); - - // Start with keys from initial state (skip if scope was cleared) - if (!scopeCleared) - { - foreach (string stateKey in this._initialState.Keys) - { - if (stateKey.StartsWith(scopePrefix, StringComparison.Ordinal)) - { - keys.Add(stateKey[scopePrefixLength..]); - } - } - } - - // Merge local updates: add if non-null, remove if null (deleted) - foreach (KeyValuePair update in this.StateUpdates) - { - if (!update.Key.StartsWith(scopePrefix, StringComparison.Ordinal)) - { - continue; - } - - string key = update.Key[scopePrefixLength..]; - if (update.Value is not null) - { - keys.Add(key); - } - else - { - keys.Remove(key); - } - } - - return ValueTask.FromResult(keys); - } - - /// - public ValueTask QueueStateUpdateAsync( - string key, - T? value, - string? scopeName = null, - CancellationToken cancellationToken = default) - { - ArgumentException.ThrowIfNullOrEmpty(key); - - string scopeKey = GetScopeKey(scopeName, key); - this.StateUpdates[scopeKey] = value is null ? null : SerializeState(value); - return default; - } - - /// - public ValueTask QueueClearScopeAsync( - string? scopeName = null, - CancellationToken cancellationToken = default) - { - this.ClearedScopes.Add(scopeName ?? DefaultScopeName); - - // Remove any pending updates in this scope (snapshot keys to allow removal during iteration) - string scopePrefix = GetScopePrefix(scopeName); - foreach (string key in this.StateUpdates.Keys.ToList()) - { - if (key.StartsWith(scopePrefix, StringComparison.Ordinal)) - { - this.StateUpdates.Remove(key); - } - } - - return default; - } - - /// - public IReadOnlyDictionary? TraceContext => null; - - /// - public bool ConcurrentRunsEnabled => false; - - private static string GetScopeKey(string? scopeName, string key) - => $"{GetScopePrefix(scopeName)}{key}"; - - /// - /// Checks whether the given key exists in local updates or initial state, - /// respecting cleared scopes. - /// - private bool HasStateKey(string key, string? scopeName) - { - string scopeKey = GetScopeKey(scopeName, key); - - if (this.StateUpdates.TryGetValue(scopeKey, out string? updated)) - { - return updated is not null; - } - - string normalizedScope = scopeName ?? DefaultScopeName; - if (this.ClearedScopes.Contains(normalizedScope)) - { - return false; - } - - return this._initialState.ContainsKey(scopeKey); - } - - /// - /// Returns the key prefix for the given scope. Scopes partition shared state - /// into logical namespaces, allowing different workflow executors to manage - /// their state keys independently. When no scope is specified, the - /// is used. - /// - private static string GetScopePrefix(string? scopeName) - => scopeName is null ? $"{DefaultScopeName}:" : $"{scopeName}:"; - - [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing workflow state types.")] - [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing workflow state types.")] - private static string SerializeState(T value) - => JsonSerializer.Serialize(value, DurableSerialization.Options); - - [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow state types.")] - [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow state types.")] - private static ValueTask DeserializeStateAsync(string? json) - { - if (json is null) - { - return ValueTask.FromResult(default); - } - - return ValueTask.FromResult(JsonSerializer.Deserialize(json, DurableSerialization.Options)); - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowFailedEvent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowFailedEvent.cs deleted file mode 100644 index 4f1e411be63..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowFailedEvent.cs +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Diagnostics; -using Microsoft.Agents.AI.Workflows; -using Microsoft.DurableTask; - -namespace Microsoft.Agents.AI.DurableTask.Workflows; - -/// -/// Event raised when a durable workflow fails. -/// -[DebuggerDisplay("Failed: {ErrorMessage}")] -public sealed class DurableWorkflowFailedEvent : WorkflowEvent -{ - /// - /// Initializes a new instance of the class. - /// - /// The error message describing the failure. - /// The full failure details from the Durable Task runtime, if available. - public DurableWorkflowFailedEvent(string errorMessage, TaskFailureDetails? failureDetails = null) : base(errorMessage) - { - this.ErrorMessage = errorMessage; - this.FailureDetails = failureDetails; - } - - /// - /// Gets the error message describing the failure. - /// - public string ErrorMessage { get; } - - /// - /// Gets the full failure details from the Durable Task runtime, including error type, stack trace, and inner failure. - /// - public TaskFailureDetails? FailureDetails { get; } -} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowInput.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowInput.cs deleted file mode 100644 index bd6f42f501c..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowInput.cs +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -namespace Microsoft.Agents.AI.DurableTask.Workflows; - -/// -/// Represents the input envelope for a durable workflow orchestration. -/// -/// The type of the workflow input. -internal sealed class DurableWorkflowInput - where TInput : notnull -{ - /// - /// Gets the workflow input data. - /// - public required TInput Input { get; init; } -} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowJsonContext.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowJsonContext.cs deleted file mode 100644 index 12f4c490b9b..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowJsonContext.cs +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Text.Json.Serialization; - -namespace Microsoft.Agents.AI.DurableTask.Workflows; - -/// -/// Source-generated JSON serialization context for durable workflow types. -/// -/// -/// -/// This context provides AOT-compatible and trimmer-safe JSON serialization for the -/// internal data transfer types used by the durable workflow infrastructure: -/// -/// -/// : Activity input wrapper with state -/// : Executor output wrapper with results, events, and state updates -/// : Serialized payload wrapper with type info (events and messages) -/// : Live status payload (streaming events and pending request ports) -/// -/// -/// Note: User-defined executor input/output types still use reflection-based serialization -/// since their types are not known at compile time. -/// -/// -[JsonSourceGenerationOptions( - WriteIndented = false, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] -[JsonSerializable(typeof(DurableActivityInput))] -[JsonSerializable(typeof(DurableExecutorOutput))] -[JsonSerializable(typeof(TypedPayload))] -[JsonSerializable(typeof(List))] -[JsonSerializable(typeof(DurableWorkflowLiveStatus))] -[JsonSerializable(typeof(DurableWorkflowResult))] -[JsonSerializable(typeof(PendingRequestPortStatus))] -[JsonSerializable(typeof(List))] -[JsonSerializable(typeof(List))] -[JsonSerializable(typeof(Dictionary))] -[JsonSerializable(typeof(Dictionary))] -internal partial class DurableWorkflowJsonContext : JsonSerializerContext; diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowLiveStatus.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowLiveStatus.cs deleted file mode 100644 index 5e381ce0eb2..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowLiveStatus.cs +++ /dev/null @@ -1,59 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Agents.AI.Workflows; - -namespace Microsoft.Agents.AI.DurableTask.Workflows; - -/// -/// Live status payload written to the orchestration via SetCustomStatus. -/// -/// -/// -/// This is the only orchestration state readable by external clients while the workflow -/// is still running. It is written after each superstep so that -/// can poll for new events. -/// On completion the framework clears it, so events are also -/// embedded in the output via . -/// -/// -/// When the workflow is paused at one or more nodes, -/// contains the request data for each. -/// -/// -internal sealed class DurableWorkflowLiveStatus -{ - /// - /// Gets or sets the pending request ports the workflow is waiting on. Empty when no input is needed. - /// - public List PendingEvents { get; set; } = []; - - /// - /// Gets or sets the serialized workflow events emitted so far. - /// - public List Events { get; set; } = []; - - /// - /// Attempts to deserialize a serialized custom status string into a . - /// - [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing durable workflow status.")] - [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing durable workflow status.")] - internal static bool TryParse(string? serializedStatus, out DurableWorkflowLiveStatus result) - { - if (serializedStatus is null) - { - result = default!; - return false; - } - - try - { - result = System.Text.Json.JsonSerializer.Deserialize(serializedStatus, DurableSerialization.Options)!; - return result is not null; - } - catch (System.Text.Json.JsonException) - { - result = default!; - return false; - } - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowOptions.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowOptions.cs deleted file mode 100644 index 67a21c9100a..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowOptions.cs +++ /dev/null @@ -1,111 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Diagnostics; -using Microsoft.Agents.AI.Workflows; - -namespace Microsoft.Agents.AI.DurableTask.Workflows; - -/// -/// Provides configuration options for managing durable workflows within an application. -/// -[DebuggerDisplay("Workflows = {Workflows.Count}")] -public sealed class DurableWorkflowOptions -{ - private readonly Dictionary _workflows = new(StringComparer.OrdinalIgnoreCase); - - /// - /// Initializes a new instance of the class. - /// - /// Optional parent options container for accessing related configuration. - internal DurableWorkflowOptions(DurableOptions? parentOptions = null) - { - this.ParentOptions = parentOptions; - } - - /// - /// Gets the parent container, if available. - /// - internal DurableOptions? ParentOptions { get; } - - /// - /// Gets the collection of workflows available in the current context, keyed by their unique names. - /// - public IReadOnlyDictionary Workflows => this._workflows; - - /// - /// Gets the executor registry for direct executor lookup. - /// - internal ExecutorRegistry Executors { get; } = new(); - - /// - /// Adds a workflow to the collection for processing or execution. - /// - /// The workflow instance to add. Cannot be null. - /// - /// When a workflow is added, all executors are registered in the executor registry. - /// Any AI agent executors will also be automatically registered with the - /// if available. - /// - /// Thrown when is null. - /// Thrown when the workflow does not have a valid name. - public void AddWorkflow(Workflow workflow) - { - ArgumentNullException.ThrowIfNull(workflow); - - if (string.IsNullOrEmpty(workflow.Name)) - { - throw new ArgumentException("Workflow must have a valid Name property.", nameof(workflow)); - } - - this._workflows[workflow.Name] = workflow; - this.RegisterWorkflowExecutors(workflow); - } - - /// - /// Adds a collection of workflows to the current instance. - /// - /// The collection of objects to add. - /// Thrown when is null. - public void AddWorkflows(params Workflow[] workflows) - { - ArgumentNullException.ThrowIfNull(workflows); - - foreach (Workflow workflow in workflows) - { - this.AddWorkflow(workflow); - } - } - - /// - /// Registers all executors from a workflow, including AI agents if agent options are available. - /// - private void RegisterWorkflowExecutors(Workflow workflow) - { - DurableAgentsOptions? agentOptions = this.ParentOptions?.Agents; - - foreach ((string executorId, ExecutorBinding binding) in workflow.ReflectExecutors()) - { - string executorName = WorkflowNamingHelper.GetExecutorName(executorId); - this.Executors.Register(executorName, executorId, workflow); - - TryRegisterAgent(binding, agentOptions); - } - } - - /// - /// Registers an AI agent with the agent options if the binding contains an unregistered agent. - /// - private static void TryRegisterAgent(ExecutorBinding binding, DurableAgentsOptions? agentOptions) - { - if (agentOptions is null) - { - return; - } - - if (binding.RawValue is AIAgent { Name: not null } agent - && !agentOptions.ContainsAgent(agent.Name)) - { - agentOptions.AddAIAgent(agent); - } - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowResult.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowResult.cs deleted file mode 100644 index 7f63232185c..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowResult.cs +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -namespace Microsoft.Agents.AI.DurableTask.Workflows; - -/// -/// Wraps the orchestration output to include both the workflow result and accumulated events. -/// -/// -/// The Durable Task framework clears SerializedCustomStatus when an orchestration -/// completes. To ensure streaming clients can retrieve events even after completion, -/// the accumulated events are embedded in the orchestration output alongside the result. -/// -internal sealed class DurableWorkflowResult -{ - /// - /// Gets or sets the serialized result of the workflow execution. - /// - public string? Result { get; set; } - - /// - /// Gets or sets the serialized workflow events emitted during execution. - /// - public List Events { get; set; } = []; - - /// - /// Gets or sets the typed messages to forward to connected executors in the parent workflow. - /// - /// - /// When this workflow runs as a sub-orchestration, these messages are propagated to the - /// parent workflow and routed to successor executors via the edge map. - /// - public List SentMessages { get; set; } = []; - - /// - /// Gets or sets a value indicating whether the workflow was halted by an executor. - /// - /// - /// When this workflow runs as a sub-orchestration, this flag is propagated to the - /// parent workflow so halt semantics are preserved across nesting levels. - /// - public bool HaltRequested { get; set; } -} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowRun.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowRun.cs deleted file mode 100644 index aeb42f4fb68..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowRun.cs +++ /dev/null @@ -1,116 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Diagnostics; -using Microsoft.Agents.AI.Workflows; -using Microsoft.DurableTask; -using Microsoft.DurableTask.Client; - -namespace Microsoft.Agents.AI.DurableTask.Workflows; - -/// -/// Represents a durable workflow run that tracks execution status and provides access to workflow events. -/// -[DebuggerDisplay("{WorkflowName} ({RunId})")] -internal sealed class DurableWorkflowRun : IAwaitableWorkflowRun -{ - private readonly DurableTaskClient _client; - private readonly List _eventSink = []; - private int _lastBookmark; - - /// - /// Initializes a new instance of the class. - /// - /// The durable task client for orchestration operations. - /// The unique instance ID for this orchestration run. - /// The name of the workflow being executed. - internal DurableWorkflowRun(DurableTaskClient client, string instanceId, string workflowName) - { - this._client = client; - this.RunId = instanceId; - this.WorkflowName = workflowName; - } - - /// - public string RunId { get; } - - /// - /// Gets the name of the workflow being executed. - /// - public string WorkflowName { get; } - - /// - /// Waits for the workflow to complete and returns the result. - /// - /// The expected result type. - /// A cancellation token to observe. - /// The result of the workflow execution. - /// Thrown when the workflow failed. - /// Thrown when the workflow was terminated or ended with an unexpected status. - public async ValueTask WaitForCompletionAsync(CancellationToken cancellationToken = default) - { - OrchestrationMetadata metadata = await this._client.WaitForInstanceCompletionAsync( - this.RunId, - getInputsAndOutputs: true, - cancellation: cancellationToken).ConfigureAwait(false); - - if (metadata.RuntimeStatus == OrchestrationRuntimeStatus.Completed) - { - return DurableStreamingWorkflowRun.ExtractResult(metadata.SerializedOutput); - } - - if (metadata.RuntimeStatus == OrchestrationRuntimeStatus.Failed) - { - if (metadata.FailureDetails is not null) - { - // Use TaskFailedException to preserve full failure details including stack trace and inner exceptions - throw new TaskFailedException( - taskName: this.WorkflowName, - taskId: 0, - failureDetails: metadata.FailureDetails); - } - - throw new InvalidOperationException( - $"Workflow '{this.WorkflowName}' (RunId: {this.RunId}) failed without failure details."); - } - - throw new InvalidOperationException( - $"Workflow '{this.WorkflowName}' (RunId: {this.RunId}) ended with unexpected status: {metadata.RuntimeStatus}"); - } - - /// - /// Waits for the workflow to complete and returns the string result. - /// - /// A cancellation token to observe. - /// The string result of the workflow execution. - public ValueTask WaitForCompletionAsync(CancellationToken cancellationToken = default) - => this.WaitForCompletionAsync(cancellationToken); - - /// - /// Gets all events that have been collected from the workflow. - /// - public IEnumerable OutgoingEvents => this._eventSink; - - /// - /// Gets the number of events collected since the last access to . - /// - public int NewEventCount => this._eventSink.Count - this._lastBookmark; - - /// - /// Gets all events collected since the last access to . - /// - public IEnumerable NewEvents - { - get - { - if (this._lastBookmark >= this._eventSink.Count) - { - return []; - } - - int currentBookmark = this._lastBookmark; - this._lastBookmark = this._eventSink.Count; - - return this._eventSink.Skip(currentBookmark); - } - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowRunner.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowRunner.cs deleted file mode 100644 index b458bf98b08..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowRunner.cs +++ /dev/null @@ -1,619 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -// ConfigureAwait Usage in Orchestration Code: -// This file uses ConfigureAwait(true) because it runs within orchestration context. -// Durable Task orchestrations require deterministic replay - the same code must execute -// identically across replays. ConfigureAwait(true) ensures continuations run on the -// orchestration's synchronization context, which is essential for replay correctness. -// Using ConfigureAwait(false) here could cause non-deterministic behavior during replay. - -// Superstep execution walkthrough for a workflow like below: -// -// [A] ──► [B] ──► [C] ──► [E] (B→D has condition: x => x.NeedsReview) -// │ ▲ -// └──► [D] ──────┘ -// -// Superstep 1 — A runs -// Queues before: A:[input] Results: {} -// Dispatch: A executes, returns resultA -// Route: EdgeMap routes A's output → B's queue -// Queues after: B:[resultA] Results: {A: resultA} -// -// Superstep 2 — B runs -// Queues before: B:[resultA] Results: {A: resultA} -// Dispatch: B executes, returns resultB (type: Order) -// Route: FanOutRouter sends resultB to: -// C's queue (unconditional) -// D's queue (only if resultB.NeedsReview == true) -// Queues after: C:[resultB], D:[resultB] Results: {A: .., B: resultB} -// (D may be empty if condition was false) -// -// Superstep 3 — C and D run in parallel -// Queues before: C:[resultB], D:[resultB] -// Dispatch: C and D execute concurrently via Task.WhenAll -// Route: Both route output → E's queue -// Queues after: E:[resultC, resultD] Results: {.., C: resultC, D: resultD} -// -// Superstep 4 — E runs (fan-in) -// Queues before: E:[resultC, resultD] ◄── IsFanInExecutor("E") = true -// Collect: AggregateQueueMessages merges into JSON array ["resultC","resultD"] -// Dispatch: E executes with aggregated input -// Route: E has no successors → nothing enqueued -// Queues after: (all empty) Results: {.., E: resultE} -// -// Superstep 5 — loop exits (no pending messages) -// GetFinalResult returns resultE - -using System.Diagnostics.CodeAnalysis; -using System.Text.Json; -using Microsoft.Agents.AI.DurableTask.Workflows.EdgeRouters; -using Microsoft.Agents.AI.Workflows; -using Microsoft.DurableTask; -using Microsoft.Extensions.Logging; - -namespace Microsoft.Agents.AI.DurableTask.Workflows; - -// Superstep loop: -// -// ┌───────────────┐ ┌───────────────┐ ┌───────────────────┐ -// │ Collect │───►│ Dispatch │───►│ Process Results │ -// │ Executor │ │ Executors │ │ & Route Messages │ -// │ Inputs │ │ in Parallel │ │ │ -// └───────────────┘ └───────────────┘ └───────────────────┘ -// ▲ │ -// └───────────────────────────────────────────┘ -// (repeat until no pending messages) - -/// -/// Runs workflow orchestrations using message-driven superstep execution with Durable Task. -/// -internal sealed class DurableWorkflowRunner -{ - private const int MaxSupersteps = 100; - - /// - /// Initializes a new instance of the class. - /// - /// The durable options containing workflow configurations. - public DurableWorkflowRunner(DurableOptions durableOptions) - { - ArgumentNullException.ThrowIfNull(durableOptions); - - this.Options = durableOptions.Workflows; - } - - /// - /// Gets the workflow options. - /// - private DurableWorkflowOptions Options { get; } - - /// - /// Runs a workflow orchestration. - /// - /// The task orchestration context. - /// The workflow input envelope containing workflow input and metadata. - /// The replay-safe logger for orchestration logging. - /// The result of the workflow execution. - /// Thrown when the specified workflow is not found. - internal async Task RunWorkflowOrchestrationAsync( - TaskOrchestrationContext context, - DurableWorkflowInput workflowInput, - ILogger logger) - { - ArgumentNullException.ThrowIfNull(context); - ArgumentNullException.ThrowIfNull(workflowInput); - - Workflow workflow = this.GetWorkflowOrThrow(context.Name); - - string workflowName = context.Name; - string instanceId = context.InstanceId; - logger.LogWorkflowStarting(workflowName, instanceId); - - WorkflowGraphInfo graphInfo = WorkflowAnalyzer.BuildGraphInfo(workflow); - DurableEdgeMap edgeMap = new(graphInfo); - - // Extract input - the start executor determines the expected input type from its own InputTypes - object input = workflowInput.Input; - - return await RunSuperstepLoopAsync(context, workflow, edgeMap, input, logger).ConfigureAwait(true); - } - - private Workflow GetWorkflowOrThrow(string orchestrationName) - { - string workflowName = WorkflowNamingHelper.ToWorkflowName(orchestrationName); - - if (!this.Options.Workflows.TryGetValue(workflowName, out Workflow? workflow)) - { - throw new InvalidOperationException($"Workflow '{workflowName}' not found."); - } - - return workflow; - } - - /// - /// Runs the workflow execution loop using superstep-based processing. - /// - [UnconditionalSuppressMessage("AOT", "IL2026:RequiresUnreferencedCode", Justification = "Input types are preserved by the Durable Task framework's DataConverter.")] - [UnconditionalSuppressMessage("AOT", "IL3050:RequiresDynamicCode", Justification = "Input types are preserved by the Durable Task framework's DataConverter.")] - private static async Task RunSuperstepLoopAsync( - TaskOrchestrationContext context, - Workflow workflow, - DurableEdgeMap edgeMap, - object initialInput, - ILogger logger) - { - SuperstepState state = new(workflow, edgeMap); - - // Convert input to string for the message queue. - // When DurableWorkflowInput is deserialized as DurableWorkflowInput, - // the Input property becomes a JsonElement instead of a string. - // We must extract the raw string value to avoid double-serialization. - string inputString = initialInput switch - { - string s => s, - JsonElement je when je.ValueKind == JsonValueKind.String => je.GetString() ?? string.Empty, - _ => JsonSerializer.Serialize(initialInput) - }; - - edgeMap.EnqueueInitialInput(inputString, state.MessageQueues); - - bool haltRequested = false; - - for (int superstep = 1; superstep <= MaxSupersteps; superstep++) - { - List executorInputs = CollectExecutorInputs(state, logger); - if (executorInputs.Count == 0) - { - break; - } - - logger.LogSuperstepStarting(superstep, executorInputs.Count); - if (logger.IsEnabled(LogLevel.Debug)) - { - logger.LogSuperstepExecutors(superstep, string.Join(", ", executorInputs.Select(e => e.ExecutorId))); - } - - string[] results = await DispatchExecutorsInParallelAsync(context, executorInputs, state, logger).ConfigureAwait(true); - - haltRequested = ProcessSuperstepResults(executorInputs, results, state, context, logger); - - if (haltRequested) - { - break; - } - - // Check if we've reached the limit and still have work remaining - int remainingExecutors = CountRemainingExecutors(state.MessageQueues); - if (superstep == MaxSupersteps && remainingExecutors > 0) - { - logger.LogWorkflowMaxSuperstepsExceeded(context.InstanceId, MaxSupersteps, remainingExecutors); - } - } - - // Publish final events for live streaming (skip during replay) - if (!context.IsReplaying) - { - PublishEventsToLiveStatus(context, state); - } - - string finalResult = GetFinalResult(state.LastResults); - logger.LogWorkflowCompleted(); - - // Return wrapper with both result and events so streaming clients can - // retrieve events from SerializedOutput after the orchestration completes - // (SerializedCustomStatus is cleared by the framework on completion). - // SentMessages carries the final result so parent workflows can route it - // to connected executors, matching the in-process WorkflowHostExecutor behavior. - return new DurableWorkflowResult - { - Result = finalResult, - Events = state.AccumulatedEvents, - SentMessages = !string.IsNullOrEmpty(finalResult) - ? [new TypedPayload { Data = finalResult }] - : [], - HaltRequested = haltRequested - }; - } - - /// - /// Counts the number of executors with pending messages in their queues. - /// - private static int CountRemainingExecutors(Dictionary> messageQueues) - { - return messageQueues.Count(kvp => kvp.Value.Count > 0); - } - - private static async Task DispatchExecutorsInParallelAsync( - TaskOrchestrationContext context, - List executorInputs, - SuperstepState state, - ILogger logger) - { - Task[] dispatchTasks = executorInputs - .Select(input => DurableExecutorDispatcher.DispatchAsync(context, input.Info, input.Envelope, state.SharedState, state.LiveStatus, logger)) - .ToArray(); - - return await Task.WhenAll(dispatchTasks).ConfigureAwait(true); - } - - /// - /// Holds state that accumulates and changes across superstep iterations during workflow execution. - /// - /// - /// - /// MessageQueues starts with one entry (the start executor's queue, seeded by - /// ). After each superstep, RouteOutputToSuccessors - /// adds entries for successor executors that receive routed messages. Queues are drained during - /// CollectExecutorInputs; empty queues are skipped. - /// - /// - /// LastResults is updated after every superstep with the result of each executor that ran. - /// At workflow completion, the last non-empty value is returned as the workflow's final result. - /// - /// - private sealed class SuperstepState - { - public SuperstepState(Workflow workflow, DurableEdgeMap edgeMap) - { - this.EdgeMap = edgeMap; - this.ExecutorBindings = workflow.ReflectExecutors(); - } - - public DurableEdgeMap EdgeMap { get; } - - public Dictionary ExecutorBindings { get; } - - public Dictionary> MessageQueues { get; } = []; - - public Dictionary LastResults { get; } = []; - - /// - /// Shared state dictionary across supersteps (scope-prefixed key -> serialized value). - /// - public Dictionary SharedState { get; } = []; - - /// - /// Accumulated workflow events for the durable workflow status (streaming consumption). - /// - public List AccumulatedEvents { get; } = []; - - /// - /// Workflow status published via SetCustomStatus so external clients can poll for streaming events and pending HITL requests. - /// - public DurableWorkflowLiveStatus LiveStatus { get; } = new(); - } - - /// - /// Represents prepared input for an executor ready for dispatch. - /// - private sealed record ExecutorInput(string ExecutorId, DurableMessageEnvelope Envelope, WorkflowExecutorInfo Info); - - /// - /// Collects inputs for all active executors, applying Fan-In aggregation where needed. - /// - private static List CollectExecutorInputs( - SuperstepState state, - ILogger logger) - { - List inputs = []; - - // Only process queues that have pending messages - foreach ((string executorId, Queue queue) in state.MessageQueues - .Where(kvp => kvp.Value.Count > 0)) - { - DurableMessageEnvelope envelope = GetNextEnvelope(executorId, queue, state.EdgeMap, logger); - WorkflowExecutorInfo executorInfo = CreateExecutorInfo(executorId, state.ExecutorBindings); - - inputs.Add(new ExecutorInput(executorId, envelope, executorInfo)); - } - - return inputs; - } - - private static DurableMessageEnvelope GetNextEnvelope( - string executorId, - Queue queue, - DurableEdgeMap edgeMap, - ILogger logger) - { - bool shouldAggregate = edgeMap.IsFanInExecutor(executorId) && queue.Count > 1; - - return shouldAggregate - ? AggregateQueueMessages(queue, executorId, logger) - : queue.Dequeue(); - } - - /// - /// Aggregates all messages in a queue into a JSON array for Fan-In executors. - /// - private static DurableMessageEnvelope AggregateQueueMessages( - Queue queue, - string executorId, - ILogger logger) - { - List messages = []; - List sourceIds = []; - - while (queue.Count > 0) - { - DurableMessageEnvelope envelope = queue.Dequeue(); - messages.Add(envelope.Message); - - if (envelope.SourceExecutorId is not null) - { - sourceIds.Add(envelope.SourceExecutorId); - } - } - - if (logger.IsEnabled(LogLevel.Debug)) - { - logger.LogFanInAggregated(executorId, messages.Count, string.Join(", ", sourceIds)); - } - - return new DurableMessageEnvelope - { - Message = SerializeToJsonArray(messages), - InputTypeName = typeof(string[]).FullName, - SourceExecutorId = sourceIds.Count > 0 ? string.Join(",", sourceIds) : null - }; - } - - /// - /// Processes results from a superstep, updating state and routing messages to successors. - /// - /// true if a halt was requested by any executor; otherwise, false. - private static bool ProcessSuperstepResults( - List inputs, - string[] rawResults, - SuperstepState state, - TaskOrchestrationContext context, - ILogger logger) - { - bool haltRequested = false; - - for (int i = 0; i < inputs.Count; i++) - { - string executorId = inputs[i].ExecutorId; - ExecutorResultInfo resultInfo = ParseActivityResult(rawResults[i]); - - logger.LogExecutorResultReceived(executorId, resultInfo.Result.Length, resultInfo.SentMessages.Count); - - state.LastResults[executorId] = resultInfo.Result; - - // Merge state updates from activity into shared state - MergeStateUpdates(state, resultInfo.StateUpdates, resultInfo.ClearedScopes); - - // Accumulate events for the durable workflow status (streaming) - state.AccumulatedEvents.AddRange(resultInfo.Events); - - // Check for halt request - haltRequested |= resultInfo.HaltRequested; - - // Publish events for live streaming (skip during replay) - if (!context.IsReplaying) - { - PublishEventsToLiveStatus(context, state); - } - - RouteOutputToSuccessors(executorId, resultInfo.Result, resultInfo.SentMessages, state, logger); - } - - return haltRequested; - } - - /// - /// Merges state updates from an executor into the shared state. - /// - /// - /// When concurrent executors in the same superstep modify keys in the same scope, - /// last-write-wins semantics apply. - /// - private static void MergeStateUpdates( - SuperstepState state, - Dictionary stateUpdates, - List clearedScopes) - { - Dictionary shared = state.SharedState; - - ApplyClearedScopes(shared, clearedScopes); - - // Apply individual state updates - foreach ((string key, string? value) in stateUpdates) - { - if (value is null) - { - shared.Remove(key); - } - else - { - shared[key] = value; - } - } - } - - /// - /// Removes all keys belonging to the specified scopes from the shared state dictionary. - /// - private static void ApplyClearedScopes(Dictionary shared, List clearedScopes) - { - if (clearedScopes.Count == 0 || shared.Count == 0) - { - return; - } - - List keysToRemove = []; - - foreach (string clearedScope in clearedScopes) - { - string scopePrefix = string.Concat(clearedScope, ":"); - keysToRemove.Clear(); - - foreach (string key in shared.Keys) - { - if (key.StartsWith(scopePrefix, StringComparison.Ordinal)) - { - keysToRemove.Add(key); - } - } - - foreach (string key in keysToRemove) - { - shared.Remove(key); - } - - if (shared.Count == 0) - { - break; - } - } - } - - /// - /// Publishes accumulated workflow events to the durable workflow's custom status, - /// making them available to for live streaming. - /// - /// - /// Custom status is the only orchestration state readable by external clients while - /// the orchestration is still running. It is cleared by the framework on completion, - /// so events are also included in for final retrieval. - /// - private static void PublishEventsToLiveStatus( - TaskOrchestrationContext context, - SuperstepState state) - { - state.LiveStatus.Events = state.AccumulatedEvents; - - // Pass the object directly — the framework's DataConverter handles serialization. - // Pre-serializing would cause double-serialization (string wrapped in JSON quotes). - context.SetCustomStatus(state.LiveStatus); - } - - /// - /// Routes executor output (explicit messages or return value) to successor executors. - /// - private static void RouteOutputToSuccessors( - string executorId, - string result, - List sentMessages, - SuperstepState state, - ILogger logger) - { - if (sentMessages.Count > 0) - { - // Only route messages that have content - foreach (TypedPayload message in sentMessages.Where(m => !string.IsNullOrEmpty(m.Data))) - { - state.EdgeMap.RouteMessage(executorId, message.Data!, message.TypeName, state.MessageQueues, logger); - } - - return; - } - - if (!string.IsNullOrEmpty(result)) - { - state.EdgeMap.RouteMessage(executorId, result, inputTypeName: null, state.MessageQueues, logger); - } - } - - /// - /// Serializes a list of messages into a JSON array. - /// - [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing string array.")] - [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing string array.")] - private static string SerializeToJsonArray(List messages) - { - return JsonSerializer.Serialize(messages); - } - - /// - /// Creates a for the given executor ID. - /// - /// Thrown when the executor ID is not found in bindings. - private static WorkflowExecutorInfo CreateExecutorInfo( - string executorId, - Dictionary executorBindings) - { - if (!executorBindings.TryGetValue(executorId, out ExecutorBinding? binding)) - { - throw new InvalidOperationException($"Executor '{executorId}' not found in workflow bindings."); - } - - bool isAgentic = WorkflowAnalyzer.IsAgentExecutorType(binding.ExecutorType); - RequestPort? requestPort = (binding is RequestPortBinding rpb) ? rpb.Port : null; - Workflow? subWorkflow = (binding is SubworkflowBinding swb) ? swb.WorkflowInstance : null; - - return new WorkflowExecutorInfo(executorId, isAgentic, requestPort, subWorkflow); - } - - /// - /// Returns the last non-empty result from executed steps, or empty string if none. - /// - private static string GetFinalResult(Dictionary lastResults) - { - return lastResults.Values.LastOrDefault(value => !string.IsNullOrEmpty(value)) ?? string.Empty; - } - - /// - /// Output from an executor invocation, including its result, - /// messages, state updates, and emitted workflow events. - /// - private sealed record ExecutorResultInfo( - string Result, - List SentMessages, - Dictionary StateUpdates, - List ClearedScopes, - List Events, - bool HaltRequested); - - /// - /// Parses the raw activity result to extract result, messages, events, and state updates. - /// - private static ExecutorResultInfo ParseActivityResult(string rawResult) - { - if (string.IsNullOrEmpty(rawResult)) - { - return new ExecutorResultInfo(rawResult, [], [], [], [], false); - } - - try - { - DurableExecutorOutput? output = JsonSerializer.Deserialize( - rawResult, - DurableWorkflowJsonContext.Default.DurableExecutorOutput); - - if (output is null || !HasMeaningfulContent(output)) - { - return new ExecutorResultInfo(rawResult, [], [], [], [], false); - } - - return new ExecutorResultInfo( - output.Result ?? string.Empty, - output.SentMessages, - output.StateUpdates, - output.ClearedScopes, - output.Events, - output.HaltRequested); - } - catch (JsonException) - { - return new ExecutorResultInfo(rawResult, [], [], [], [], false); - } - } - - /// - /// Determines whether the activity output contains meaningful content. - /// - /// - /// Distinguishes actual activity output from arbitrary JSON that deserialized - /// successfully but with all default/empty values. - /// - private static bool HasMeaningfulContent(DurableExecutorOutput output) - { - return output.Result is not null - || output.SentMessages?.Count > 0 - || output.Events?.Count > 0 - || output.StateUpdates?.Count > 0 - || output.ClearedScopes?.Count > 0 - || output.HaltRequested; - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowWaitingForInputEvent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowWaitingForInputEvent.cs deleted file mode 100644 index ed93c5928b9..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowWaitingForInputEvent.cs +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Text.Json; -using Microsoft.Agents.AI.Workflows; - -namespace Microsoft.Agents.AI.DurableTask.Workflows; - -/// -/// Event raised when the durable workflow is waiting for external input at a . -/// -/// The serialized input data that was passed to the RequestPort. -/// The request port definition. -[DebuggerDisplay("RequestPort = {RequestPort.Id}")] -public sealed class DurableWorkflowWaitingForInputEvent( - string Input, - RequestPort RequestPort) : WorkflowEvent -{ - /// - /// Gets the serialized input data that was passed to the RequestPort. - /// - public string Input { get; } = Input; - - /// - /// Gets the request port definition. - /// - public RequestPort RequestPort { get; } = RequestPort; - - /// - /// Attempts to deserialize the input data to the specified type. - /// - /// The type to deserialize to. - /// The deserialized input. - /// Thrown when the input cannot be deserialized to the specified type. - [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow types provided by the caller.")] - [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow types provided by the caller.")] - public T? GetInputAs() - { - return JsonSerializer.Deserialize(this.Input, DurableSerialization.Options); - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/EdgeRouters/DurableDirectEdgeRouter.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/EdgeRouters/DurableDirectEdgeRouter.cs deleted file mode 100644 index 3f780931839..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/EdgeRouters/DurableDirectEdgeRouter.cs +++ /dev/null @@ -1,156 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -// Routing decision flow for a single edge. -// Example: the B→D edge from a workflow like below: -// -// [A] ──► [B] ──► [C] ──► [E] (B→D has condition: x => x.NeedsReview) -// │ ▲ -// └──► [D] ──────┘ -// -// (condition: x => x.NeedsReview, _sourceOutputType: typeof(Order)) -// -// RouteMessage(envelope) envelope.Message = "{\"NeedsReview\":true, ...}" -// │ -// ▼ -// Has condition? ──── No ────► Enqueue to sink's queue -// │ -// Yes (B→D has one) -// │ -// ▼ -// Deserialize message JSON string → Order object using _sourceOutputType -// │ -// ▼ -// Evaluate _condition(order) order => order.NeedsReview -// │ -// ┌──┴──┐ -// true false -// │ │ -// ▼ └──► Skip (log and return, D will not run) -// Enqueue to -// D's queue - -using System.Diagnostics.CodeAnalysis; -using System.Text.Json; -using Microsoft.Extensions.Logging; - -namespace Microsoft.Agents.AI.DurableTask.Workflows.EdgeRouters; - -/// -/// Routes messages from a source executor to a single target executor with optional condition evaluation. -/// -/// -/// -/// Created by during construction — one instance per (source, sink) edge. -/// When an edge has a condition (e.g., order => order.Total > 1000), the router deserialises -/// the serialised JSON message back to the source executor's output type so the condition delegate -/// can evaluate it against strongly-typed properties. If the condition returns false, the -/// message is not forwarded and the target executor will not run for this edge. -/// -/// -/// For sources with multiple successors, individual instances -/// are wrapped in a so a single RouteMessage call -/// fans the same message out to all targets, each evaluating its own condition independently. -/// -/// -internal sealed class DurableDirectEdgeRouter : IDurableEdgeRouter -{ - private readonly string _sourceId; - private readonly string _sinkId; - private readonly Func? _condition; - private readonly Type? _sourceOutputType; - - /// - /// Initializes a new instance of . - /// - /// The source executor ID. - /// The target executor ID. - /// Optional condition function to evaluate before routing. - /// The output type of the source executor for deserialization. - internal DurableDirectEdgeRouter( - string sourceId, - string sinkId, - Func? condition, - Type? sourceOutputType) - { - this._sourceId = sourceId; - this._sinkId = sinkId; - this._condition = condition; - this._sourceOutputType = sourceOutputType; - } - - /// - public void RouteMessage( - DurableMessageEnvelope envelope, - Dictionary> messageQueues, - ILogger logger) - { - if (this._condition is not null) - { - try - { - object? messageObj = DeserializeForCondition(envelope.Message, this._sourceOutputType); - if (!this._condition(messageObj)) - { - logger.LogEdgeConditionFalse(this._sourceId, this._sinkId); - return; - } - } - catch (Exception ex) - { - logger.LogEdgeConditionEvaluationFailed(ex, this._sourceId, this._sinkId); - return; - } - } - - logger.LogEdgeRoutingMessage(this._sourceId, this._sinkId); - EnqueueMessage(messageQueues, this._sinkId, envelope); - } - - /// - /// Deserializes a JSON message to an object for condition evaluation. - /// - /// - /// Messages travel through the durable workflow as serialized JSON strings, but condition - /// delegates need typed objects to evaluate (e.g., order => order.Status == "Approved"). - /// This method converts the JSON back to an object the condition delegate can evaluate. - /// - /// The JSON string representation of the message. - /// - /// The expected type of the message. When provided, enables strongly-typed deserialization - /// so the condition function receives the correct type to evaluate against. - /// - /// - /// The deserialized object, or null if the JSON is empty. - /// - /// Thrown when the JSON is invalid or cannot be deserialized to the target type. - [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow types registered at startup.")] - [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow types registered at startup.")] - private static object? DeserializeForCondition(string json, Type? targetType) - { - if (string.IsNullOrEmpty(json)) - { - return null; - } - - // If we know the source executor's output type, deserialize to that specific type - // so the condition function can access strongly-typed properties. - // Otherwise, deserialize as a generic object for basic inspection. - return targetType is null - ? JsonSerializer.Deserialize(json, DurableSerialization.Options) - : JsonSerializer.Deserialize(json, targetType, DurableSerialization.Options); - } - - private static void EnqueueMessage( - Dictionary> queues, - string executorId, - DurableMessageEnvelope envelope) - { - if (!queues.TryGetValue(executorId, out Queue? queue)) - { - queue = new Queue(); - queues[executorId] = queue; - } - - queue.Enqueue(envelope); - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/EdgeRouters/DurableEdgeMap.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/EdgeRouters/DurableEdgeMap.cs deleted file mode 100644 index 69b8b7cc1c8..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/EdgeRouters/DurableEdgeMap.cs +++ /dev/null @@ -1,205 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -// How WorkflowGraphInfo maps to DurableEdgeMap at runtime. -// For a workflow like below: -// -// [A] ──► [B] ──► [C] ──► [E] -// │ ▲ -// └──► [D] ──────┘ -// (condition: x => x.NeedsReview) -// -// WorkflowGraphInfo DurableEdgeMap -// ┌──────────────────────────┐ ┌──────────────────────────────────────┐ -// │ Successors: │ │ _routersBySource: │ -// │ A → [B] │──constructs──►│ A → [DirectRouter(A→B)] │ -// │ B → [C, D] │ │ B → [FanOutRouter([C, D])] │ -// │ C → [E] │ │ C → [DirectRouter(C→E)] │ -// │ D → [E] │ │ D → [DirectRouter(D→E)] │ -// └──────────────────────────┘ │ │ -// ┌──────────────────────────┐ │ _predecessorCounts: │ -// │ Predecessors: │ │ A → 0 │ -// │ E → [C, D] (fan-in!) │──constructs──►│ B → 1, C → 1, D → 1 │ -// └──────────────────────────┘ │ E → 2 ◄── IsFanInExecutor = true │ -// └──────────────────────────────────────┘ -// -// Usage during superstep execution (continuing the example): -// -// 1. EnqueueInitialInput(msg) ──► MessageQueues["A"].Enqueue(envelope) -// -// 2. After B completes, RouteMessage("B", resultB) ──► _routersBySource["B"] -// │ -// ▼ -// FanOutRouter (B has 2 successors) -// ├─► DirectRouter(B→C) ──► no condition ──► enqueue to C -// └─► DirectRouter(B→D) ──► evaluate x => x.NeedsReview ──► enqueue to D (or skip) -// -// 3. Before superstep 4, IsFanInExecutor("E") returns true (count=2) -// → CollectExecutorInputs aggregates C and D results into ["resultC","resultD"] - -using Microsoft.Extensions.Logging; - -namespace Microsoft.Agents.AI.DurableTask.Workflows.EdgeRouters; - -/// -/// Manages message routing through workflow edges for durable orchestrations. -/// -/// -/// -/// This is the durable equivalent of EdgeMap in the in-process runner. -/// It is constructed from (produced by ) -/// and converts the static graph structure into an active routing layer used during superstep execution. -/// -/// -/// What it stores: -/// -/// -/// _routersBySource — For each source executor, a list of instances -/// that know how to deliver messages to successor executors. When a source has multiple successors, a single -/// wraps the individual instances. -/// _predecessorCounts — The number of predecessors for each executor, used to detect -/// fan-in points where multiple incoming messages should be aggregated before execution. -/// _startExecutorId — The entry-point executor that receives the initial workflow input. -/// -/// -/// How it is used during execution: -/// -/// -/// seeds the start executor's queue before the first superstep. -/// After each superstep, DurableWorkflowRunner.RouteOutputToSuccessors calls -/// which looks up the routers for the completed executor and forwards the -/// result to successor queues. Each router may evaluate an edge condition before enqueueing. -/// is checked during input collection to decide whether -/// to aggregate multiple queued messages into a single JSON array before dispatching. -/// -/// -internal sealed class DurableEdgeMap -{ - private readonly Dictionary> _routersBySource = []; - private readonly Dictionary _predecessorCounts = []; - private readonly string _startExecutorId; - - /// - /// Initializes a new instance of from workflow graph info. - /// - /// The workflow graph information containing routing structure. - internal DurableEdgeMap(WorkflowGraphInfo graphInfo) - { - ArgumentNullException.ThrowIfNull(graphInfo); - - this._startExecutorId = graphInfo.StartExecutorId; - - // Build edge routers for each source executor - foreach (KeyValuePair> entry in graphInfo.Successors) - { - string sourceId = entry.Key; - List successorIds = entry.Value; - - if (successorIds.Count == 0) - { - continue; - } - - graphInfo.ExecutorOutputTypes.TryGetValue(sourceId, out Type? sourceOutputType); - - List routers = []; - foreach (string sinkId in successorIds) - { - graphInfo.EdgeConditions.TryGetValue((sourceId, sinkId), out Func? condition); - - routers.Add(new DurableDirectEdgeRouter(sourceId, sinkId, condition, sourceOutputType)); - } - - // If multiple successors, wrap in a fan-out router - if (routers.Count > 1) - { - this._routersBySource[sourceId] = [new DurableFanOutEdgeRouter(sourceId, routers)]; - } - else - { - this._routersBySource[sourceId] = routers; - } - } - - // Store predecessor counts for fan-in detection - foreach (KeyValuePair> entry in graphInfo.Predecessors) - { - this._predecessorCounts[entry.Key] = entry.Value.Count; - } - } - - /// - /// Routes a message from a source executor to its successors. - /// - /// - /// Called by DurableWorkflowRunner.RouteOutputToSuccessors after each superstep. - /// Wraps the message in a and delegates to the - /// appropriate (s) for the source executor. Each router - /// may evaluate an edge condition and, if satisfied, enqueue the envelope into the - /// target executor's message queue for the next superstep. - /// - /// The source executor ID. - /// The serialized message to route. - /// The type name of the message. - /// The message queues to enqueue messages into. - /// The logger for tracing. - internal void RouteMessage( - string sourceId, - string message, - string? inputTypeName, - Dictionary> messageQueues, - ILogger logger) - { - if (!this._routersBySource.TryGetValue(sourceId, out List? routers)) - { - return; - } - - DurableMessageEnvelope envelope = DurableMessageEnvelope.Create(message, inputTypeName, sourceId); - - foreach (IDurableEdgeRouter router in routers) - { - router.RouteMessage(envelope, messageQueues, logger); - } - } - - /// - /// Enqueues the initial workflow input to the start executor. - /// - /// The serialized initial input message. - /// The message queues to enqueue into. - /// - /// This method is used only at workflow startup to provide input to the first executor. - /// No input type hint is required because the start executor determines its expected input type from its own InputTypes configuration. - /// - internal void EnqueueInitialInput( - string message, - Dictionary> messageQueues) - { - DurableMessageEnvelope envelope = DurableMessageEnvelope.Create(message, inputTypeName: null); - EnqueueMessage(messageQueues, this._startExecutorId, envelope); - } - - /// - /// Determines if an executor is a fan-in point (has multiple predecessors). - /// - /// The executor ID to check. - /// true if the executor has multiple predecessors; otherwise, false. - internal bool IsFanInExecutor(string executorId) - { - return this._predecessorCounts.TryGetValue(executorId, out int count) && count > 1; - } - - private static void EnqueueMessage( - Dictionary> queues, - string executorId, - DurableMessageEnvelope envelope) - { - if (!queues.TryGetValue(executorId, out Queue? queue)) - { - queue = new Queue(); - queues[executorId] = queue; - } - - queue.Enqueue(envelope); - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/EdgeRouters/DurableFanOutEdgeRouter.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/EdgeRouters/DurableFanOutEdgeRouter.cs deleted file mode 100644 index f13a0def923..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/EdgeRouters/DurableFanOutEdgeRouter.cs +++ /dev/null @@ -1,67 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -// Fan-out routing: one source message is forwarded to multiple targets. -// Example from a workflow like below: -// -// [A] ──► [B] ──► [C] ──► [E] (B→D has condition: x => x.NeedsReview) -// │ ▲ -// └──► [D] ──────┘ -// -// B has two successors (C and D), so DurableEdgeMap wraps them: -// -// Executor B completes with resultB (type: Order) -// │ -// ▼ -// FanOutRouter(B) -// ├──► DirectRouter(B→C) ──► no condition ──► enqueue to C -// └──► DirectRouter(B→D) ──► x => x.NeedsReview ──► enqueue to D (or skip) -// -// Each DirectRouter independently evaluates its condition, -// so resultB always reaches C, but only reaches D if NeedsReview is true. - -using Microsoft.Extensions.Logging; - -namespace Microsoft.Agents.AI.DurableTask.Workflows.EdgeRouters; - -/// -/// Routes messages from a source executor to multiple target executors (fan-out pattern). -/// -/// -/// Created by when a source executor has more than one successor. -/// Wraps the individual instances and delegates -/// to each of them, so the same message is evaluated and -/// potentially enqueued for every target independently. -/// -internal sealed class DurableFanOutEdgeRouter : IDurableEdgeRouter -{ - private readonly string _sourceId; - private readonly List _targetRouters; - - /// - /// Initializes a new instance of . - /// - /// The source executor ID. - /// The routers for each target executor. - internal DurableFanOutEdgeRouter(string sourceId, List targetRouters) - { - this._sourceId = sourceId; - this._targetRouters = targetRouters; - } - - /// - public void RouteMessage( - DurableMessageEnvelope envelope, - Dictionary> messageQueues, - ILogger logger) - { - if (logger.IsEnabled(LogLevel.Debug)) - { - logger.LogDebug("Fan-Out from {Source}: routing to {Count} targets", this._sourceId, this._targetRouters.Count); - } - - foreach (IDurableEdgeRouter targetRouter in this._targetRouters) - { - targetRouter.RouteMessage(envelope, messageQueues, logger); - } - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/EdgeRouters/IDurableEdgeRouter.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/EdgeRouters/IDurableEdgeRouter.cs deleted file mode 100644 index 692ca15b5fb..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/EdgeRouters/IDurableEdgeRouter.cs +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Extensions.Logging; - -namespace Microsoft.Agents.AI.DurableTask.Workflows.EdgeRouters; - -/// -/// Defines the contract for routing messages through workflow edges in durable orchestrations. -/// -/// -/// Implementations include for single-target routing -/// and for multi-target fan-out patterns. -/// -internal interface IDurableEdgeRouter -{ - /// - /// Routes a message from the source executor to its target(s). - /// - /// The message envelope containing the message and metadata. - /// The message queues to enqueue messages into. - /// The logger for tracing. - void RouteMessage( - DurableMessageEnvelope envelope, - Dictionary> messageQueues, - ILogger logger); -} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/ExecutorRegistry.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/ExecutorRegistry.cs deleted file mode 100644 index f747d497b34..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/ExecutorRegistry.cs +++ /dev/null @@ -1,83 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Diagnostics.CodeAnalysis; -using Microsoft.Agents.AI.Workflows; - -namespace Microsoft.Agents.AI.DurableTask.Workflows; - -/// -/// Provides a registry for executor bindings used in durable workflow orchestrations. -/// -/// -/// This registry enables lookup of executors by name, decoupled from specific workflow instances. -/// Executors are registered when workflows are added to . -/// -internal sealed class ExecutorRegistry -{ - private readonly Dictionary _executors = new(StringComparer.OrdinalIgnoreCase); - - /// - /// Gets the number of registered executors. - /// - internal int Count => this._executors.Count; - - /// - /// Attempts to get an executor registration by name. - /// - /// The executor name to look up. - /// When this method returns, contains the registration if found; otherwise, null. - /// if the executor was found; otherwise, . - internal bool TryGetExecutor(string executorName, [NotNullWhen(true)] out ExecutorRegistration? registration) - { - return this._executors.TryGetValue(executorName, out registration); - } - - /// - /// Registers an executor binding from a workflow. - /// - /// The executor name (without GUID suffix). - /// The full executor ID (may include GUID suffix). - /// The workflow containing the executor. - internal void Register(string executorName, string executorId, Workflow workflow) - { - ArgumentException.ThrowIfNullOrEmpty(executorName); - ArgumentException.ThrowIfNullOrEmpty(executorId); - ArgumentNullException.ThrowIfNull(workflow); - - Dictionary bindings = workflow.ReflectExecutors(); - if (!bindings.TryGetValue(executorId, out ExecutorBinding? binding)) - { - throw new InvalidOperationException($"Executor '{executorId}' not found in workflow."); - } - - this._executors.TryAdd(executorName, new ExecutorRegistration(executorId, binding)); - } -} - -/// -/// Represents a registered executor with its binding information. -/// -/// -/// The may differ from the registered name when the executor -/// ID includes an instance suffix (e.g., "ExecutorName_Guid"). -/// -/// The full executor ID (may include instance suffix). -/// The executor binding containing the factory and configuration. -internal sealed record ExecutorRegistration(string ExecutorId, ExecutorBinding Binding) -{ - /// - /// Creates an instance of the executor. - /// - /// A unique identifier for the run context. - /// The cancellation token. - /// The created executor instance. - internal async ValueTask CreateExecutorInstanceAsync(string runId, CancellationToken cancellationToken = default) - { - if (this.Binding.FactoryAsync is null) - { - throw new InvalidOperationException($"Cannot create executor '{this.ExecutorId}': Binding is a placeholder."); - } - - return await this.Binding.FactoryAsync(runId).ConfigureAwait(false); - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/IAwaitableWorkflowRun.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/IAwaitableWorkflowRun.cs deleted file mode 100644 index e25b77f52c6..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/IAwaitableWorkflowRun.cs +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -namespace Microsoft.Agents.AI.DurableTask.Workflows; - -/// -/// Represents a workflow run that can be awaited for completion. -/// -/// -/// -/// This interface extends to provide methods for waiting -/// until the workflow execution completes. Not all workflow runners support this capability. -/// -/// -/// Use pattern matching to check if a workflow run supports awaiting: -/// -/// IWorkflowRun run = await client.RunAsync(workflow, input); -/// if (run is IAwaitableWorkflowRun awaitableRun) -/// { -/// string? result = await awaitableRun.WaitForCompletionAsync<string>(); -/// } -/// -/// -/// -public interface IAwaitableWorkflowRun : IWorkflowRun -{ - /// - /// Waits for the workflow to complete and returns the result. - /// - /// The expected result type. - /// A cancellation token to observe. - /// The result of the workflow execution. - /// Thrown when the workflow failed or was terminated. - ValueTask WaitForCompletionAsync(CancellationToken cancellationToken = default); -} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/IStreamingWorkflowRun.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/IStreamingWorkflowRun.cs deleted file mode 100644 index 079ee7258ef..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/IStreamingWorkflowRun.cs +++ /dev/null @@ -1,55 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Agents.AI.Workflows; - -namespace Microsoft.Agents.AI.DurableTask.Workflows; - -/// -/// Represents a workflow run that supports streaming workflow events as they occur. -/// -/// -/// This interface defines the contract for streaming workflow runs in durable execution -/// environments. Implementations provide real-time access to workflow events. -/// -public interface IStreamingWorkflowRun -{ - /// - /// Gets the unique identifier for the run. - /// - /// - /// This identifier can be provided at the start of the run, or auto-generated. - /// For durable runs, this corresponds to the orchestration instance ID. - /// - string RunId { get; } - - /// - /// Asynchronously streams workflow events as they occur during workflow execution. - /// - /// - /// This method yields instances in real time as the workflow - /// progresses. The stream completes when the workflow completes, fails, or is terminated. - /// Events are delivered in the order they are raised. - /// - /// - /// A that can be used to cancel the streaming operation. - /// If cancellation is requested, the stream will end and no further events will be yielded. - /// - /// - /// An asynchronous stream of objects representing significant - /// workflow state changes. - /// - IAsyncEnumerable WatchStreamAsync(CancellationToken cancellationToken = default); - - /// - /// Sends a response to a to resume the workflow. - /// - /// The type of the response data. - /// The request event to respond to. - /// The response data to send. - /// A cancellation token to observe. - /// A representing the asynchronous operation. - ValueTask SendResponseAsync( - DurableWorkflowWaitingForInputEvent requestEvent, - TResponse response, - CancellationToken cancellationToken = default); -} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/IWorkflowClient.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/IWorkflowClient.cs deleted file mode 100644 index e84f3fe4cde..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/IWorkflowClient.cs +++ /dev/null @@ -1,71 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Agents.AI.Workflows; - -namespace Microsoft.Agents.AI.DurableTask.Workflows; - -/// -/// Defines a client for running and managing workflow executions. -/// -public interface IWorkflowClient -{ - /// - /// Runs a workflow and returns a handle to monitor its execution. - /// - /// The type of the input to the workflow. - /// The workflow to execute. - /// The input to pass to the workflow's starting executor. - /// Optional identifier for the run. If not provided, a new ID will be generated. - /// A cancellation token to observe. - /// An that can be used to monitor the workflow execution. - ValueTask RunAsync( - Workflow workflow, - TInput input, - string? runId = null, - CancellationToken cancellationToken = default) - where TInput : notnull; - - /// - /// Runs a workflow with string input and returns a handle to monitor its execution. - /// - /// The workflow to execute. - /// The string input to pass to the workflow. - /// Optional identifier for the run. If not provided, a new ID will be generated. - /// A cancellation token to observe. - /// An that can be used to monitor the workflow execution. - ValueTask RunAsync( - Workflow workflow, - string input, - string? runId = null, - CancellationToken cancellationToken = default); - - /// - /// Starts a workflow and returns a streaming handle to watch events in real-time. - /// - /// The type of the input to the workflow. - /// The workflow to execute. - /// The input to pass to the workflow's starting executor. - /// Optional identifier for the run. If not provided, a new ID will be generated. - /// A cancellation token to observe. - /// An that can be used to stream workflow events. - ValueTask StreamAsync( - Workflow workflow, - TInput input, - string? runId = null, - CancellationToken cancellationToken = default) - where TInput : notnull; - - /// - /// Starts a workflow with string input and returns a streaming handle to watch events in real-time. - /// - /// The workflow to execute. - /// The string input to pass to the workflow. - /// Optional identifier for the run. If not provided, a new ID will be generated. - /// A cancellation token to observe. - /// An that can be used to stream workflow events. - ValueTask StreamAsync( - Workflow workflow, - string input, - string? runId = null, - CancellationToken cancellationToken = default); -} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/IWorkflowRun.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/IWorkflowRun.cs deleted file mode 100644 index f6d5e5b203f..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/IWorkflowRun.cs +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Agents.AI.Workflows; - -namespace Microsoft.Agents.AI.DurableTask.Workflows; - -/// -/// Represents a running instance of a workflow. -/// -public interface IWorkflowRun -{ - /// - /// Gets the unique identifier for the run. - /// - /// - /// This identifier can be provided at the start of the run, or auto-generated. - /// For durable runs, this corresponds to the orchestration instance ID. - /// - string RunId { get; } - - /// - /// Gets all events that have been emitted by the workflow. - /// - IEnumerable OutgoingEvents { get; } - - /// - /// Gets the number of events emitted since the last access to . - /// - int NewEventCount { get; } - - /// - /// Gets all events emitted by the workflow since the last access to this property. - /// - /// - /// Each access to this property advances the bookmark, so subsequent accesses - /// will only return events emitted after the previous access. - /// - IEnumerable NewEvents { get; } -} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/PendingRequestPortStatus.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/PendingRequestPortStatus.cs deleted file mode 100644 index c60f00d5f65..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/PendingRequestPortStatus.cs +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -namespace Microsoft.Agents.AI.DurableTask.Workflows; - -/// -/// Represents a RequestPort the workflow is paused at, waiting for a response. -/// -/// The RequestPort ID identifying which input is needed. -/// The serialized request data passed to the RequestPort. -internal sealed record PendingRequestPortStatus( - string EventName, - string Input); diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/TypedPayload.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/TypedPayload.cs deleted file mode 100644 index 7c0998585a4..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/TypedPayload.cs +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -namespace Microsoft.Agents.AI.DurableTask.Workflows; - -/// -/// Pairs a JSON-serialized payload with its assembly-qualified type name -/// for type-safe deserialization across activity boundaries. -/// -internal sealed class TypedPayload -{ - /// - /// Gets or sets the assembly-qualified type name of the payload. - /// - public string? TypeName { get; set; } - - /// - /// Gets or sets the serialized payload data as JSON. - /// - public string? Data { get; set; } -} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/WorkflowAnalyzer.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/WorkflowAnalyzer.cs deleted file mode 100644 index bb4d2956166..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/WorkflowAnalyzer.cs +++ /dev/null @@ -1,245 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Agents.AI.Workflows; - -namespace Microsoft.Agents.AI.DurableTask.Workflows; - -/// -/// Analyzes workflow structure to extract executor metadata and build graph information -/// for message-driven execution. -/// -internal static class WorkflowAnalyzer -{ - private const string AgentExecutorTypeName = "AIAgentHostExecutor"; - private const string AgentAssemblyPrefix = "Microsoft.Agents.AI"; - private const string ExecutorTypePrefix = "Executor"; - - /// - /// Analyzes a workflow instance and returns a list of executors with their metadata. - /// - /// The workflow instance to analyze. - /// A list of executor information in workflow order. - internal static List GetExecutorsFromWorkflowInOrder(Workflow workflow) - { - ArgumentNullException.ThrowIfNull(workflow); - - return workflow.ReflectExecutors() - .Select(kvp => CreateExecutorInfo(kvp.Key, kvp.Value)) - .ToList(); - } - - /// - /// Builds the workflow graph information needed for message-driven execution. - /// - /// - /// - /// Extracts routing information including successors, predecessors, edge conditions, - /// and output types. Supports cyclic workflows through message-driven superstep execution. - /// - /// - /// The returned is consumed by DurableEdgeMap - /// to build the runtime routing layer: - /// Successors become IDurableEdgeRouter instances, - /// Predecessors become fan-in counts, and - /// EdgeConditions / ExecutorOutputTypes are passed into - /// DurableDirectEdgeRouter for conditional routing with typed deserialization. - /// - /// - /// The workflow instance to analyze. - /// A graph info object containing routing information. - internal static WorkflowGraphInfo BuildGraphInfo(Workflow workflow) - { - ArgumentNullException.ThrowIfNull(workflow); - - Dictionary executors = workflow.ReflectExecutors(); - - WorkflowGraphInfo graphInfo = new() - { - StartExecutorId = workflow.StartExecutorId - }; - - InitializeExecutorMappings(graphInfo, executors); - PopulateGraphFromEdges(graphInfo, workflow.Edges); - - return graphInfo; - } - - /// - /// Determines whether the specified executor type is an agentic executor. - /// - /// The executor type to check. - /// true if the executor is an agentic executor; otherwise, false. - internal static bool IsAgentExecutorType(Type executorType) - { - string typeName = executorType.FullName ?? executorType.Name; - string assemblyName = executorType.Assembly.GetName().Name ?? string.Empty; - - return typeName.Contains(AgentExecutorTypeName, StringComparison.OrdinalIgnoreCase) - && assemblyName.Contains(AgentAssemblyPrefix, StringComparison.OrdinalIgnoreCase); - } - - /// - /// Creates a from an executor binding. - /// - /// The unique identifier of the executor. - /// The executor binding containing type and configuration information. - /// A new instance with extracted metadata. - private static WorkflowExecutorInfo CreateExecutorInfo(string executorId, ExecutorBinding binding) - { - bool isAgentic = IsAgentExecutorType(binding.ExecutorType); - RequestPort? requestPort = (binding is RequestPortBinding rpb) ? rpb.Port : null; - Workflow? subWorkflow = (binding is SubworkflowBinding swb) ? swb.WorkflowInstance : null; - - return new WorkflowExecutorInfo(executorId, isAgentic, requestPort, subWorkflow); - } - - /// - /// Initializes the graph info with empty collections for each executor. - /// - /// The graph info to initialize. - /// The dictionary of executor bindings. - private static void InitializeExecutorMappings(WorkflowGraphInfo graphInfo, Dictionary executors) - { - foreach ((string executorId, ExecutorBinding binding) in executors) - { - graphInfo.Successors[executorId] = []; - graphInfo.Predecessors[executorId] = []; - graphInfo.ExecutorOutputTypes[executorId] = GetExecutorOutputType(binding.ExecutorType); - } - } - - /// - /// Populates the graph info with successor/predecessor relationships and edge conditions. - /// - /// The graph info to populate. - /// The dictionary of edges grouped by source executor ID. - private static void PopulateGraphFromEdges(WorkflowGraphInfo graphInfo, Dictionary> edges) - { - foreach ((string sourceId, HashSet edgeSet) in edges) - { - List successors = graphInfo.Successors[sourceId]; - - foreach (Edge edge in edgeSet) - { - AddSuccessorsFromEdge(graphInfo, sourceId, edge, successors); - TryAddEdgeCondition(graphInfo, edge); - } - } - } - - /// - /// Adds successor relationships from an edge to the graph info. - /// - /// The graph info to update. - /// The source executor ID. - /// The edge containing connection information. - /// The list of successors to append to. - private static void AddSuccessorsFromEdge( - WorkflowGraphInfo graphInfo, - string sourceId, - Edge edge, - List successors) - { - foreach (string sinkId in edge.Data.Connection.SinkIds) - { - if (!graphInfo.Successors.ContainsKey(sinkId)) - { - continue; - } - - successors.Add(sinkId); - graphInfo.Predecessors[sinkId].Add(sourceId); - } - } - - /// - /// Extracts and adds an edge condition to the graph info if present. - /// - /// The graph info to update. - /// The edge that may contain a condition. - private static void TryAddEdgeCondition(WorkflowGraphInfo graphInfo, Edge edge) - { - DirectEdgeData? directEdge = edge.DirectEdgeData; - - if (directEdge?.Condition is not null) - { - graphInfo.EdgeConditions[(directEdge.SourceId, directEdge.SinkId)] = directEdge.Condition; - } - } - - /// - /// Extracts the output type from an executor type by walking the inheritance chain. - /// - /// The executor type to analyze. - /// - /// The TOutput type for Executor<TInput, TOutput>, - /// or null for Executor<TInput> (void output) or non-executor types. - /// - private static Type? GetExecutorOutputType(Type executorType) - { - Type? currentType = executorType; - - while (currentType is not null) - { - Type? outputType = TryExtractOutputTypeFromGeneric(currentType); - if (outputType is not null || IsVoidExecutorType(currentType)) - { - return outputType; - } - - currentType = currentType.BaseType; - } - - return null; - } - - /// - /// Attempts to extract the output type from a generic executor type. - /// - /// The type to inspect. - /// The TOutput type if this is an Executor<TInput, TOutput>; otherwise, null. - private static Type? TryExtractOutputTypeFromGeneric(Type type) - { - if (!type.IsGenericType) - { - return null; - } - - Type genericDefinition = type.GetGenericTypeDefinition(); - Type[] genericArgs = type.GetGenericArguments(); - - bool isExecutorType = genericDefinition.Name.StartsWith(ExecutorTypePrefix, StringComparison.Ordinal); - if (!isExecutorType) - { - return null; - } - - // Executor - return TOutput - if (genericArgs.Length == 2) - { - return genericArgs[1]; - } - - return null; - } - - /// - /// Determines whether the type is a void-returning executor (Executor<TInput>). - /// - /// The type to check. - /// true if this is an Executor with a single type parameter; otherwise, false. - private static bool IsVoidExecutorType(Type type) - { - if (!type.IsGenericType) - { - return false; - } - - Type genericDefinition = type.GetGenericTypeDefinition(); - Type[] genericArgs = type.GetGenericArguments(); - - // Executor with 1 type parameter indicates void return - return genericArgs.Length == 1 - && genericDefinition.Name.StartsWith(ExecutorTypePrefix, StringComparison.Ordinal); - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/WorkflowExecutorInfo.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/WorkflowExecutorInfo.cs deleted file mode 100644 index ffaa9fbe1f5..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/WorkflowExecutorInfo.cs +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Agents.AI.Workflows; - -namespace Microsoft.Agents.AI.DurableTask.Workflows; - -/// -/// Represents an executor in the workflow with its metadata. -/// -/// The unique identifier of the executor. -/// Indicates whether this executor is an agentic executor. -/// The request port if this executor is a request port executor; otherwise, null. -/// The sub-workflow if this executor is a sub-workflow executor; otherwise, null. -internal sealed record WorkflowExecutorInfo( - string ExecutorId, - bool IsAgenticExecutor, - RequestPort? RequestPort = null, - Workflow? SubWorkflow = null) -{ - /// - /// Gets a value indicating whether this executor is a request port executor (human-in-the-loop). - /// - public bool IsRequestPortExecutor => this.RequestPort is not null; - - /// - /// Gets a value indicating whether this executor is a sub-workflow executor. - /// - public bool IsSubworkflowExecutor => this.SubWorkflow is not null; -} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/WorkflowGraphInfo.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/WorkflowGraphInfo.cs deleted file mode 100644 index a504a07b13a..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/WorkflowGraphInfo.cs +++ /dev/null @@ -1,98 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -// Example: Given this workflow graph with a fan-out from B and a fan-in at E, -// plus a conditional edge from B to D: -// -// [A] ──► [B] ──► [C] ──► [E] -// │ ▲ -// └──► [D] ──────┘ -// (condition: -// x => x.NeedsReview) -// -// WorkflowAnalyzer.BuildGraphInfo() produces: -// -// StartExecutorId = "A" -// -// Successors (who does each executor send output to?): -// ┌──────────┬──────────────┐ -// │ "A" │ ["B"] │ -// │ "B" │ ["C", "D"] │ ◄── fan-out: B sends to both C and D -// │ "C" │ ["E"] │ -// │ "D" │ ["E"] │ -// │ "E" │ [] │ ◄── terminal: no successors -// └──────────┴──────────────┘ -// -// Predecessors (who feeds into each executor?): -// ┌──────────┬──────────────┐ -// │ "A" │ [] │ ◄── start: no predecessors -// │ "B" │ ["A"] │ -// │ "C" │ ["B"] │ -// │ "D" │ ["B"] │ -// │ "E" │ ["C", "D"] │ ◄── fan-in: count=2, messages will be aggregated -// └──────────┴──────────────┘ -// -// EdgeConditions (which edges have routing conditions?): -// ┌──────────────────┬──────────────────────────┐ -// │ ("B", "D") │ x => x.NeedsReview │ ◄── D only receives if condition is true -// └──────────────────┴──────────────────────────┘ -// (The B→C edge has no condition, so C always receives B's output.) -// -// ExecutorOutputTypes (what type does each executor return?): -// ┌──────────┬──────────────────┐ -// │ "A" │ typeof(string) │ ◄── used by DurableDirectEdgeRouter to deserialize -// │ "B" │ typeof(Order) │ the JSON message for condition evaluation -// │ "C" │ typeof(Report) │ -// │ "D" │ typeof(Report) │ -// │ "E" │ typeof(string) │ -// └──────────┴──────────────────┘ -// -// DurableEdgeMap then consumes this to build the runtime routing layer. - -using System.Diagnostics; - -namespace Microsoft.Agents.AI.DurableTask.Workflows; - -/// -/// Represents the workflow graph structure needed for message-driven execution. -/// -/// -/// -/// This is a simplified representation that contains only the information needed -/// for routing messages between executors during superstep execution: -/// -/// -/// Successors for routing messages forward -/// Predecessors for detecting fan-in points -/// Edge conditions for conditional routing -/// Output types for deserialization during condition evaluation -/// -/// -[DebuggerDisplay("Start = {StartExecutorId}, Executors = {Successors.Count}")] -internal sealed class WorkflowGraphInfo -{ - /// - /// Gets or sets the starting executor ID for the workflow. - /// - public string StartExecutorId { get; set; } = string.Empty; - - /// - /// Maps each executor ID to its successors (for message routing). - /// - public Dictionary> Successors { get; } = []; - - /// - /// Maps each executor ID to its predecessors (for fan-in detection). - /// - public Dictionary> Predecessors { get; } = []; - - /// - /// Maps edge connections (sourceId, targetId) to their condition functions. - /// The condition function takes the predecessor's result and returns true if the edge should be followed. - /// - public Dictionary<(string SourceId, string TargetId), Func?> EdgeConditions { get; } = []; - - /// - /// Maps executor IDs to their output types (for proper deserialization during condition evaluation). - /// - public Dictionary ExecutorOutputTypes { get; } = []; -} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/WorkflowNamingHelper.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/WorkflowNamingHelper.cs deleted file mode 100644 index 0b657b3235a..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/WorkflowNamingHelper.cs +++ /dev/null @@ -1,113 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Diagnostics.CodeAnalysis; - -namespace Microsoft.Agents.AI.DurableTask.Workflows; - -/// -/// Provides helper methods for workflow naming conventions used in durable orchestrations. -/// -internal static class WorkflowNamingHelper -{ - internal const string OrchestrationFunctionPrefix = "dafx-"; - private const char ExecutorIdSuffixSeparator = '_'; - - /// - /// Converts a workflow name to its corresponding orchestration function name. - /// - /// The workflow name. - /// The orchestration function name. - /// Thrown when the workflow name is null or empty. - internal static string ToOrchestrationFunctionName(string workflowName) - { - ArgumentException.ThrowIfNullOrEmpty(workflowName); - return string.Concat(OrchestrationFunctionPrefix, workflowName); - } - - /// - /// Converts an orchestration function name back to its workflow name. - /// - /// The orchestration function name. - /// The workflow name. - /// Thrown when the orchestration function name is null, empty, or doesn't have the expected prefix. - internal static string ToWorkflowName(string orchestrationFunctionName) - { - ArgumentException.ThrowIfNullOrEmpty(orchestrationFunctionName); - - if (!TryGetWorkflowName(orchestrationFunctionName, out string? workflowName)) - { - throw new ArgumentException( - $"Orchestration function name '{orchestrationFunctionName}' does not have the expected '{OrchestrationFunctionPrefix}' prefix or is missing a workflow name.", - nameof(orchestrationFunctionName)); - } - - return workflowName; - } - - /// - /// Extracts the executor name from an executor ID. - /// - /// - /// - /// For non-agentic executors, the executor ID is the same as the executor name (e.g., "OrderParser"). - /// - /// - /// For agentic executors, the workflow builder appends a GUID suffix separated by an underscore - /// (e.g., "Physicist_8884e71021334ce49517fa2b17b1695b"). This method extracts just the name portion. - /// - /// - /// The executor ID, which may contain a GUID suffix. - /// The executor name without any GUID suffix. - /// Thrown when the executor ID is null or empty. - internal static string GetExecutorName(string executorId) - { - ArgumentException.ThrowIfNullOrEmpty(executorId); - - int separatorIndex = executorId.LastIndexOf(ExecutorIdSuffixSeparator); - if (separatorIndex > 0) - { - ReadOnlySpan suffix = executorId.AsSpan(separatorIndex + 1); - if (IsGuidSuffix(suffix)) - { - return executorId[..separatorIndex]; - } - } - - return executorId; - } - - /// - /// Checks whether the given span looks like a sanitized GUID (32 hex characters). - /// - private static bool IsGuidSuffix(ReadOnlySpan value) - { - if (value.Length != 32) - { - return false; - } - - foreach (char c in value) - { - if (!char.IsAsciiHexDigit(c)) - { - return false; - } - } - - return true; - } - - private static bool TryGetWorkflowName(string? orchestrationFunctionName, [NotNullWhen(true)] out string? workflowName) - { - workflowName = null; - - if (string.IsNullOrEmpty(orchestrationFunctionName) || - !orchestrationFunctionName.StartsWith(OrchestrationFunctionPrefix, StringComparison.Ordinal)) - { - return false; - } - - workflowName = orchestrationFunctionName[OrchestrationFunctionPrefix.Length..]; - return workflowName.Length > 0; - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctionExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctionExecutor.cs deleted file mode 100644 index 64ec846eb8f..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctionExecutor.cs +++ /dev/null @@ -1,214 +0,0 @@ -// 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); - - // Orchestration triggers use a different input binding mechanism than other triggers. - // The encoded orchestrator state is retrieved via BindInputAsync on the orchestration trigger binding, - // not through IFunctionInputBindingFeature. Handle this case first to avoid unnecessary binding work. - if (context.FunctionDefinition.EntryPoint == BuiltInFunctions.RunWorkflowOrchestrationFunctionEntryPoint) - { - await ExecuteOrchestrationAsync(context); - return; - } - - // Acquire the input binding feature (fail fast if missing rather than null-forgiving operator). - IFunctionInputBindingFeature? functionInputBindingFeature = context.Features.Get() ?? - 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; - string? encodedEntityRequest = null; - DurableTaskClient? durableTaskClient = null; - ToolInvocationContext? mcpToolInvocationContext = null; - - foreach (var binding in values) - { - switch (binding) - { - case HttpRequestData request: - httpRequestData = request; - break; - case string entityRequest: - encodedEntityRequest = entityRequest; - 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 (other than orchestration triggers) - // 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.RunWorkflowOrchestrationHttpFunctionEntryPoint) - { - if (httpRequestData == null) - { - throw new InvalidOperationException($"HTTP request data binding is missing for the invocation {context.InvocationId}."); - } - - context.GetInvocationResult().Value = await BuiltInFunctions.RunWorkflowOrchestrationHttpTriggerAsync( - httpRequestData, - durableTaskClient, - context); - return; - } - - if (context.FunctionDefinition.EntryPoint == BuiltInFunctions.GetWorkflowStatusHttpFunctionEntryPoint) - { - if (httpRequestData == null) - { - throw new InvalidOperationException($"HTTP request data binding is missing for the invocation {context.InvocationId}."); - } - - context.GetInvocationResult().Value = await BuiltInFunctions.GetWorkflowStatusAsync( - httpRequestData, - durableTaskClient, - context); - return; - } - - if (context.FunctionDefinition.EntryPoint == BuiltInFunctions.RespondToWorkflowHttpFunctionEntryPoint) - { - if (httpRequestData == null) - { - throw new InvalidOperationException($"HTTP request data binding is missing for the invocation {context.InvocationId}."); - } - - context.GetInvocationResult().Value = await BuiltInFunctions.RespondToWorkflowAsync( - httpRequestData, - durableTaskClient, - context); - return; - } - - if (context.FunctionDefinition.EntryPoint == BuiltInFunctions.InvokeWorkflowActivityFunctionEntryPoint) - { - if (encodedEntityRequest is null) - { - throw new InvalidOperationException($"Activity trigger input binding is missing for the invocation {context.InvocationId}."); - } - - context.GetInvocationResult().Value = await BuiltInFunctions.InvokeWorkflowActivityAsync( - encodedEntityRequest, - durableTaskClient, - context); - return; - } - - 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 (encodedEntityRequest is null) - { - throw new InvalidOperationException($"Task entity dispatcher binding is missing for the invocation {context.InvocationId}."); - } - - context.GetInvocationResult().Value = await BuiltInFunctions.InvokeAgentAsync( - durableTaskClient, - encodedEntityRequest, - 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; - } - - if (context.FunctionDefinition.EntryPoint == BuiltInFunctions.RunWorkflowMcpToolFunctionEntryPoint) - { - if (mcpToolInvocationContext is null) - { - throw new InvalidOperationException($"MCP tool invocation context binding is missing for the invocation {context.InvocationId}."); - } - - context.GetInvocationResult().Value = await BuiltInFunctions.RunWorkflowMcpToolAsync( - mcpToolInvocationContext, - durableTaskClient, - context); - return; - } - - throw new InvalidOperationException($"Unsupported function entry point '{context.FunctionDefinition.EntryPoint}' for invocation {context.InvocationId}."); - } - - private static async ValueTask ExecuteOrchestrationAsync(FunctionContext context) - { - BindingMetadata? orchestrationBinding = null; - foreach (BindingMetadata binding in context.FunctionDefinition.InputBindings.Values) - { - if (string.Equals(binding.Type, "orchestrationTrigger", StringComparison.OrdinalIgnoreCase)) - { - orchestrationBinding = binding; - break; - } - } - - if (orchestrationBinding is null) - { - throw new InvalidOperationException($"Orchestration trigger binding is missing for the invocation {context.InvocationId}."); - } - - InputBindingData triggerInputData = await context.BindInputAsync(orchestrationBinding); - if (triggerInputData?.Value is not string encodedOrchestratorState) - { - throw new InvalidOperationException($"Orchestration history state was either missing from the input or not a string value for invocation {context.InvocationId}."); - } - - context.GetInvocationResult().Value = BuiltInFunctions.RunWorkflowOrchestration( - encodedOrchestratorState, - context); - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs deleted file mode 100644 index 478be50d6f2..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs +++ /dev/null @@ -1,794 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Net; -using System.Text.Json; -using System.Text.Json.Serialization; -using Microsoft.Agents.AI.DurableTask; -using Microsoft.Agents.AI.DurableTask.Workflows; -using Microsoft.Azure.Functions.Worker; -using Microsoft.Azure.Functions.Worker.Extensions.Mcp; -using Microsoft.Azure.Functions.Worker.Http; -using Microsoft.DurableTask; -using Microsoft.DurableTask.Client; -using Microsoft.DurableTask.Worker.Grpc; -using Microsoft.Extensions.AI; -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 const string StatusFunctionSuffix = "-status"; - internal const string RespondFunctionSuffix = "-respond"; - - private const string WaitForResponseHeaderName = "x-ms-wait-for-response"; - - 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)}"; - internal static readonly string RunWorkflowOrchestrationHttpFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RunWorkflowOrchestrationHttpTriggerAsync)}"; - internal static readonly string RunWorkflowOrchestrationFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RunWorkflowOrchestration)}"; - internal static readonly string InvokeWorkflowActivityFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(InvokeWorkflowActivityAsync)}"; - internal static readonly string GetWorkflowStatusHttpFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(GetWorkflowStatusAsync)}"; - internal static readonly string RespondToWorkflowHttpFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RespondToWorkflowAsync)}"; - internal static readonly string RunWorkflowMcpToolFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RunWorkflowMcpToolAsync)}"; - -#pragma warning disable IL3000 // Avoid accessing Assembly file path when publishing as a single file - Azure Functions does not use single-file publishing - internal static readonly string ScriptFile = Path.GetFileName(typeof(BuiltInFunctions).Assembly.Location); -#pragma warning restore IL3000 - - /// - /// Starts a workflow orchestration in response to an HTTP request. - /// The workflow name is derived from the function name by stripping the . - /// Callers can optionally provide a custom run ID via the runId query string parameter - /// (e.g., /api/workflows/MyWorkflow/run?runId=my-id). If not provided, one is auto-generated. - /// - public static async Task RunWorkflowOrchestrationHttpTriggerAsync( - [HttpTrigger] HttpRequestData req, - [DurableClient] DurableTaskClient client, - FunctionContext context) - { - string workflowName = context.FunctionDefinition.Name.Replace(HttpPrefix, string.Empty); - string orchestrationFunctionName = WorkflowNamingHelper.ToOrchestrationFunctionName(workflowName); - string? inputMessage = await req.ReadAsStringAsync(); - - if (string.IsNullOrEmpty(inputMessage)) - { - return await CreateErrorResponseAsync(req, context, HttpStatusCode.BadRequest, "Workflow input cannot be empty."); - } - - DurableWorkflowInput orchestrationInput = new() { Input = inputMessage }; - - // Allow users to provide a custom run ID via query string; otherwise, auto-generate one. - string? instanceId = req.Query["runId"]; - StartOrchestrationOptions? options = instanceId is not null ? new StartOrchestrationOptions(instanceId) : null; - string resolvedInstanceId = await client.ScheduleNewOrchestrationInstanceAsync(orchestrationFunctionName, orchestrationInput, options); - - if (ShouldWaitForResponse(req, defaultValue: false)) - { - return await WaitForWorkflowCompletionAsync(req, client, context, resolvedInstanceId); - } - - HttpResponseData response = req.CreateResponse(HttpStatusCode.Accepted); - await response.WriteStringAsync($"Workflow orchestration started for {workflowName}. Orchestration runId: {resolvedInstanceId}"); - return response; - } - - /// - /// Returns the workflow status including any pending HITL requests. - /// The run ID is extracted from the route parameter {runId}. - /// - public static async Task GetWorkflowStatusAsync( - [HttpTrigger] HttpRequestData req, - [DurableClient] DurableTaskClient client, - FunctionContext context) - { - string? runId = context.BindingContext.BindingData.TryGetValue("runId", out object? value) ? value?.ToString() : null; - if (string.IsNullOrEmpty(runId)) - { - return await CreateErrorResponseAsync(req, context, HttpStatusCode.BadRequest, "Run ID is required."); - } - - OrchestrationMetadata? metadata = await client.GetInstanceAsync(runId, getInputsAndOutputs: true); - if (metadata is null || !IsOrchestrationOwnedByWorkflow(metadata.Name, context.FunctionDefinition.Name, StatusFunctionSuffix)) - { - return await CreateErrorResponseAsync(req, context, HttpStatusCode.NotFound, $"Workflow run '{runId}' not found."); - } - - // Parse HITL inputs the workflow is waiting for from the durable workflow status - List? waitingForInput = null; - if (DurableWorkflowLiveStatus.TryParse(metadata.SerializedCustomStatus, out DurableWorkflowLiveStatus liveStatus) - && liveStatus.PendingEvents.Count > 0) - { - waitingForInput = liveStatus.PendingEvents; - } - - HttpResponseData response = req.CreateResponse(HttpStatusCode.OK); - await response.WriteAsJsonAsync(new - { - runId, - status = metadata.RuntimeStatus.ToString(), - waitingForInput = waitingForInput?.Select(p => new { eventName = p.EventName, input = JsonDocument.Parse(p.Input).RootElement }) - }); - return response; - } - - /// - /// Sends a response to a pending RequestPort, resuming the workflow. - /// Expects a JSON body: { "eventName": "...", "response": { ... } }. - /// - public static async Task RespondToWorkflowAsync( - [HttpTrigger] HttpRequestData req, - [DurableClient] DurableTaskClient client, - FunctionContext context) - { - string? runId = context.BindingContext.BindingData.TryGetValue("runId", out object? value) ? value?.ToString() : null; - if (string.IsNullOrEmpty(runId)) - { - return await CreateErrorResponseAsync(req, context, HttpStatusCode.BadRequest, "Run ID is required."); - } - - WorkflowRespondRequest? request; - try - { - request = await req.ReadFromJsonAsync(context.CancellationToken); - } - catch (JsonException) - { - return await CreateErrorResponseAsync(req, context, HttpStatusCode.BadRequest, "Request body is not valid JSON."); - } - - if (request is null || string.IsNullOrEmpty(request.EventName) - || request.Response.ValueKind == JsonValueKind.Undefined) - { - return await CreateErrorResponseAsync(req, context, HttpStatusCode.BadRequest, "Body must contain a non-empty 'eventName' and a 'response' property."); - } - - // Verify the orchestration exists and is in a valid state - OrchestrationMetadata? metadata = await client.GetInstanceAsync(runId, getInputsAndOutputs: true); - if (metadata is null || !IsOrchestrationOwnedByWorkflow(metadata.Name, context.FunctionDefinition.Name, RespondFunctionSuffix)) - { - return await CreateErrorResponseAsync(req, context, HttpStatusCode.NotFound, $"Workflow run '{runId}' not found."); - } - - if (metadata.RuntimeStatus is OrchestrationRuntimeStatus.Completed - or OrchestrationRuntimeStatus.Failed - or OrchestrationRuntimeStatus.Terminated) - { - return await CreateErrorResponseAsync(req, context, HttpStatusCode.BadRequest, - $"Workflow run '{runId}' is in terminal state '{metadata.RuntimeStatus}'."); - } - - // Verify the workflow is waiting for the specified event. - // If status can't be parsed (e.g., not yet set during early execution), allow the event through — - // Durable Task safely queues it until the orchestration reaches WaitForExternalEvent. - bool eventValidated = false; - if (DurableWorkflowLiveStatus.TryParse(metadata.SerializedCustomStatus, out DurableWorkflowLiveStatus liveStatus)) - { - if (!liveStatus.PendingEvents.Exists(p => string.Equals(p.EventName, request.EventName, StringComparison.Ordinal))) - { - return await CreateErrorResponseAsync(req, context, HttpStatusCode.BadRequest, - $"Workflow is not waiting for event '{request.EventName}'."); - } - - eventValidated = true; - } - - // Raise the external event to unblock the orchestration's WaitForExternalEvent call - await client.RaiseEventAsync(runId, request.EventName, request.Response.GetRawText()); - - HttpResponseData response = req.CreateResponse(HttpStatusCode.Accepted); - await response.WriteAsJsonAsync(new - { - message = eventValidated - ? "Response sent to workflow." - : "Response sent to workflow. Event could not be validated against pending requests.", - runId, - eventName = request.EventName, - validated = eventValidated, - }); - return response; - } - - /// - /// Executes a workflow activity by looking up the registered executor and delegating to it. - /// The executor name is derived from the activity function name via . - /// - public static Task InvokeWorkflowActivityAsync( - [ActivityTrigger] string input, - [DurableClient] DurableTaskClient durableTaskClient, - FunctionContext functionContext) - { - ArgumentNullException.ThrowIfNull(input); - ArgumentNullException.ThrowIfNull(durableTaskClient); - ArgumentNullException.ThrowIfNull(functionContext); - - string activityFunctionName = functionContext.FunctionDefinition.Name; - string executorName = WorkflowNamingHelper.ToWorkflowName(activityFunctionName); - - DurableOptions durableOptions = functionContext.InstanceServices.GetRequiredService(); - if (!durableOptions.Workflows.Executors.TryGetExecutor(executorName, out ExecutorRegistration? registration)) - { - throw new InvalidOperationException($"Executor '{executorName}' not found in workflow options."); - } - - return DurableActivityExecutor.ExecuteAsync(registration.Binding, input, functionContext.CancellationToken); - } - - /// - /// Runs a workflow orchestration by delegating to - /// via . - /// - public static string RunWorkflowOrchestration( - string encodedOrchestratorRequest, - FunctionContext functionContext) - { - ArgumentNullException.ThrowIfNull(encodedOrchestratorRequest); - ArgumentNullException.ThrowIfNull(functionContext); - - WorkflowOrchestrator orchestrator = new(functionContext.InstanceServices); - return GrpcOrchestrationRunner.LoadAndRun(encodedOrchestratorRequest, orchestrator, functionContext.InstanceServices); - } - - // Exposed as an entity trigger via AgentFunctionsProvider - public static Task InvokeAgentAsync( - [DurableClient] DurableTaskClient client, - string encodedEntityRequest, - FunctionContext functionContext) - { - // This should never be null except if the function trigger is misconfigured. - ArgumentNullException.ThrowIfNull(client); - ArgumentNullException.ThrowIfNull(encodedEntityRequest); - 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. - AgentEntity entity = new(combinedServiceProvider, functionContext.CancellationToken); - return GrpcEntityRunner.LoadAndRunAsync(encodedEntityRequest, entity, combinedServiceProvider); - } - - 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 session 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; - - // The thread_id is treated as a session key (not a full session ID). - // If no session key is provided, use the function invocation ID as the session key - // to help correlate the session with the function invocation. - string agentName = GetAgentName(context); - AgentSessionId sessionId = string.IsNullOrEmpty(threadIdValue) - ? new AgentSessionId(agentName, context.InvocationId) - : new AgentSessionId(agentName, 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 = ShouldWaitForResponse(req, defaultValue: true); - - AIAgent agentProxy = client.AsDurableAgentProxy(context, agentName); - - DurableAgentRunOptions options = new() { IsFireAndForget = !waitForResponse }; - - if (waitForResponse) - { - AgentResponse agentResponse = await agentProxy.RunAsync( - message: new ChatMessage(ChatRole.User, message), - session: new DurableAgentSession(sessionId), - options: options, - cancellationToken: context.CancellationToken); - - return await CreateSuccessResponseAsync( - req, - context, - HttpStatusCode.OK, - sessionId.Key, - agentResponse); - } - - // Fire and forget - return 202 Accepted - await agentProxy.RunAsync( - message: new ChatMessage(ChatRole.User, message), - session: new DurableAgentSession(sessionId), - options: options, - cancellationToken: context.CancellationToken); - - return await CreateAcceptedResponseAsync( - req, - context, - sessionId.Key); - } - - 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; - - // Bind the caller-supplied threadId as a session key under the current agent name, - // mirroring the behavior of RunAgentHttpAsync. - AgentSessionId sessionId = context.Arguments.TryGetValue("threadId", out object? threadObj) && threadObj is string threadId && !string.IsNullOrWhiteSpace(threadId) - ? new AgentSessionId(agentName, threadId) - : new AgentSessionId(agentName, functionContext.InvocationId); - - AIAgent agentProxy = client.AsDurableAgentProxy(functionContext, agentName); - - AgentResponse agentResponse = await agentProxy.RunAsync( - message: new ChatMessage(ChatRole.User, query), - session: new DurableAgentSession(sessionId), - options: null); - - return agentResponse.Text; - } - - /// - /// Runs a workflow via MCP tool trigger. - /// Extracts the input argument, schedules a new orchestration, waits for completion, and returns the output. - /// - public static async Task RunWorkflowMcpToolAsync( - [McpToolTrigger("BuiltInWorkflowMcpTool")] 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("input", out object? inputObj) || inputObj is not string input) - { - throw new ArgumentException("MCP Tool invocation is missing required 'input' argument of type string."); - } - - string workflowName = context.Name; - string orchestrationFunctionName = WorkflowNamingHelper.ToOrchestrationFunctionName(workflowName); - - DurableWorkflowInput orchestrationInput = new() { Input = input }; - string instanceId = await client.ScheduleNewOrchestrationInstanceAsync(orchestrationFunctionName, orchestrationInput); - - OrchestrationMetadata? metadata = await client.WaitForInstanceCompletionAsync( - instanceId, - getInputsAndOutputs: true, - cancellation: functionContext.CancellationToken); - - if (metadata is null) - { - throw new InvalidOperationException($"Workflow orchestration '{instanceId}' returned no metadata."); - } - - if (metadata.RuntimeStatus is OrchestrationRuntimeStatus.Failed) - { - string errorMessage = metadata.FailureDetails?.ErrorMessage ?? "Unknown error"; - throw new InvalidOperationException($"Workflow orchestration '{instanceId}' failed: {errorMessage}"); - } - - if (metadata.RuntimeStatus is not OrchestrationRuntimeStatus.Completed) - { - throw new InvalidOperationException($"Workflow orchestration '{instanceId}' ended with unexpected status '{metadata.RuntimeStatus}'."); - } - - return metadata.ReadOutputAs()?.Result; - } - - /// - /// Waits for a workflow orchestration to complete and returns an appropriate HTTP response. - /// - private static async Task WaitForWorkflowCompletionAsync( - HttpRequestData req, - DurableTaskClient client, - FunctionContext context, - string instanceId) - { - bool acceptsJson = AcceptsJson(req); - - OrchestrationMetadata? metadata = await client.WaitForInstanceCompletionAsync( - instanceId, - getInputsAndOutputs: true, - cancellation: context.CancellationToken); - - if (metadata is null) - { - return await CreateErrorResponseAsync(req, context, HttpStatusCode.NotFound, - $"No workflow orchestration with ID '{instanceId}' was found.", acceptsJson); - } - - if (metadata.RuntimeStatus is OrchestrationRuntimeStatus.Failed) - { - string errorMessage = metadata.FailureDetails?.ErrorMessage ?? "Unknown error"; - HttpResponseData failedResponse = req.CreateResponse(HttpStatusCode.OK); - - if (acceptsJson) - { - await failedResponse.WriteAsJsonAsync( - new WorkflowRunResponse(instanceId, metadata.RuntimeStatus.ToString(), Result: null, Error: errorMessage), - context.CancellationToken); - } - else - { - failedResponse.Headers.Add("Content-Type", "text/plain"); - await failedResponse.WriteStringAsync(errorMessage, context.CancellationToken); - } - - return failedResponse; - } - - if (metadata.RuntimeStatus is not OrchestrationRuntimeStatus.Completed) - { - return await CreateErrorResponseAsync(req, context, HttpStatusCode.InternalServerError, - $"Workflow orchestration '{instanceId}' ended with unexpected status '{metadata.RuntimeStatus}'.", acceptsJson); - } - - string? result = metadata.ReadOutputAs()?.Result; - - HttpResponseData response = req.CreateResponse(HttpStatusCode.OK); - - if (acceptsJson) - { - JsonElement? resultElement = null; - if (!string.IsNullOrEmpty(result)) - { - try - { - using JsonDocument doc = JsonDocument.Parse(result); - resultElement = doc.RootElement.Clone(); - } - catch (JsonException) - { - // Result is a plain string (not valid JSON) — serialize it as a JSON string element. - var buffer = new System.Buffers.ArrayBufferWriter(); - using (var writer = new Utf8JsonWriter(buffer)) - { - writer.WriteStringValue(result); - } - - using JsonDocument fallbackDoc = JsonDocument.Parse(buffer.WrittenMemory); - resultElement = fallbackDoc.RootElement.Clone(); - } - } - - await response.WriteAsJsonAsync( - new WorkflowRunResponse(instanceId, metadata.RuntimeStatus.ToString(), resultElement), - context.CancellationToken); - } - else - { - response.Headers.Add("Content-Type", "text/plain"); - await response.WriteStringAsync(result ?? string.Empty, context.CancellationToken); - } - - return response; - } - - /// - /// 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. - /// Optional pre-computed value indicating whether the client accepts JSON. When , the value is determined from the request's Accept header. - /// The HTTP response data containing the error. - private static async Task CreateErrorResponseAsync( - HttpRequestData req, - FunctionContext context, - HttpStatusCode statusCode, - string errorMessage, - bool? acceptsJson = null) - { - HttpResponseData response = req.CreateResponse(statusCode); - - if (acceptsJson ?? AcceptsJson(req)) - { - 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 session 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 sessionId, - AgentResponse agentResponse) - { - HttpResponseData response = req.CreateResponse(statusCode); - response.Headers.Add("x-ms-thread-id", sessionId); - - if (AcceptsJson(req)) - { - AgentRunSuccessResponse successResponse = new((int)statusCode, sessionId, 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 session ID for the conversation. - /// The HTTP response data containing the accepted response. - private static async Task CreateAcceptedResponseAsync( - HttpRequestData req, - FunctionContext context, - string sessionId) - { - HttpResponseData response = req.CreateResponse(HttpStatusCode.Accepted); - response.Headers.Add("x-ms-thread-id", sessionId); - - if (AcceptsJson(req)) - { - AgentRunAcceptedResponse acceptedResponse = new((int)HttpStatusCode.Accepted, sessionId); - await response.WriteAsJsonAsync(acceptedResponse, context.CancellationToken); - } - else - { - response.Headers.Add("Content-Type", "text/plain"); - await response.WriteStringAsync("Request accepted.", context.CancellationToken); - } - - return response; - } - - /// - /// Returns when the caller has requested waiting for the workflow/agent to complete, - /// as indicated by the x-ms-wait-for-response header. Falls back to - /// when the header is absent or not a valid boolean. - /// - private static bool ShouldWaitForResponse(HttpRequestData req, bool defaultValue) - { - if (req.Headers.TryGetValues(WaitForResponseHeaderName, out IEnumerable? values) && - bool.TryParse(values.FirstOrDefault(), out bool parsed)) - { - return parsed; - } - - return defaultValue; - } - - /// - /// Returns when the request accepts the application/json media type. - /// - private static bool AcceptsJson(HttpRequestData req) - { - return req.Headers.TryGetValues("Accept", out IEnumerable? acceptValues) && - acceptValues - .SelectMany(v => v.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) - .Select(v => v.Split(';', 2)[0].Trim()) - .Contains("application/json", StringComparer.OrdinalIgnoreCase); - } - - 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..]; - } - - /// - /// Extracts the workflow name from the function definition name by stripping the - /// and the given suffix (e.g., "-status" or "-respond"). - /// - internal static string GetWorkflowName(string functionName, string suffix) - { - if (!functionName.StartsWith(HttpPrefix, StringComparison.Ordinal) || - !functionName.EndsWith(suffix, StringComparison.Ordinal)) - { - throw new InvalidOperationException( - $"Built-in HTTP trigger function name '{functionName}' does not match the expected pattern '{HttpPrefix}{suffix}'."); - } - - return functionName[HttpPrefix.Length..^suffix.Length]; - } - - /// - /// Returns true if the orchestration name matches the expected orchestration for the - /// workflow derived from the given function name and suffix. - /// - internal static bool IsOrchestrationOwnedByWorkflow(string orchestrationName, string functionName, string suffix) - { - if (!functionName.StartsWith(HttpPrefix, StringComparison.Ordinal) || - !functionName.EndsWith(suffix, StringComparison.Ordinal)) - { - return false; - } - - string workflowName = GetWorkflowName(functionName, suffix); - string expectedOrchestrationName = WorkflowNamingHelper.ToOrchestrationFunctionName(workflowName); - return string.Equals(orchestrationName, expectedOrchestrationName, StringComparison.OrdinalIgnoreCase); - } - - /// - /// Represents a request to run an agent. - /// - /// The message to send to the agent. - /// The optional session 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 session 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")] AgentResponse Response); - - /// - /// Represents an accepted (fire-and-forget) agent run response. - /// - /// The HTTP status code. - /// The session ID for the conversation. - private sealed record AgentRunAcceptedResponse( - [property: JsonPropertyName("status")] int Status, - [property: JsonPropertyName("thread_id")] string ThreadId); - - /// - /// Represents a request to respond to a pending RequestPort in a workflow. - /// - /// The name of the event to raise (the RequestPort ID). - /// The response payload to send to the workflow. - private sealed record WorkflowRespondRequest( - [property: JsonPropertyName("eventName")] string? EventName, - [property: JsonPropertyName("response")] JsonElement Response); - - /// - /// Represents a workflow run response when waiting for completion. - /// - /// The orchestration run ID. - /// The orchestration runtime status (e.g., "Completed", "Failed"). - /// The workflow result as a JSON element so POCOs serialize as nested objects rather than escaped strings. - /// An optional error message when the workflow has failed. - private sealed record WorkflowRunResponse( - [property: JsonPropertyName("runId")] string RunId, - [property: JsonPropertyName("workflowStatus")] string WorkflowStatus, - [property: JsonPropertyName("result")] JsonElement? Result, - [property: JsonPropertyName("error"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Error = null); - - /// - /// 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 deleted file mode 100644 index 96844d0d73d..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/CHANGELOG.md +++ /dev/null @@ -1,26 +0,0 @@ -# Release History - -## [Unreleased] - -- Scope workflow status/respond endpoints to the route workflow name ([#6608](https://github.com/microsoft/agent-framework/pull/6608)) -- Bind MCP threadId to the current agent and guard cross-agent session dispatch ([#6531](https://github.com/microsoft/agent-framework/pull/6531)) -- Support returning workflow results from HTTP trigger endpoint ([#5321](https://github.com/microsoft/agent-framework/pull/5321)) -- Added MCP tool trigger support for durable workflows ([#4768](https://github.com/microsoft/agent-framework/pull/4768)) -- Added Azure Functions hosting support for durable workflows ([#4436](https://github.com/microsoft/agent-framework/pull/4436)) - -## v1.0.0-preview.251219.1 - -- Addressed incompatibility issue with `Microsoft.Azure.Functions.Worker.Extensions.DurableTask` >= 1.11.0 ([#2759](https://github.com/microsoft/agent-framework/pull/2759)) - -## v1.0.0-preview.251125.1 - -- Added support for .NET 10 ([#2128](https://github.com/microsoft/agent-framework/pull/2128)) -- [BREAKING] Changed `thread_id` in HTTP APIs from entity ID to GUID ([#2260](https://github.com/microsoft/agent-framework/pull/2260)) - -## v1.0.0-preview.251114.1 - -- Added friendly error message when running durable agent that isn't registered ([#2214](https://github.com/microsoft/agent-framework/pull/2214)) - -## v1.0.0-preview.251112.1 - -- Initial public release ([#1916](https://github.com/microsoft/agent-framework/pull/1916)) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DefaultFunctionsAgentOptionsProvider.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DefaultFunctionsAgentOptionsProvider.cs deleted file mode 100644 index 4debb5facf2..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DefaultFunctionsAgentOptionsProvider.cs +++ /dev/null @@ -1,33 +0,0 @@ -// 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 when no explicit options have been configured for an agent, -/// which distinguishes standalone agents from those auto-registered by workflows. -/// -internal sealed class DefaultFunctionsAgentOptionsProvider(IReadOnlyDictionary functionsAgentOptions) - : IFunctionsAgentOptionsProvider -{ - private readonly IReadOnlyDictionary _functionsAgentOptions = - functionsAgentOptions ?? throw new ArgumentNullException(nameof(functionsAgentOptions)); - - /// - /// Attempts to retrieve the options associated with the specified agent name. - /// Returns when no options have been explicitly configured for the agent. - /// - /// The name of the agent whose options are to be retrieved. Cannot be null or empty. - /// - /// When this method returns , contains the options for the specified agent; - /// otherwise, . - /// - /// if options were found for the agent; otherwise, . - public bool TryGet(string agentName, [NotNullWhen(true)] out FunctionsAgentOptions? options) - { - ArgumentException.ThrowIfNullOrEmpty(agentName); - return this._functionsAgentOptions.TryGetValue(agentName, out options); - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableAgentFunctionMetadataTransformer.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableAgentFunctionMetadataTransformer.cs deleted file mode 100644 index fe20eeb6f96..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableAgentFunctionMetadataTransformer.cs +++ /dev/null @@ -1,87 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -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 explicitly configured agent. -/// -/// -/// This transformer adds entity, HTTP, and MCP tool trigger functions for agents that have -/// explicit . Agents auto-registered by workflows -/// (which lack explicit options) are handled by . -/// -internal sealed class DurableAgentFunctionMetadataTransformer : IFunctionMetadataTransformer -{ - private readonly ILogger _logger; - private readonly IReadOnlyDictionary> _agents; - private readonly IServiceProvider _serviceProvider; - private readonly IFunctionsAgentOptionsProvider _functionsAgentOptionsProvider; - - 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; - - // Only generate triggers for agents with explicit Functions agent options. - // Agents auto-registered by workflows are handled by DurableWorkflowsFunctionMetadataTransformer. - if (!this._functionsAgentOptionsProvider.TryGet(agentName, out FunctionsAgentOptions? agentTriggerOptions)) - { - continue; - } - - this._logger.LogRegisteringTriggerForAgent(agentName, "entity"); - original.Add(FunctionMetadataFactory.CreateEntityTrigger(agentName)); - - if (agentTriggerOptions.HttpTrigger.IsEnabled) - { - this._logger.LogRegisteringTriggerForAgent(agentName, "http"); - original.Add(FunctionMetadataFactory.CreateHttpTrigger(agentName, $"agents/{agentName}/run", BuiltInFunctions.RunAgentHttpFunctionEntryPoint)); - } - - if (agentTriggerOptions.McpToolTrigger.IsEnabled) - { - AIAgent agent = kvp.Value(this._serviceProvider); - this._logger.LogRegisteringTriggerForAgent(agentName, "mcpTool"); - original.Add(CreateMcpToolTrigger(agentName, agent.Description)); - } - } - } - - 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 = BuiltInFunctions.ScriptFile, - }; - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableAgentsOptionsExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableAgentsOptionsExtensions.cs deleted file mode 100644 index 8d161710ae2..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableAgentsOptionsExtensions.cs +++ /dev/null @@ -1,150 +0,0 @@ -// 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); - } - - /// - /// Ensures every agent in has an entry in the - /// options registry. Agents that already have explicit options are left untouched. - /// New entries receive the default configuration (HTTP trigger enabled, MCP tool disabled). - /// - internal static void EnsureDefaultOptionsForAll(IEnumerable agentNames) - { - foreach (string name in agentNames) - { - s_agentOptions.TryAdd(name, new FunctionsAgentOptions { HttpTrigger = { IsEnabled = true } }); - } - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableTaskClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableTaskClientExtensions.cs deleted file mode 100644 index 0977d756cb9..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableTaskClientExtensions.cs +++ /dev/null @@ -1,48 +0,0 @@ -// 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. - /// - /// Thrown when durable agents have not been configured on the service collection. - /// - /// - /// Thrown when the agent has not been registered. - /// - public static AIAgent AsDurableAgentProxy( - this DurableTaskClient durableClient, - FunctionContext context, - string agentName) - { - ArgumentNullException.ThrowIfNull(durableClient); - ArgumentNullException.ThrowIfNull(context); - ArgumentException.ThrowIfNullOrEmpty(agentName); - - // Validate that the agent is registered - DurableTask.ServiceCollectionExtensions.ValidateAgentIsRegistered(context.InstanceServices, agentName); - - DefaultDurableAgentClient agentClient = ActivatorUtilities.CreateInstance( - context.InstanceServices, - durableClient); - - return new DurableAIAgentProxy(agentName, agentClient); - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionMetadataFactory.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionMetadataFactory.cs deleted file mode 100644 index 46053507b14..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionMetadataFactory.cs +++ /dev/null @@ -1,163 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Text.Json.Nodes; -using Microsoft.Agents.AI.DurableTask; -using Microsoft.Azure.Functions.Worker.Core.FunctionMetadata; - -namespace Microsoft.Agents.AI.Hosting.AzureFunctions; - -/// -/// Provides factory methods for creating common instances -/// used by function metadata transformers. -/// -internal static class FunctionMetadataFactory -{ - /// - /// Creates function metadata for an entity trigger function. - /// - /// The base name used to derive the entity function name. - /// A configured for an entity trigger. - internal static DefaultFunctionMetadata CreateEntityTrigger(string name) - { - return new DefaultFunctionMetadata() - { - Name = AgentSessionId.ToEntityName(name), - Language = "dotnet-isolated", - RawBindings = - [ - """{"name":"encodedEntityRequest","type":"entityTrigger","direction":"In"}""", - """{"name":"client","type":"durableClient","direction":"In"}""" - ], - EntryPoint = BuiltInFunctions.RunAgentEntityFunctionEntryPoint, - ScriptFile = BuiltInFunctions.ScriptFile, - }; - } - - /// - /// Creates function metadata for an HTTP trigger function. - /// - /// The base name used to derive the HTTP function name. - /// The HTTP route for the trigger. - /// The entry point method for the HTTP trigger. - /// The allowed HTTP methods as a JSON array fragment (e.g., "\"get\""). Defaults to POST. - /// A configured for an HTTP trigger. - internal static DefaultFunctionMetadata CreateHttpTrigger(string name, string route, string entryPoint, string methods = "\"post\"") - { - return new DefaultFunctionMetadata() - { - Name = $"{BuiltInFunctions.HttpPrefix}{name}", - Language = "dotnet-isolated", - RawBindings = - [ - $"{{\"name\":\"req\",\"type\":\"httpTrigger\",\"direction\":\"In\",\"authLevel\":\"function\",\"methods\": [{methods}],\"route\":\"{route}\"}}", - "{\"name\":\"$return\",\"type\":\"http\",\"direction\":\"Out\"}", - "{\"name\":\"client\",\"type\":\"durableClient\",\"direction\":\"In\"}" - ], - EntryPoint = entryPoint, - ScriptFile = BuiltInFunctions.ScriptFile, - }; - } - - /// - /// Creates function metadata for an activity trigger function. - /// - /// The name of the activity function. - /// A configured for an activity trigger. - internal static DefaultFunctionMetadata CreateActivityTrigger(string functionName) - { - return new DefaultFunctionMetadata() - { - Name = functionName, - Language = "dotnet-isolated", - RawBindings = - [ - """{"name":"input","type":"activityTrigger","direction":"In","dataType":"String"}""", - """{"name":"durableTaskClient","type":"durableClient","direction":"In"}""" - ], - EntryPoint = BuiltInFunctions.InvokeWorkflowActivityFunctionEntryPoint, - ScriptFile = BuiltInFunctions.ScriptFile, - }; - } - - /// - /// Creates function metadata for an orchestration trigger function. - /// - /// The name of the orchestration function. - /// The entry point method for the orchestration trigger. - /// A configured for an orchestration trigger. - internal static DefaultFunctionMetadata CreateOrchestrationTrigger(string functionName, string entryPoint) - { - return new DefaultFunctionMetadata() - { - Name = functionName, - Language = "dotnet-isolated", - RawBindings = - [ - """{"name":"context","type":"orchestrationTrigger","direction":"In"}""" - ], - EntryPoint = entryPoint, - ScriptFile = BuiltInFunctions.ScriptFile, - }; - } - - /// - /// Creates function metadata for an MCP tool trigger function that starts a workflow. - /// - /// The name of the workflow to expose as an MCP tool. - /// An optional description for the MCP tool. If null, a default description is generated. - /// A configured for an MCP tool trigger. - internal static DefaultFunctionMetadata CreateWorkflowMcpToolTrigger( - string workflowName, - string? description) - { - var functionName = $"{BuiltInFunctions.McpToolPrefix}{workflowName}"; - var toolDescription = description ?? $"Run the {workflowName} workflow"; - - var toolProperties = new JsonArray(new JsonObject - { - ["propertyName"] = "input", - ["propertyType"] = "string", - ["description"] = "The input to the workflow.", - ["isRequired"] = true, - ["isArray"] = false, - }); - - var triggerBinding = new JsonObject - { - ["name"] = "context", - ["type"] = "mcpToolTrigger", - ["direction"] = "In", - ["toolName"] = workflowName, - ["description"] = toolDescription, - ["toolProperties"] = toolProperties.ToJsonString(), - }; - - var inputBinding = new JsonObject - { - ["name"] = "input", - ["type"] = "mcpToolProperty", - ["direction"] = "In", - ["propertyName"] = "input", - ["description"] = "The input to the workflow", - ["isRequired"] = true, - ["dataType"] = "String", - ["propertyType"] = "string", - }; - - var clientBinding = new JsonObject - { - ["name"] = "client", - ["type"] = "durableClient", - ["direction"] = "In", - }; - - return new DefaultFunctionMetadata - { - Name = functionName, - Language = "dotnet-isolated", - RawBindings = [triggerBinding.ToJsonString(), inputBinding.ToJsonString(), clientBinding.ToJsonString()], - EntryPoint = BuiltInFunctions.RunWorkflowMcpToolFunctionEntryPoint, - ScriptFile = BuiltInFunctions.ScriptFile, - }; - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsAgentOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsAgentOptions.cs deleted file mode 100644 index 6ead7d8be54..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsAgentOptions.cs +++ /dev/null @@ -1,19 +0,0 @@ -// 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 deleted file mode 100644 index 3c5e7936dac..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsApplicationBuilderExtensions.cs +++ /dev/null @@ -1,149 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Agents.AI.DurableTask; -using Microsoft.Agents.AI.DurableTask.Workflows; -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); - - // Create/get shared options BEFORE the DurableTask library call so it can find them. - FunctionsDurableOptions sharedOptions = GetOrCreateSharedOptions(builder.Services); - - // The main agent services registration is done in Microsoft.DurableTask.Agents. - builder.Services.ConfigureDurableAgents(configure); - - // Ensure all agents registered through this path have default FunctionsAgentOptions. - // This distinguishes them from agents auto-registered by workflows. - DurableAgentsOptionsExtensions.EnsureDefaultOptionsForAll(sharedOptions.Agents.GetAgentFactories().Keys); - - 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; - } - - /// - /// Configures durable options for the functions application, allowing customization of Durable Task framework - /// settings. - /// - /// This method ensures that a single shared instance is used across all - /// configuration calls. If any workflows have been added, it configures the necessary orchestrations and registers - /// required middleware. - /// The functions application builder to configure. Cannot be null. - /// An action that configures the instance. Cannot be null. - /// The updated instance, enabling method chaining. - public static FunctionsApplicationBuilder ConfigureDurableOptions( - this FunctionsApplicationBuilder builder, - Action configure) - { - ArgumentNullException.ThrowIfNull(builder); - ArgumentNullException.ThrowIfNull(configure); - - // Ensure FunctionsDurableOptions is registered BEFORE the core extension creates a plain DurableOptions - FunctionsDurableOptions sharedOptions = GetOrCreateSharedOptions(builder.Services); - - builder.Services.ConfigureDurableOptions(configure); - - if (DurableAgentsOptionsExtensions.GetAgentOptionsSnapshot().Count > 0) - { - builder.Services.TryAddSingleton(_ => - new DefaultFunctionsAgentOptionsProvider(DurableAgentsOptionsExtensions.GetAgentOptionsSnapshot())); - builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton()); - } - - if (sharedOptions.Workflows.Workflows.Count > 0) - { - builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton()); - } - - EnsureMiddlewareRegistered(builder); - - return builder; - } - - /// - /// Configures durable workflow support for the specified Azure Functions application builder. - /// - /// The instance to configure for durable workflows. - /// An action that configures the , allowing customization of durable workflow behavior. - /// The updated instance, enabling method chaining. - public static FunctionsApplicationBuilder ConfigureDurableWorkflows( - this FunctionsApplicationBuilder builder, - Action configure) - { - ArgumentNullException.ThrowIfNull(configure); - - return builder.ConfigureDurableOptions(options => configure(options.Workflows)); - } - - private static void EnsureMiddlewareRegistered(FunctionsApplicationBuilder builder) - { - // Guard against registering the middleware filter multiple times in the pipeline. - if (builder.Services.Any(d => d.ServiceType == typeof(BuiltInFunctionExecutor))) - { - return; - } - - builder.UseWhen(static context => - string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunAgentHttpFunctionEntryPoint, StringComparison.Ordinal) || - string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunAgentMcpToolFunctionEntryPoint, StringComparison.Ordinal) || - string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunAgentEntityFunctionEntryPoint, StringComparison.Ordinal) || - string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunWorkflowOrchestrationHttpFunctionEntryPoint, StringComparison.Ordinal) || - string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunWorkflowOrchestrationFunctionEntryPoint, StringComparison.Ordinal) || - string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.InvokeWorkflowActivityFunctionEntryPoint, StringComparison.Ordinal) || - string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.GetWorkflowStatusHttpFunctionEntryPoint, StringComparison.Ordinal) || - string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RespondToWorkflowHttpFunctionEntryPoint, StringComparison.Ordinal) || - string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunWorkflowMcpToolFunctionEntryPoint, StringComparison.Ordinal) - ); - builder.Services.TryAddSingleton(); - } - - /// - /// Gets or creates a shared instance from the service collection. - /// - private static FunctionsDurableOptions GetOrCreateSharedOptions(IServiceCollection services) - { - ServiceDescriptor? existingDescriptor = services.FirstOrDefault( - d => d.ServiceType == typeof(DurableOptions) && d.ImplementationInstance is not null); - - if (existingDescriptor?.ImplementationInstance is FunctionsDurableOptions existing) - { - return existing; - } - - FunctionsDurableOptions options = new(); - services.AddSingleton(options); - services.AddSingleton(options); - return options; - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsDurableOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsDurableOptions.cs deleted file mode 100644 index ee9051fa0d3..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsDurableOptions.cs +++ /dev/null @@ -1,46 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Agents.AI.DurableTask; - -namespace Microsoft.Agents.AI.Hosting.AzureFunctions; - -/// -/// Provides Azure Functions–specific configuration for durable workflows. -/// -internal sealed class FunctionsDurableOptions : DurableOptions -{ - private readonly HashSet _statusEndpointWorkflows = new(StringComparer.OrdinalIgnoreCase); - private readonly HashSet _mcpToolTriggerWorkflows = new(StringComparer.OrdinalIgnoreCase); - - /// - /// Enables the status HTTP endpoint for the specified workflow. - /// - internal void EnableStatusEndpoint(string workflowName) - { - this._statusEndpointWorkflows.Add(workflowName); - } - - /// - /// Returns whether the status endpoint is enabled for the specified workflow. - /// - internal bool IsStatusEndpointEnabled(string workflowName) - { - return this._statusEndpointWorkflows.Contains(workflowName); - } - - /// - /// Enables the MCP tool trigger for the specified workflow. - /// - internal void EnableMcpToolTrigger(string workflowName) - { - this._mcpToolTriggerWorkflows.Add(workflowName); - } - - /// - /// Returns whether the MCP tool trigger is enabled for the specified workflow. - /// - internal bool IsMcpToolTriggerEnabled(string workflowName) - { - return this._mcpToolTriggerWorkflows.Contains(workflowName); - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/HttpTriggerOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/HttpTriggerOptions.cs deleted file mode 100644 index 2a750c3ae57..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/HttpTriggerOptions.cs +++ /dev/null @@ -1,18 +0,0 @@ -// 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 deleted file mode 100644 index 347b4242a3b..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/IFunctionsAgentOptionsProvider.cs +++ /dev/null @@ -1,19 +0,0 @@ -// 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 deleted file mode 100644 index 73c31402666..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Logs.cs +++ /dev/null @@ -1,32 +0,0 @@ -// 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); - - [LoggerMessage( - EventId = 102, - Level = LogLevel.Information, - Message = "Registering {TriggerType} trigger function '{FunctionName}' for workflow '{WorkflowKey}'")] - public static partial void LogRegisteringWorkflowTrigger(this ILogger logger, string workflowKey, string functionName, string triggerType); - - [LoggerMessage( - EventId = 103, - Level = LogLevel.Information, - Message = "Function metadata transformation complete. Added {AddedCount} workflow function(s). Total function count: {TotalCount}")] - public static partial void LogTransformationComplete(this ILogger logger, int addedCount, int totalCount); -} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/McpToolTriggerOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/McpToolTriggerOptions.cs deleted file mode 100644 index 8e729f68406..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/McpToolTriggerOptions.cs +++ /dev/null @@ -1,18 +0,0 @@ -// 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 deleted file mode 100644 index ae63946d972..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Microsoft.Agents.AI.Hosting.AzureFunctions.csproj +++ /dev/null @@ -1,59 +0,0 @@ - - - - $(TargetFrameworksCore) - enable - - - $(NoWarn);CA2007;AD0001 - - - - - - - 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 deleted file mode 100644 index 3dc1a589434..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Middlewares/BuiltInFunctionExecutionMiddleware.cs +++ /dev/null @@ -1,24 +0,0 @@ -// 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 deleted file mode 100644 index 6cacc5adff9..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/README.md +++ /dev/null @@ -1,177 +0,0 @@ -# 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) - .AsAIAgent( - instructions: "You are a spam detection assistant that identifies spam emails.", - name: "SpamDetectionAgent"); - -AIAgent emailAssistant = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()) - .GetChatClient(deploymentName) - .AsAIAgent( - 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"); - AgentSession spamSession = await spamDetectionAgent.CreateSessionAsync(); - - // Step 1: Check if the email is spam - AgentResponse 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} - """, - session: spamSession); - 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"); - AgentSession emailSession = await emailAssistantAgent.CreateSessionAsync(); - - AgentResponse 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} - """, - session: emailSession); - - 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) - .AsAIAgent( - 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.Hosting.AzureFunctions/Workflows/DurableWorkflowOptionsExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/DurableWorkflowOptionsExtensions.cs deleted file mode 100644 index 7cf38397ae5..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/DurableWorkflowOptionsExtensions.cs +++ /dev/null @@ -1,57 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Agents.AI.DurableTask.Workflows; -using Microsoft.Agents.AI.Workflows; - -namespace Microsoft.Agents.AI.Hosting.AzureFunctions; - -/// -/// Extension methods for to configure Azure Functions HTTP trigger options. -/// -public static class DurableWorkflowOptionsExtensions -{ - /// - /// Adds a workflow and optionally exposes a status HTTP endpoint for querying pending HITL requests. - /// - /// The workflow options to add the workflow to. - /// The workflow instance to add. - /// If , a GET endpoint is generated at workflows/{name}/status/{runId}. - public static void AddWorkflow(this DurableWorkflowOptions options, Workflow workflow, bool exposeStatusEndpoint) - { - ArgumentNullException.ThrowIfNull(options); - - options.AddWorkflow(workflow); - - if (exposeStatusEndpoint && options.ParentOptions is FunctionsDurableOptions functionsOptions) - { - functionsOptions.EnableStatusEndpoint(workflow.Name!); - } - } - - /// - /// Adds a workflow and configures whether to expose a status HTTP endpoint and/or an MCP tool trigger. - /// - /// The workflow options to add the workflow to. - /// The workflow instance to add. - /// If , a GET endpoint is generated at workflows/{name}/status/{runId}. - /// If , an MCP tool trigger is generated for the workflow. - public static void AddWorkflow(this DurableWorkflowOptions options, Workflow workflow, bool exposeStatusEndpoint, bool exposeMcpToolTrigger) - { - ArgumentNullException.ThrowIfNull(options); - - options.AddWorkflow(workflow); - - if (options.ParentOptions is FunctionsDurableOptions functionsOptions) - { - if (exposeStatusEndpoint) - { - functionsOptions.EnableStatusEndpoint(workflow.Name!); - } - - if (exposeMcpToolTrigger) - { - functionsOptions.EnableMcpToolTrigger(workflow.Name!); - } - } - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/DurableWorkflowsFunctionMetadataTransformer.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/DurableWorkflowsFunctionMetadataTransformer.cs deleted file mode 100644 index 202c54519ec..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/DurableWorkflowsFunctionMetadataTransformer.cs +++ /dev/null @@ -1,166 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Agents.AI.DurableTask; -using Microsoft.Agents.AI.DurableTask.Workflows; -using Microsoft.Agents.AI.Workflows; -using Microsoft.Azure.Functions.Worker.Core.FunctionMetadata; -using Microsoft.Extensions.Logging; - -namespace Microsoft.Agents.AI.Hosting.AzureFunctions; - -/// -/// Transforms function metadata by dynamically registering Azure Functions triggers -/// for each configured durable workflow and its executors. -/// -/// -/// For each workflow, this transformer registers: -/// -/// An HTTP trigger function to start the workflow orchestration via HTTP. -/// An orchestration trigger function to run the workflow orchestration. -/// An activity trigger function for each non-agent executor in the workflow. -/// An entity trigger function for each AI agent executor in the workflow. -/// -/// When multiple workflows share the same executor, the corresponding function is registered only once. -/// -internal sealed class DurableWorkflowsFunctionMetadataTransformer : IFunctionMetadataTransformer -{ - private readonly ILogger _logger; - private readonly FunctionsDurableOptions _options; - - /// - /// Initializes a new instance of the class. - /// - /// The logger instance for diagnostic output. - /// The durable options containing workflow configurations. - public DurableWorkflowsFunctionMetadataTransformer( - ILogger logger, - FunctionsDurableOptions durableOptions) - { - this._logger = logger ?? throw new ArgumentNullException(nameof(logger)); - ArgumentNullException.ThrowIfNull(durableOptions); - this._options = durableOptions; - } - - /// - public string Name => nameof(DurableWorkflowsFunctionMetadataTransformer); - - /// - public void Transform(IList original) - { - int initialCount = original.Count; - this._logger.LogTransformingFunctionMetadata(initialCount); - - // Seed with existing function names to avoid duplicates across transformers - // (e.g., when DurableAgentFunctionMetadataTransformer already registered entity triggers). - HashSet registeredFunctions = new( - original.Select(f => f.Name!), - StringComparer.OrdinalIgnoreCase); - - DurableWorkflowOptions workflowOptions = this._options.Workflows; - foreach (var workflow in workflowOptions.Workflows) - { - string httpFunctionName = $"{BuiltInFunctions.HttpPrefix}{workflow.Key}"; - - if (this._logger.IsEnabled(LogLevel.Information)) - { - this._logger.LogInformation("Registering durable workflow functions for workflow '{WorkflowKey}' with HTTP trigger function name '{HttpFunctionName}'", workflow.Key, httpFunctionName); - } - - // Register an orchestration function for the workflow. - string orchestrationFunctionName = WorkflowNamingHelper.ToOrchestrationFunctionName(workflow.Key); - if (registeredFunctions.Add(orchestrationFunctionName)) - { - this._logger.LogRegisteringWorkflowTrigger(workflow.Key, orchestrationFunctionName, "orchestration"); - original.Add(FunctionMetadataFactory.CreateOrchestrationTrigger( - orchestrationFunctionName, - BuiltInFunctions.RunWorkflowOrchestrationFunctionEntryPoint)); - } - - // Register an HTTP trigger so users can start this workflow via HTTP. - if (registeredFunctions.Add(httpFunctionName)) - { - this._logger.LogRegisteringWorkflowTrigger(workflow.Key, httpFunctionName, "http"); - original.Add(FunctionMetadataFactory.CreateHttpTrigger( - workflow.Key, - $"workflows/{workflow.Key}/run", - BuiltInFunctions.RunWorkflowOrchestrationHttpFunctionEntryPoint)); - } - - // Register a status endpoint if opted in via AddWorkflow(exposeStatusEndpoint: true). - if (this._options.IsStatusEndpointEnabled(workflow.Key)) - { - string statusFunctionName = $"{BuiltInFunctions.HttpPrefix}{workflow.Key}{BuiltInFunctions.StatusFunctionSuffix}"; - if (registeredFunctions.Add(statusFunctionName)) - { - this._logger.LogRegisteringWorkflowTrigger(workflow.Key, statusFunctionName, "http-status"); - original.Add(FunctionMetadataFactory.CreateHttpTrigger( - $"{workflow.Key}-status", - $"workflows/{workflow.Key}/status/{{runId}}", - BuiltInFunctions.GetWorkflowStatusHttpFunctionEntryPoint, - methods: "\"get\"")); - } - } - - // Register a respond endpoint when the workflow contains RequestPort nodes. - bool hasRequestPorts = workflow.Value.ReflectExecutors().Values.Any(b => b is RequestPortBinding); - if (hasRequestPorts) - { - string respondFunctionName = $"{BuiltInFunctions.HttpPrefix}{workflow.Key}{BuiltInFunctions.RespondFunctionSuffix}"; - if (registeredFunctions.Add(respondFunctionName)) - { - this._logger.LogRegisteringWorkflowTrigger(workflow.Key, respondFunctionName, "http-respond"); - original.Add(FunctionMetadataFactory.CreateHttpTrigger( - $"{workflow.Key}-respond", - $"workflows/{workflow.Key}/respond/{{runId}}", - BuiltInFunctions.RespondToWorkflowHttpFunctionEntryPoint)); - } - } - - // Register an MCP tool trigger if opted in via AddWorkflow(exposeMcpToolTrigger: true). - if (this._options.IsMcpToolTriggerEnabled(workflow.Key)) - { - string mcpToolFunctionName = $"{BuiltInFunctions.McpToolPrefix}{workflow.Key}"; - if (registeredFunctions.Add(mcpToolFunctionName)) - { - this._logger.LogRegisteringWorkflowTrigger(workflow.Key, mcpToolFunctionName, "mcpTool"); - original.Add(FunctionMetadataFactory.CreateWorkflowMcpToolTrigger(workflow.Key, workflow.Value.Description)); - } - } - - // Register activity or entity functions for each executor in the workflow. - // ReflectExecutors() returns all executors across the graph; no need to manually traverse edges. - foreach (KeyValuePair entry in workflow.Value.ReflectExecutors()) - { - // Sub-workflow and RequestPort bindings use specialized dispatch, not activities. - if (entry.Value is SubworkflowBinding or RequestPortBinding) - { - continue; - } - - string executorName = WorkflowNamingHelper.GetExecutorName(entry.Key); - - // AI agent executors are backed by durable entities; other executors use activity triggers. - if (entry.Value is AIAgentBinding) - { - string entityName = AgentSessionId.ToEntityName(executorName); - if (registeredFunctions.Add(entityName)) - { - this._logger.LogRegisteringWorkflowTrigger(workflow.Key, entityName, "entity"); - original.Add(FunctionMetadataFactory.CreateEntityTrigger(executorName)); - } - } - else - { - string functionName = WorkflowNamingHelper.ToOrchestrationFunctionName(executorName); - if (registeredFunctions.Add(functionName)) - { - this._logger.LogRegisteringWorkflowTrigger(workflow.Key, functionName, "activity"); - original.Add(FunctionMetadataFactory.CreateActivityTrigger(functionName)); - } - } - } - } - - this._logger.LogTransformationComplete(original.Count - initialCount, original.Count); - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/WorkflowOrchestrator.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/WorkflowOrchestrator.cs deleted file mode 100644 index f89abedc23f..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/WorkflowOrchestrator.cs +++ /dev/null @@ -1,51 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Agents.AI.DurableTask.Workflows; -using Microsoft.DurableTask; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace Microsoft.Agents.AI.Hosting.AzureFunctions; - -/// -/// A custom implementation that delegates workflow orchestration -/// execution to the . -/// -internal sealed class WorkflowOrchestrator : ITaskOrchestrator -{ - private readonly IServiceProvider _serviceProvider; - - /// - /// Initializes a new instance of the class. - /// - /// The service provider used to resolve workflow dependencies. - public WorkflowOrchestrator(IServiceProvider serviceProvider) - { - this._serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); - } - - /// - public Type InputType => typeof(DurableWorkflowInput); - - /// - public Type OutputType => typeof(DurableWorkflowResult); - - /// - public async Task RunAsync(TaskOrchestrationContext context, object? input) - { - ArgumentNullException.ThrowIfNull(context); - - DurableWorkflowRunner runner = this._serviceProvider.GetRequiredService(); - ILogger logger = context.CreateReplaySafeLogger(context.Name); - - DurableWorkflowInput workflowInput = input switch - { - DurableWorkflowInput existing => existing, - _ => new DurableWorkflowInput { Input = input! } - }; - - // ConfigureAwait(true) is required to preserve the orchestration context - // across awaits, which the Durable Task framework uses for replay. - return await runner.RunWorkflowOrchestrationAsync(context, workflowInput, logger).ConfigureAwait(true); - } -} diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/AgentEntityTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/AgentEntityTests.cs deleted file mode 100644 index 02a75d839c2..00000000000 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/AgentEntityTests.cs +++ /dev/null @@ -1,199 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Diagnostics; -using System.Reflection; -using Microsoft.Agents.AI.DurableTask.State; -using Microsoft.DurableTask; -using Microsoft.DurableTask.Client; -using Microsoft.DurableTask.Client.Entities; -using Microsoft.DurableTask.Entities; -using Microsoft.Extensions.Configuration; -using OpenAI.Chat; - -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 const string DisabledDueToFailingCiJob = "Disabled due to persistent CI failures. See #6732."; - - 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(Skip = DisabledDueToFailingCiJob)] - public async Task EntityNamePrefixAsync() - { - // Setup - AIAgent simpleAgent = TestHelper.GetAzureOpenAIChatClient(s_configuration).AsAIAgent( - 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); - - AgentSession session = await simpleAgentProxy.CreateSessionAsync(this.TestTimeoutToken); - - DurableTaskClient client = testHelper.GetClient(); - - AgentSessionId sessionId = session.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!", - session, - cancellationToken: this.TestTimeoutToken); - - // Assert: verify the agent state was stored with the correct entity name prefix - entity = await client.Entities.GetEntityAsync(expectedEntityId, true, this.TestTimeoutToken); - - Assert.NotNull(entity); - Assert.True(entity.IncludesState); - - DurableAgentState state = entity.State.ReadAs(); - - DurableAgentStateRequest request = Assert.Single(state.Data.ConversationHistory.OfType()); - - Assert.Null(request.OrchestrationId); - } - - [Theory(Skip = DisabledDueToFailingCiJob)] - [InlineData("run")] - [InlineData("Run")] - [InlineData("RunAgentAsync")] - public async Task RunAgentMethodNamesAllWorkAsync(string runAgentMethodName) - { - // Setup - AIAgent simpleAgent = TestHelper.GetAzureOpenAIChatClient(s_configuration).AsAIAgent( - 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); - - AgentSession session = await simpleAgentProxy.CreateSessionAsync(this.TestTimeoutToken); - - DurableTaskClient client = testHelper.GetClient(); - - AgentSessionId sessionId = session.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 client.Entities.SignalEntityAsync( - expectedEntityId, - runAgentMethodName, - new RunRequest("Hello!"), - cancellation: this.TestTimeoutToken); - - while (!this.TestTimeoutToken.IsCancellationRequested) - { - await Task.Delay(500, this.TestTimeoutToken); - - // Assert: verify the agent state was stored with the correct entity name prefix - entity = await client.Entities.GetEntityAsync(expectedEntityId, true, this.TestTimeoutToken); - - if (entity is not null) - { - break; - } - } - - Assert.NotNull(entity); - Assert.True(entity.IncludesState); - - DurableAgentState state = entity.State.ReadAs(); - - DurableAgentStateRequest request = Assert.Single(state.Data.ConversationHistory.OfType()); - - Assert.Null(request.OrchestrationId); - } - - [Fact(Skip = DisabledDueToFailingCiJob)] - public async Task OrchestrationIdSetDuringOrchestrationAsync() - { - // Arrange - AIAgent simpleAgent = TestHelper.GetAzureOpenAIChatClient(s_configuration).AsAIAgent( - name: "TestAgent", - instructions: "You are a helpful assistant that always responds with a friendly greeting." - ); - - using TestHelper testHelper = TestHelper.Start( - [simpleAgent], - this._outputHelper, - registry => registry.AddOrchestrator()); - - DurableTaskClient client = testHelper.GetClient(); - - // Act - string orchestrationId = await client.ScheduleNewOrchestrationInstanceAsync(nameof(TestOrchestrator), "What is the capital of Maine?"); - - OrchestrationMetadata? status = await client.WaitForInstanceCompletionAsync( - orchestrationId, - true, - this.TestTimeoutToken); - - // Assert - EntityInstanceId expectedEntityId = AgentSessionId.Parse(status.ReadOutputAs()!); - - EntityMetadata? entity = await client.Entities.GetEntityAsync(expectedEntityId, true, this.TestTimeoutToken); - - Assert.NotNull(entity); - Assert.True(entity.IncludesState); - - DurableAgentState state = entity.State.ReadAs(); - - DurableAgentStateRequest request = Assert.Single(state.Data.ConversationHistory.OfType()); - - Assert.Equal(orchestrationId, request.OrchestrationId); - } - - [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Constructed via reflection.")] - private sealed class TestOrchestrator : TaskOrchestrator - { - public override async Task RunAsync(TaskOrchestrationContext context, string input) - { - DurableAIAgent writer = context.GetAgent("TestAgent"); - AgentSession writerSession = await writer.CreateSessionAsync(); - - await writer.RunAsync( - message: context.GetInput()!, - session: writerSession); - - AgentSessionId sessionId = writerSession.GetService(); - - return sessionId.ToString(); - } - } -} diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/ConsoleAppSamplesValidation.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/ConsoleAppSamplesValidation.cs deleted file mode 100644 index 9d68ef6c214..00000000000 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/ConsoleAppSamplesValidation.cs +++ /dev/null @@ -1,572 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Concurrent; -using System.Diagnostics; -using System.Text; -namespace Microsoft.Agents.AI.DurableTask.IntegrationTests; - -/// -/// Integration tests for validating the durable agent console app samples -/// located in samples/Durable/Agents/ConsoleApps. -/// -[Collection("Samples")] -[Trait("Category", "SampleValidation")] -public sealed class ConsoleAppSamplesValidation(ITestOutputHelper outputHelper) : SamplesValidationBase(outputHelper) -{ - private static readonly string s_samplesPath = Path.GetFullPath( - Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "..", "..", "..", "..", "..", "samples", "04-hosting", "DurableAgents", "ConsoleApps")); - - /// - protected override string SamplesPath => s_samplesPath; - - /// - protected override bool RequiresRedis => true; - - /// - protected override void ConfigureAdditionalEnvironmentVariables(ProcessStartInfo startInfo, Action setEnvVar) - { - setEnvVar("REDIS_CONNECTION_STRING", $"localhost:{RedisPort}"); - } - - [Fact(Skip = "Disabled due to persistent CI failures. See #6732.")] - public async Task SingleAgentSampleValidationAsync() - { - using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts(); - string samplePath = Path.Combine(s_samplesPath, "01_SingleAgent"); - await this.RunSampleTestAsync(samplePath, async (process, logs) => - { - string agentResponse = string.Empty; - bool inputSent = false; - - // Read output from logs queue - string? line; - while ((line = this.ReadLogLine(logs, testTimeoutCts.Token)) != null) - { - // Look for the agent's response. Unlike the interactive mode, we won't actually see a line - // that starts with "Joker: ". Instead, we'll see a line that looks like "You: Joker: ..." because - // the standard input is *not* echoed back to standard output. - if (line.Contains("Joker: ", StringComparison.OrdinalIgnoreCase)) - { - // This will give us the first line of the agent's response, which is all we need to verify that the agent is working. - agentResponse = line.Substring("Joker: ".Length).Trim(); - break; - } - else if (!inputSent) - { - // Send input to stdin after we've started seeing output from the app - await this.WriteInputAsync(process, "Tell me a joke about a pirate.", testTimeoutCts.Token); - inputSent = true; - } - } - - Assert.True(inputSent, "Input was not sent to the agent"); - Assert.NotEmpty(agentResponse); - - // Send exit command - await this.WriteInputAsync(process, "exit", testTimeoutCts.Token); - }); - } - - [RetryFact(2, 5000, Skip = "Disabled due to persistent CI failures. See #6732.")] - public async Task SingleAgentOrchestrationChainingSampleValidationAsync() - { - using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts(); - string samplePath = Path.Combine(s_samplesPath, "02_AgentOrchestration_Chaining"); - await this.RunSampleTestAsync(samplePath, async (process, logs) => - { - // Console app runs automatically, just wait for completion - string? line; - bool foundSuccess = false; - - while ((line = this.ReadLogLine(logs, testTimeoutCts.Token)) != null) - { - if (line.Contains("Orchestration completed successfully!", StringComparison.OrdinalIgnoreCase)) - { - foundSuccess = true; - } - - if (line.Contains("Result:", StringComparison.OrdinalIgnoreCase)) - { - string result = line.Substring("Result:".Length).Trim(); - Assert.NotEmpty(result); - break; - } - - // Check for failure - if (line.Contains("Orchestration failed!", StringComparison.OrdinalIgnoreCase)) - { - Assert.Fail("Orchestration failed."); - } - } - - Assert.True(foundSuccess, "Orchestration did not complete successfully."); - }); - } - - [RetryFact(2, 5000, Skip = "Disabled due to persistent CI failures. See #6732.")] - public async Task MultiAgentConcurrencySampleValidationAsync() - { - using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts(); - string samplePath = Path.Combine(s_samplesPath, "03_AgentOrchestration_Concurrency"); - await this.RunSampleTestAsync(samplePath, async (process, logs) => - { - // Send input to stdin - await this.WriteInputAsync(process, "What is temperature?", testTimeoutCts.Token); - - // Read output from logs queue - StringBuilder output = new(); - string? line; - bool foundSuccess = false; - bool foundPhysicist = false; - bool foundChemist = false; - - while ((line = this.ReadLogLine(logs, testTimeoutCts.Token)) != null) - { - output.AppendLine(line); - - if (line.Contains("Orchestration completed successfully!", StringComparison.OrdinalIgnoreCase)) - { - foundSuccess = true; - } - - if (line.Contains("Physicist's response:", StringComparison.OrdinalIgnoreCase)) - { - foundPhysicist = true; - } - - if (line.Contains("Chemist's response:", StringComparison.OrdinalIgnoreCase)) - { - foundChemist = true; - } - - // Check for failure - if (line.Contains("Orchestration failed!", StringComparison.OrdinalIgnoreCase)) - { - Assert.Fail("Orchestration failed."); - } - - // Stop reading once we have both responses - if (foundSuccess && foundPhysicist && foundChemist) - { - break; - } - } - - Assert.True(foundSuccess, "Orchestration did not complete successfully."); - Assert.True(foundPhysicist, "Physicist response not found."); - Assert.True(foundChemist, "Chemist response not found."); - }); - } - - [RetryFact(2, 5000, Skip = "Disabled due to persistent CI failures. See #6732.")] - public async Task MultiAgentConditionalSampleValidationAsync() - { - using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts(); - string samplePath = Path.Combine(s_samplesPath, "04_AgentOrchestration_Conditionals"); - await this.RunSampleTestAsync(samplePath, async (process, logs) => - { - // Test with legitimate email - await this.TestSpamDetectionAsync( - process: process, - logs: logs, - emailId: "email-001", - emailContent: "Hi John. 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, - testTimeoutCts.Token); - - // Restart the process for the second test - await process.WaitForExitAsync(); - }); - - // Run second test with spam email - using CancellationTokenSource testTimeoutCts2 = this.CreateTestTimeoutCts(); - await this.RunSampleTestAsync(samplePath, async (process, logs) => - { - await this.TestSpamDetectionAsync( - process, - logs, - emailId: "email-002", - emailContent: "URGENT! You've won $1,000,000! Click here now to claim your prize! Limited time offer! Don't miss out!", - expectedSpam: true, - testTimeoutCts2.Token); - }); - } - - private async Task TestSpamDetectionAsync( - Process process, - BlockingCollection logs, - string emailId, - string emailContent, - bool expectedSpam, - CancellationToken cancellationToken) - { - // Send email content to stdin - await this.WriteInputAsync(process, emailContent, cancellationToken); - - // Read output from logs queue - string? line; - bool foundSuccess = false; - - while ((line = this.ReadLogLine(logs, cancellationToken)) != null) - { - if (line.Contains("Email sent", StringComparison.OrdinalIgnoreCase)) - { - Assert.False(expectedSpam, "Email was sent, but was expected to be marked as spam."); - } - - if (line.Contains("Email marked as spam", StringComparison.OrdinalIgnoreCase)) - { - Assert.True(expectedSpam, "Email was marked as spam, but was expected to be sent."); - } - - if (line.Contains("Orchestration completed successfully!", StringComparison.OrdinalIgnoreCase)) - { - foundSuccess = true; - break; - } - - // Check for failure - if (line.Contains("Orchestration failed!", StringComparison.OrdinalIgnoreCase)) - { - Assert.Fail("Orchestration failed."); - } - } - - Assert.True(foundSuccess, "Orchestration did not complete successfully."); - } - - [RetryFact(2, 5000, Skip = "Disabled due to persistent CI failures.")] - public async Task SingleAgentOrchestrationHITLSampleValidationAsync() - { - string samplePath = Path.Combine(s_samplesPath, "05_AgentOrchestration_HITL"); - - await this.RunSampleTestAsync(samplePath, async (process, logs) => - { - using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts(TimeSpan.FromSeconds(180)); - - // Start the HITL orchestration following the happy path from README - await this.WriteInputAsync(process, "The Future of Artificial Intelligence", testTimeoutCts.Token); - await this.WriteInputAsync(process, "3", testTimeoutCts.Token); - await this.WriteInputAsync(process, "72", testTimeoutCts.Token); - - // Read output from logs queue - string? line; - bool rejectionSent = false; - bool approvalSent = false; - bool contentPublished = false; - - while ((line = this.ReadLogLine(logs, testTimeoutCts.Token)) != null) - { - // Look for notification that content is ready. The first time we see this, we should send a rejection. - // Subsequent times we see this, we should send approval (LLM may produce extra review cycles). - if (line.Contains("Content is ready for review", StringComparison.OrdinalIgnoreCase)) - { - if (!rejectionSent) - { - // Prompt: Approve? (y/n): - await this.WriteInputAsync(process, "n", testTimeoutCts.Token); - - // Prompt: Feedback (optional): - await this.WriteInputAsync( - process, - "The article needs more technical depth and better examples. Rewrite it with less than 300 words.", - testTimeoutCts.Token); - rejectionSent = true; - } - else - { - // Approve any subsequent draft (LLM non-determinism may produce extra review cycles) - await this.WriteInputAsync(process, "y", testTimeoutCts.Token); - - // Prompt: Feedback (optional): - await this.WriteInputAsync(process, "Looks good!", testTimeoutCts.Token); - approvalSent = true; - } - } - - // Look for success message - if (line.Contains("PUBLISHING: Content has been published", StringComparison.OrdinalIgnoreCase)) - { - contentPublished = true; - break; - } - - // Check for failure - if (line.Contains("Orchestration failed", StringComparison.OrdinalIgnoreCase)) - { - Assert.Fail("Orchestration failed."); - } - } - - Assert.True(rejectionSent, "Wasn't prompted with the first draft."); - Assert.True(approvalSent, "Wasn't prompted with the second draft."); - Assert.True(contentPublished, "Content was not published."); - }); - } - - [RetryFact(2, 5000, Skip = "Disabled due to persistent CI failures.")] - public async Task LongRunningToolsSampleValidationAsync() - { - string samplePath = Path.Combine(s_samplesPath, "06_LongRunningTools"); - await this.RunSampleTestAsync(samplePath, async (process, logs) => - { - // This test takes a bit longer to run due to the multiple agent interactions and the lengthy content generation. - using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts(TimeSpan.FromSeconds(180)); - - // Test starting an agent that schedules a content generation orchestration - await this.WriteInputAsync( - process, - "Start a content generation workflow for the topic 'The Future of Artificial Intelligence'. Keep it less than 300 words.", - testTimeoutCts.Token); - - // Read output from logs queue - bool rejectionSent = false; - bool approvalSent = false; - bool contentPublished = false; - - string? line; - while ((line = this.ReadLogLine(logs, testTimeoutCts.Token)) != null) - { - // Look for notification that content is ready. The first time we see this, we should send a rejection. - // Subsequent times we see this, we should send approval (LLM may produce extra review cycles). - if (line.Contains("NOTIFICATION: Please review the following content for approval", StringComparison.OrdinalIgnoreCase)) - { - // Wait for the notification to be fully written to the console - await Task.Delay(TimeSpan.FromSeconds(1), testTimeoutCts.Token); - - if (!rejectionSent) - { - // Reject the content with feedback. Note that we need to send a newline character to the console first before sending the input. - await this.WriteInputAsync( - process, - "\nReject the content with feedback: Make it even shorter.", - testTimeoutCts.Token); - rejectionSent = true; - } - else - { - // Approve any subsequent draft (LLM non-determinism may produce extra review cycles) - await this.WriteInputAsync( - process, - "\nApprove the content", - testTimeoutCts.Token); - approvalSent = true; - } - } - - // Look for success message - if (line.Contains("PUBLISHING: Content has been published successfully", StringComparison.OrdinalIgnoreCase)) - { - contentPublished = true; - - // Ask for the status of the workflow to confirm that it completed successfully. - await Task.Delay(TimeSpan.FromSeconds(1), testTimeoutCts.Token); - await this.WriteInputAsync(process, "\nGet the status of the workflow you previously started", testTimeoutCts.Token); - } - - // Check for workflow completion or failure - if (contentPublished) - { - if (line.Contains("Completed", StringComparison.OrdinalIgnoreCase)) - { - break; - } - else if (line.Contains("Failed", StringComparison.OrdinalIgnoreCase)) - { - Assert.Fail("Workflow failed."); - } - } - } - - Assert.True(rejectionSent, "Wasn't prompted with the first draft."); - Assert.True(approvalSent, "Wasn't prompted with the second draft."); - Assert.True(contentPublished, "Content was not published."); - }); - } - - [RetryFact(2, 5000, Skip = "Disabled due to persistent CI failures.")] - public async Task ReliableStreamingSampleValidationAsync() - { - string samplePath = Path.Combine(s_samplesPath, "07_ReliableStreaming"); - await this.RunSampleTestAsync(samplePath, async (process, logs) => - { - // This test takes a bit longer to run due to the multiple agent interactions and the lengthy content generation. - using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts(TimeSpan.FromSeconds(150)); - - // Test the agent endpoint with a simple prompt - await this.WriteInputAsync(process, "Plan a 5-day trip to Seattle. Include daily activities.", testTimeoutCts.Token); - - // Read output from stdout - should stream in real-time - // NOTE: The sample uses Console.Write() for streaming chunks, which means content may not be line-buffered. - // We test the interrupt/resume flow by: - // 1. Waiting for at least 10 lines of content - // 2. Sending Enter to interrupt - // 3. Verifying we get "Last cursor" output - // 4. Sending Enter again to resume - // 5. Verifying we get more content and that we're not restarting from the beginning - string? line; - bool foundConversationStart = false; - int contentLinesBeforeInterrupt = 0; - int contentLinesAfterResume = 0; - bool foundLastCursor = false; - bool foundResumeMessage = false; - bool interrupted = false; - bool resumed = false; - - // Read output with a reasonable timeout - using CancellationTokenSource readTimeoutCts = this.CreateTestTimeoutCts(); - DateTime? interruptTime = null; - try - { - while ((line = this.ReadLogLine(logs, readTimeoutCts.Token)) != null) - { - // Look for the conversation start message (updated format) - if (line.Contains("Conversation ID", StringComparison.OrdinalIgnoreCase)) - { - foundConversationStart = true; - continue; - } - - // Check if this is a content line (not prompts or status messages) - bool isContentLine = !string.IsNullOrWhiteSpace(line) && - !line.Contains("Conversation ID", StringComparison.OrdinalIgnoreCase) && - !line.Contains("Press [Enter]", StringComparison.OrdinalIgnoreCase) && - !line.Contains("You:", StringComparison.OrdinalIgnoreCase) && - !line.Contains("exit", StringComparison.OrdinalIgnoreCase) && - !line.Contains("Stream cancelled", StringComparison.OrdinalIgnoreCase) && - !line.Contains("Resuming conversation", StringComparison.OrdinalIgnoreCase) && - !line.Contains("Last cursor", StringComparison.OrdinalIgnoreCase); - - // Phase 1: Collect content before interrupt - if (foundConversationStart && !interrupted && isContentLine) - { - contentLinesBeforeInterrupt++; - } - - // Phase 2: Wait for enough content, then interrupt - // Interrupt after 2 lines to maximize chance of catching stream while active - // (streams can complete very quickly, so we need to interrupt early) - if (foundConversationStart && !interrupted && contentLinesBeforeInterrupt >= 2) - { - this.OutputHelper.WriteLine($"Interrupting stream after {contentLinesBeforeInterrupt} content lines"); - interrupted = true; - interruptTime = DateTime.Now; - - // Send Enter to interrupt the stream - await this.WriteInputAsync(process, string.Empty, testTimeoutCts.Token); - - // Give the cancellation token a moment to be processed - // Use a longer delay to ensure cancellation propagates - await Task.Delay(TimeSpan.FromMilliseconds(300), testTimeoutCts.Token); - } - - // Phase 3: Look for "Last cursor" message after interrupt - if (interrupted && !resumed && line.Contains("Last cursor", StringComparison.OrdinalIgnoreCase)) - { - foundLastCursor = true; - - // Send Enter again to resume - this.OutputHelper.WriteLine("Resuming stream from last cursor"); - await this.WriteInputAsync(process, string.Empty, testTimeoutCts.Token); - resumed = true; - } - - // Phase 4: Look for resume message - if (resumed && line.Contains("Resuming conversation", StringComparison.OrdinalIgnoreCase)) - { - foundResumeMessage = true; - } - - // Phase 5: Collect content after resume - if (resumed && isContentLine) - { - contentLinesAfterResume++; - } - - // Look for completion message - but don't break if we interrupted and haven't found Last cursor yet - // Allow some time after interrupt for the cancellation message to appear - if (line.Contains("Conversation completed", StringComparison.OrdinalIgnoreCase)) - { - // If we interrupted but haven't found Last cursor, wait a bit more - if (interrupted && !foundLastCursor && interruptTime.HasValue) - { - TimeSpan timeSinceInterrupt = DateTime.Now - interruptTime.Value; - if (timeSinceInterrupt < TimeSpan.FromSeconds(2)) - { - // Continue reading for a bit more to catch the cancellation message - this.OutputHelper.WriteLine("Stream completed naturally, but waiting for Last cursor message after interrupt..."); - continue; - } - } - - // Only break if we've completed the test or if stream completed without interruption - if (!interrupted || (resumed && foundResumeMessage && contentLinesAfterResume >= 5)) - { - break; - } - } - - // Stop once we've verified the interrupt/resume flow works - if (resumed && foundResumeMessage && contentLinesAfterResume >= 5) - { - this.OutputHelper.WriteLine($"Successfully verified interrupt/resume: {contentLinesBeforeInterrupt} lines before, {contentLinesAfterResume} lines after"); - break; - } - } - - // If we interrupted but didn't find Last cursor, wait a bit more for it to appear - if (interrupted && !foundLastCursor && interruptTime.HasValue) - { - TimeSpan timeSinceInterrupt = DateTime.Now - interruptTime.Value; - if (timeSinceInterrupt < TimeSpan.FromSeconds(3)) - { - this.OutputHelper.WriteLine("Waiting for Last cursor message after interrupt..."); - using CancellationTokenSource waitCts = new(TimeSpan.FromSeconds(2)); - try - { - while ((line = this.ReadLogLine(logs, waitCts.Token)) != null) - { - if (line.Contains("Last cursor", StringComparison.OrdinalIgnoreCase)) - { - foundLastCursor = true; - if (!resumed) - { - this.OutputHelper.WriteLine("Resuming stream from last cursor"); - await this.WriteInputAsync(process, string.Empty, testTimeoutCts.Token); - resumed = true; - } - break; - } - } - } - catch (OperationCanceledException) - { - // Timeout waiting for Last cursor - } - } - } - } - catch (OperationCanceledException) - { - // Timeout - check if we got enough to verify the flow - this.OutputHelper.WriteLine($"Read timeout reached. Interrupted: {interrupted}, Resumed: {resumed}, Content before: {contentLinesBeforeInterrupt}, Content after: {contentLinesAfterResume}"); - } - - Assert.True(foundConversationStart, "Conversation start message not found."); - Assert.True(contentLinesBeforeInterrupt >= 2, $"Not enough content before interrupt (got {contentLinesBeforeInterrupt})."); - - // If stream completed before interrupt could take effect, that's a timing issue - // but we should still verify we got the conversation started - if (!interrupted) - { - this.OutputHelper.WriteLine("WARNING: Stream completed before interrupt could be sent. This may indicate the stream is too fast."); - } - - Assert.True(interrupted, "Stream was not interrupted (may have completed too quickly)."); - Assert.True(foundLastCursor, "'Last cursor' message not found after interrupt."); - Assert.True(resumed, "Stream was not resumed."); - Assert.True(foundResumeMessage, "Resume message not found."); - Assert.True(contentLinesAfterResume > 0, "No content received after resume (expected to continue from cursor, not restart)."); - }); - } -} diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/ExternalClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/ExternalClientTests.cs deleted file mode 100644 index 511dc2e1191..00000000000 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/ExternalClientTests.cs +++ /dev/null @@ -1,238 +0,0 @@ -// 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.Chat; - -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 const string DisabledDueToFailingCiJob = "Disabled due to persistent CI failures. See #6732."; - - private static readonly TimeSpan s_defaultTimeout = Debugger.IsAttached - ? TimeSpan.FromMinutes(5) - : TimeSpan.FromSeconds(120); - - 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(); - - [RetryFact(2, 5000, Skip = DisabledDueToFailingCiJob)] - public async Task SimplePromptAsync() - { - // Setup - AIAgent simpleAgent = TestHelper.GetAzureOpenAIChatClient(s_configuration).AsAIAgent( - 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 - AgentSession session = await simpleAgentProxy.CreateSessionAsync(this.TestTimeoutToken); - await simpleAgentProxy.RunAsync( - message: "Hello!", - session, - cancellationToken: this.TestTimeoutToken); - - AgentResponse response = await simpleAgentProxy.RunAsync( - message: "Repeat what you just said but say it like a pirate", - session, - 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"); - } - - [RetryFact(2, 5000, Skip = DisabledDueToFailingCiJob)] - 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).AsAIAgent( - 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 - AgentResponse 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); - } - - [RetryFact(2, 5000, Skip = DisabledDueToFailingCiJob)] - 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"); - AgentSession session = await agent.CreateSessionAsync(this.TestTimeoutToken); - - // 2. Call an agent and tell it my name - await agent.RunAsync($"My name is {name}.", session); - - // 3. Call the agent again with the same session (ask it to tell me my name) - AgentResponse response = await agent.RunAsync("What is my name?", session); - - 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).AsAIAgent( - 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).AsAIAgent( - 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 - AgentSession session = await workflowManagerAgentProxy.CreateSessionAsync(this.TestTimeoutToken); - await workflowManagerAgentProxy.RunAsync( - message: "Start a greeting workflow for \"John Doe\".", - session, - cancellationToken: this.TestTimeoutToken); - - // Act: prompt it again to wait for the workflow to complete - AgentResponse response = await workflowManagerAgentProxy.RunAsync( - message: "Wait for the workflow to complete and tell me the result.", - session, - 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); - } - - [Fact] - public void AsDurableAgentProxy_ThrowsWhenAgentNotRegistered() - { - // Setup: Register one agent but try to use a different one - AIAgent registeredAgent = TestHelper.GetAzureOpenAIChatClient(s_configuration).AsAIAgent( - instructions: "You are a helpful assistant.", - name: "RegisteredAgent"); - - using TestHelper testHelper = TestHelper.Start([registeredAgent], this._outputHelper); - - // Create an agent with a different name that isn't registered - AIAgent unregisteredAgent = TestHelper.GetAzureOpenAIChatClient(s_configuration).AsAIAgent( - instructions: "You are a helpful assistant.", - name: "UnregisteredAgent"); - - // Act & Assert: Should throw AgentNotRegisteredException - AgentNotRegisteredException exception = Assert.Throws( - () => unregisteredAgent.AsDurableAgentProxy(testHelper.Services)); - - Assert.Equal("UnregisteredAgent", exception.AgentName); - } -} diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/Logging/LogEntry.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/Logging/LogEntry.cs deleted file mode 100644 index fa9eddaeb40..00000000000 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/Logging/LogEntry.cs +++ /dev/null @@ -1,48 +0,0 @@ -// 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 deleted file mode 100644 index 764d9cb24c0..00000000000 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/Logging/TestLogger.cs +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Concurrent; -using Microsoft.Extensions.Logging; - -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 deleted file mode 100644 index 57fbc4e4dba..00000000000 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/Logging/TestLoggerProvider.cs +++ /dev/null @@ -1,51 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Concurrent; -using Microsoft.Extensions.Logging; - -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 deleted file mode 100644 index adc184e5107..00000000000 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/Microsoft.Agents.AI.DurableTask.IntegrationTests.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - $(TargetFrameworksCore) - enable - True - - - - - - - - - - - - - - - - diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/OrchestrationTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/OrchestrationTests.cs deleted file mode 100644 index 753d57f160e..00000000000 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/OrchestrationTests.cs +++ /dev/null @@ -1,94 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Diagnostics; -using System.Reflection; -using Microsoft.DurableTask; -using Microsoft.DurableTask.Client; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Configuration; -using OpenAI.Chat; - -namespace Microsoft.Agents.AI.DurableTask.IntegrationTests; - -/// -/// Tests for orchestration execution scenarios with Durable Task Agents. -/// -[Collection("Sequential")] -[Trait("Category", "Integration")] -public sealed class OrchestrationTests(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 GetAgent_ThrowsWhenAgentNotRegisteredAsync() - { - // Define an orchestration that tries to use an unregistered agent - static async Task TestOrchestrationAsync(TaskOrchestrationContext context) - { - // Get an agent that hasn't been registered - DurableAIAgent agent = context.GetAgent("NonExistentAgent"); - - // This should throw when RunAsync is called because the agent doesn't exist - await agent.RunAsync("Hello"); - return "Should not reach here"; - } - - // Setup: Create test helper without registering "NonExistentAgent" - using TestHelper testHelper = TestHelper.Start( - this._outputHelper, - configureAgents: agents => - { - // Register a different agent, but not "NonExistentAgent" - agents.AddAIAgentFactory( - "OtherAgent", - sp => TestHelper.GetAzureOpenAIChatClient(s_configuration).AsAIAgent( - name: "OtherAgent", - instructions: "You are a test agent.")); - }, - durableTaskRegistry: registry => - registry.AddOrchestratorFunc( - name: nameof(TestOrchestrationAsync), - orchestrator: TestOrchestrationAsync)); - - DurableTaskClient client = testHelper.GetClient(); - - // Act: Start the orchestration - string instanceId = await client.ScheduleNewOrchestrationInstanceAsync( - orchestratorName: nameof(TestOrchestrationAsync), - cancellation: this.TestTimeoutToken); - - // Wait for the orchestration to complete and check for failure - OrchestrationMetadata status = await client.WaitForInstanceCompletionAsync( - instanceId, - getInputsAndOutputs: true, - this.TestTimeoutToken); - - // Assert: Verify the orchestration failed with the expected exception - Assert.NotNull(status); - Assert.Equal(OrchestrationRuntimeStatus.Failed, status.RuntimeStatus); - Assert.NotNull(status.FailureDetails); - - // Verify the exception type is AgentNotRegisteredException - Assert.True( - status.FailureDetails.ErrorType == typeof(AgentNotRegisteredException).FullName, - $"Expected AgentNotRegisteredException but got ErrorType: {status.FailureDetails.ErrorType}, Message: {status.FailureDetails.ErrorMessage}"); - - // Verify the exception message contains the agent name - Assert.Contains("NonExistentAgent", status.FailureDetails.ErrorMessage, StringComparison.OrdinalIgnoreCase); - } -} diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/SamplesValidationBase.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/SamplesValidationBase.cs deleted file mode 100644 index 3f01b83e54f..00000000000 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/SamplesValidationBase.cs +++ /dev/null @@ -1,513 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Concurrent; -using System.Diagnostics; -using System.Reflection; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging; -namespace Microsoft.Agents.AI.DurableTask.IntegrationTests; - -/// -/// Base class for sample validation integration tests providing shared infrastructure -/// setup and utility methods for running console app samples. -/// -public abstract class SamplesValidationBase : IAsyncLifetime -{ - protected const string DtsPort = "8080"; - protected const string RedisPort = "6379"; - - protected static readonly string DotnetTargetFramework = GetTargetFramework(); - protected static readonly IConfiguration Configuration = - new ConfigurationBuilder() - .AddUserSecrets(Assembly.GetExecutingAssembly()) - .AddEnvironmentVariables() - .Build(); - - // Semaphores for thread-safe initialization of shared infrastructure. - // xUnit may run tests in parallel, so we need to ensure that DTS emulator and Redis - // are started only once across all test instances. Using SemaphoreSlim allows async-safe - // locking, and the double-check pattern (check flag, acquire lock, check flag again) - // minimizes lock contention after initialization is complete. - private static readonly SemaphoreSlim s_dtsInitLock = new(1, 1); - private static readonly SemaphoreSlim s_redisInitLock = new(1, 1); - private static bool s_dtsInfrastructureStarted; - private static bool s_redisInfrastructureStarted; - - protected SamplesValidationBase(ITestOutputHelper outputHelper) - { - this.OutputHelper = outputHelper; - } - - /// - /// Gets the test output helper for logging. - /// - protected ITestOutputHelper OutputHelper { get; } - - /// - /// Gets the base path to the samples directory for this test class. - /// - protected abstract string SamplesPath { get; } - - /// - /// Gets whether this test class requires Redis infrastructure. - /// - protected virtual bool RequiresRedis => false; - - /// - /// Gets the task hub name prefix for this test class. - /// - protected virtual string TaskHubPrefix => "sample"; - - /// - public async ValueTask InitializeAsync() - { - await EnsureDtsInfrastructureStartedAsync(this.OutputHelper, this.StartDtsEmulatorAsync); - - if (this.RequiresRedis) - { - await EnsureRedisInfrastructureStartedAsync(this.OutputHelper, this.StartRedisAsync); - } - - await Task.Delay(TimeSpan.FromSeconds(5)); - } - - /// - /// Ensures DTS infrastructure is started exactly once across all test instances. - /// Static method writes to static field to avoid the code smell of instance methods modifying shared state. - /// - private static async Task EnsureDtsInfrastructureStartedAsync(ITestOutputHelper outputHelper, Func startAction) - { - if (s_dtsInfrastructureStarted) - { - return; - } - - await s_dtsInitLock.WaitAsync(); - try - { - if (!s_dtsInfrastructureStarted) - { - outputHelper.WriteLine("Starting shared DTS infrastructure..."); - await startAction(); - s_dtsInfrastructureStarted = true; - } - } - finally - { - s_dtsInitLock.Release(); - } - } - - /// - /// Ensures Redis infrastructure is started exactly once across all test instances. - /// Static method writes to static field to avoid the code smell of instance methods modifying shared state. - /// - private static async Task EnsureRedisInfrastructureStartedAsync(ITestOutputHelper outputHelper, Func startAction) - { - if (s_redisInfrastructureStarted) - { - return; - } - - await s_redisInitLock.WaitAsync(); - try - { - if (!s_redisInfrastructureStarted) - { - outputHelper.WriteLine("Starting shared Redis infrastructure..."); - await startAction(); - s_redisInfrastructureStarted = true; - } - } - finally - { - s_redisInitLock.Release(); - } - } - - /// - public ValueTask DisposeAsync() - { - GC.SuppressFinalize(this); - return default; - } - - protected sealed record OutputLog(DateTime Timestamp, LogLevel Level, string Message); - - /// - /// Runs a sample test by starting the console app and executing the provided test action. - /// - protected async Task RunSampleTestAsync(string samplePath, Func, Task> testAction) - { - string uniqueTaskHubName = $"{this.TaskHubPrefix}-{Guid.NewGuid():N}"[..^26]; - - // Build the sample project first so that build failures are caught immediately - // instead of silently failing inside 'dotnet run' and causing a timeout. - await this.BuildSampleAsync(samplePath); - - using BlockingCollection logsContainer = []; - using Process appProcess = this.StartConsoleApp(samplePath, logsContainer, uniqueTaskHubName); - - try - { - await testAction(appProcess, logsContainer); - } - catch (OperationCanceledException e) - { - throw new TimeoutException("Core test logic timed out!", e); - } - finally - { - if (!logsContainer.IsAddingCompleted) - { - logsContainer.CompleteAdding(); - } - - await this.StopProcessAsync(appProcess); - } - } - - /// - /// Writes a line to the process's stdin and flushes it. - /// - protected async Task WriteInputAsync(Process process, string input, CancellationToken cancellationToken) - { - this.OutputHelper.WriteLine($"{DateTime.Now:HH:mm:ss.fff} [{process.ProcessName}(in)]: {input}"); - await process.StandardInput.WriteLineAsync(input); - await process.StandardInput.FlushAsync(cancellationToken); - } - - /// - /// Reads the next Information-level log line from the queue. - /// Returns null if cancelled or collection is completed. - /// - protected string? ReadLogLine(BlockingCollection logs, CancellationToken cancellationToken) - { - try - { - while (!cancellationToken.IsCancellationRequested) - { - OutputLog log = logs.Take(cancellationToken); - - if (log.Message.Contains("Unhandled exception")) - { - Assert.Fail("Console app encountered an unhandled exception."); - } - - if (log.Level == LogLevel.Information) - { - return log.Message; - } - } - } - catch (OperationCanceledException) - { - return null; - } - catch (InvalidOperationException) - { - return null; - } - - return null; - } - - /// - /// Creates a cancellation token source with the specified timeout for test operations. - /// - protected CancellationTokenSource CreateTestTimeoutCts(TimeSpan? timeout = null) - { - TimeSpan testTimeout = Debugger.IsAttached ? TimeSpan.FromMinutes(5) : timeout ?? TimeSpan.FromSeconds(120); - return new CancellationTokenSource(testTimeout); - } - - /// - /// Allows derived classes to set additional environment variables for the console app process. - /// - protected virtual void ConfigureAdditionalEnvironmentVariables(ProcessStartInfo startInfo, Action setEnvVar) - { - } - - private static string GetTargetFramework() - { - string filePath = new Uri(typeof(SamplesValidationBase).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}"); - } - - private async Task StartDtsEmulatorAsync() - { - if (!await this.IsDtsEmulatorRunningAsync()) - { - this.OutputHelper.WriteLine("Starting DTS emulator..."); - await this.RunCommandAsync("docker", "run", "-d", - "--name", "dts-emulator", - "-p", $"{DtsPort}:8080", - "-e", "DTS_USE_DYNAMIC_TASK_HUBS=true", - "mcr.microsoft.com/dts/dts-emulator:latest"); - } - } - - private async Task StartRedisAsync() - { - if (!await this.IsRedisRunningAsync()) - { - this.OutputHelper.WriteLine("Starting Redis..."); - await this.RunCommandAsync("docker", "run", "-d", - "--name", "redis", - "-p", $"{RedisPort}:6379", - "redis:latest"); - } - } - - private async Task IsDtsEmulatorRunningAsync() - { - this.OutputHelper.WriteLine($"Checking if DTS emulator is running at http://localhost:{DtsPort}/healthz..."); - - 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}"); - } - - bool isRunning = response.IsSuccessStatusCode; - this.OutputHelper.WriteLine(isRunning ? "DTS emulator is running" : $"DTS emulator not running. Status: {response.StatusCode}"); - return isRunning; - } - catch (HttpRequestException ex) - { - this.OutputHelper.WriteLine($"DTS emulator is not running: {ex.Message}"); - return false; - } - } - - private async Task IsRedisRunningAsync() - { - this.OutputHelper.WriteLine($"Checking if Redis is running at localhost:{RedisPort}..."); - - try - { - using CancellationTokenSource timeoutCts = new(TimeSpan.FromSeconds(30)); - ProcessStartInfo startInfo = new() - { - FileName = "docker", - Arguments = "exec redis redis-cli ping", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - - using Process process = new() { StartInfo = startInfo }; - if (!process.Start()) - { - this.OutputHelper.WriteLine("Failed to start docker exec command"); - return false; - } - - string output = await process.StandardOutput.ReadToEndAsync(timeoutCts.Token); - await process.WaitForExitAsync(timeoutCts.Token); - - bool isRunning = process.ExitCode == 0 && output.Contains("PONG", StringComparison.OrdinalIgnoreCase); - this.OutputHelper.WriteLine(isRunning ? "Redis is running" : $"Redis not running. Exit: {process.ExitCode}, Output: {output}"); - return isRunning; - } - catch (Exception ex) - { - this.OutputHelper.WriteLine($"Redis is not running: {ex.Message}"); - return false; - } - } - - private async Task BuildSampleAsync(string samplePath) - { - this.OutputHelper.WriteLine($"Building sample at {samplePath}..."); - - ProcessStartInfo buildInfo = new() - { - FileName = "dotnet", - Arguments = $"build --framework {DotnetTargetFramework}", - WorkingDirectory = samplePath, - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - }; - - using Process buildProcess = new() { StartInfo = buildInfo }; - buildProcess.Start(); - - // Read both streams asynchronously to avoid deadlocks from filled pipe buffers - Task stdoutTask = buildProcess.StandardOutput.ReadToEndAsync(); - Task stderrTask = buildProcess.StandardError.ReadToEndAsync(); - - using CancellationTokenSource buildCts = new(TimeSpan.FromMinutes(5)); - try - { - await buildProcess.WaitForExitAsync(buildCts.Token); - } - catch (OperationCanceledException) - { - buildProcess.Kill(entireProcessTree: true); - throw new TimeoutException($"Build timed out after 5 minutes for sample at {samplePath}."); - } - - await Task.WhenAll(stdoutTask, stderrTask); - - string stdout = stdoutTask.Result; - string stderr = stderrTask.Result; - if (buildProcess.ExitCode != 0) - { - throw new InvalidOperationException($"Failed to build sample at {samplePath}:\n{stdout}\n{stderr}"); - } - - this.OutputHelper.WriteLine($"Build completed for {samplePath}."); - } - - private Process StartConsoleApp(string samplePath, BlockingCollection logs, string taskHubName) - { - ProcessStartInfo startInfo = new() - { - FileName = "dotnet", - Arguments = $"run --no-build --framework {DotnetTargetFramework}", - WorkingDirectory = samplePath, - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - RedirectStandardInput = true, - }; - - string openAiEndpoint = Configuration["AZURE_OPENAI_ENDPOINT"] ?? - throw new InvalidOperationException("The required AZURE_OPENAI_ENDPOINT env variable is not set."); - string openAiDeployment = Configuration["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"] ?? - throw new InvalidOperationException("The required AZURE_OPENAI_CHAT_DEPLOYMENT_NAME env variable is not set."); - - void SetAndLogEnvironmentVariable(string key, string value) - { - this.OutputHelper.WriteLine($"Setting environment variable for {startInfo.FileName} sub-process: {key}={value}"); - startInfo.EnvironmentVariables[key] = value; - } - - SetAndLogEnvironmentVariable("AZURE_OPENAI_ENDPOINT", openAiEndpoint); - SetAndLogEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT", openAiDeployment); - SetAndLogEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING", - $"Endpoint=http://localhost:{DtsPort};TaskHub={taskHubName};Authentication=None"); - - this.ConfigureAdditionalEnvironmentVariables(startInfo, SetAndLogEnvironmentVariable); - - Process process = new() { StartInfo = startInfo, EnableRaisingEvents = true }; - - process.ErrorDataReceived += (sender, e) => this.HandleProcessOutput(e.Data, startInfo.FileName, "err", LogLevel.Error, logs); - process.OutputDataReceived += (sender, e) => this.HandleProcessOutput(e.Data, startInfo.FileName, "out", LogLevel.Information, logs); - - // When the process exits unexpectedly (e.g. build failure), complete the log collection - // so that ReadLogLine returns null immediately instead of blocking until the test timeout. - process.Exited += (sender, e) => - { - if (!logs.IsAddingCompleted) - { - logs.CompleteAdding(); - } - }; - - if (!process.Start()) - { - throw new InvalidOperationException("Failed to start the console app"); - } - - process.BeginErrorReadLine(); - process.BeginOutputReadLine(); - - return process; - } - - private void HandleProcessOutput(string? data, string processName, string stream, LogLevel level, BlockingCollection logs) - { - if (data is null) - { - return; - } - - string logMessage = $"{DateTime.Now:HH:mm:ss.fff} [{processName}({stream})]: {data}"; - this.OutputHelper.WriteLine(logMessage); - Debug.WriteLine(logMessage); - - try - { - logs.Add(new OutputLog(DateTime.Now, level, data)); - } - catch (InvalidOperationException) - { - // Collection completed - } - } - - private async Task RunCommandAsync(string command, params string[] args) - { - ProcessStartInfo startInfo = new() - { - FileName = command, - Arguments = string.Join(" ", args), - 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 cts = new(TimeSpan.FromMinutes(1)); - await process.WaitForExitAsync(cts.Token); - - this.OutputHelper.WriteLine($"Command completed with exit code: {process.ExitCode}"); - } - - private async Task StopProcessAsync(Process process) - { - try - { - if (!process.HasExited) - { - this.OutputHelper.WriteLine($"{DateTime.Now:HH:mm:ss.fff} Killing process {process.ProcessName}#{process.Id}"); - process.Kill(entireProcessTree: true); - - using CancellationTokenSource cts = new(TimeSpan.FromSeconds(10)); - await process.WaitForExitAsync(cts.Token); - this.OutputHelper.WriteLine($"{DateTime.Now:HH:mm:ss.fff} Process exited: {process.Id}"); - } - } - catch (Exception ex) - { - this.OutputHelper.WriteLine($"{DateTime.Now:HH:mm:ss.fff} Failed to stop process: {ex.Message}"); - } - } -} diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/TestHelper.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/TestHelper.cs deleted file mode 100644 index d9350cec591..00000000000 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/TestHelper.cs +++ /dev/null @@ -1,177 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Azure; -using Azure.AI.OpenAI; -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 Shared.IntegrationTests; - -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); - - // Generate a unique TaskHub name for this test instance to prevent cross-test interference - // when multiple tests run together and share the same DTS emulator. - string uniqueTaskHubName = $"test-{Guid.NewGuid().ToString("N").Substring(0, 6)}"; - - IHost host = Host.CreateDefaultBuilder() - .ConfigureServices((ctx, services) => - { - string dtsConnectionString = GetDurableTaskSchedulerConnectionString(ctx.Configuration, uniqueTaskHubName); - - // 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, string? taskHubName = null) - { - // The default value is for local development using the Durable Task Scheduler emulator. - string? connectionString = configuration["DURABLE_TASK_SCHEDULER_CONNECTION_STRING"]; - - if (connectionString != null) - { - // If a connection string is provided, replace the TaskHub name if a custom one is specified - if (taskHubName != null) - { - // Replace TaskHub in the connection string - if (connectionString.Contains("TaskHub=", StringComparison.OrdinalIgnoreCase)) - { - // Find and replace the TaskHub value - int taskHubIndex = connectionString.IndexOf("TaskHub=", StringComparison.OrdinalIgnoreCase); - int taskHubValueStart = taskHubIndex + "TaskHub=".Length; - int taskHubValueEnd = connectionString.IndexOf(';', taskHubValueStart); - if (taskHubValueEnd == -1) - { - taskHubValueEnd = connectionString.Length; - } - - connectionString = string.Concat( - connectionString.AsSpan(0, taskHubValueStart), - taskHubName, - connectionString.AsSpan(taskHubValueEnd)); - } - else - { - // Append TaskHub if it doesn't exist - connectionString += $";TaskHub={taskHubName}"; - } - } - - return connectionString; - } - - // Default connection string with unique TaskHub name - string defaultTaskHub = taskHubName ?? "default"; - return $"Endpoint=http://localhost:8080;TaskHub={defaultTaskHub};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_DEPLOYMENT_NAME"] ?? - throw new InvalidOperationException("The required AZURE_OPENAI_DEPLOYMENT_NAME env variable is not set."); - - // Check if AZURE_OPENAI_API_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_API_KEY"]; - - AzureOpenAIClient client = !string.IsNullOrEmpty(azureOpenAiKey) - ? new AzureOpenAIClient(new Uri(azureOpenAiEndpoint), new AzureKeyCredential(azureOpenAiKey)) - : new AzureOpenAIClient(new Uri(azureOpenAiEndpoint), TestAzureCliCredentials.CreateAzureCliCredential()); - - return client.GetChatClient(azureOpenAiDeploymentName); - } - - internal IReadOnlyCollection GetLogs() - { - return this._loggerProvider.GetAllLogs(); - } -} diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/TimeToLiveTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/TimeToLiveTests.cs deleted file mode 100644 index 4c21817a6de..00000000000 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/TimeToLiveTests.cs +++ /dev/null @@ -1,196 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Diagnostics; -using System.Reflection; -using Microsoft.Agents.AI.DurableTask.State; -using Microsoft.DurableTask.Client; -using Microsoft.DurableTask.Client.Entities; -using Microsoft.Extensions.Configuration; -using OpenAI.Chat; - -namespace Microsoft.Agents.AI.DurableTask.IntegrationTests; - -/// -/// Tests for Time-To-Live (TTL) functionality of durable agent entities. -/// -[Collection("Sequential")] -[Trait("Category", "IntegrationDisabled")] -public sealed class TimeToLiveTests(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 EntityExpiresAfterTTLAsync() - { - // Arrange: Create agent with short TTL (10 seconds) - TimeSpan ttl = TimeSpan.FromSeconds(10); - AIAgent simpleAgent = TestHelper.GetAzureOpenAIChatClient(s_configuration).AsAIAgent( - name: "TTLTestAgent", - instructions: "You are a helpful assistant." - ); - - using TestHelper testHelper = TestHelper.Start( - this._outputHelper, - options => - { - options.DefaultTimeToLive = ttl; - options.MinimumTimeToLiveSignalDelay = TimeSpan.FromSeconds(1); - options.AddAIAgent(simpleAgent); - }); - - AIAgent agentProxy = simpleAgent.AsDurableAgentProxy(testHelper.Services); - AgentSession session = await agentProxy.CreateSessionAsync(this.TestTimeoutToken); - DurableTaskClient client = testHelper.GetClient(); - AgentSessionId sessionId = session.GetService(); - - // Act: Send a message to the agent - await agentProxy.RunAsync( - message: "Hello!", - session, - cancellationToken: this.TestTimeoutToken); - - // Verify entity exists and get expiration time - EntityMetadata? entity = await client.Entities.GetEntityAsync(sessionId, true, this.TestTimeoutToken); - Assert.NotNull(entity); - Assert.True(entity.IncludesState); - - DurableAgentState state = entity.State.ReadAs(); - Assert.NotNull(state.Data.ExpirationTimeUtc); - DateTime expirationTime = state.Data.ExpirationTimeUtc.Value; - Assert.True(expirationTime > DateTime.UtcNow); - - // Calculate how long to wait: expiration time + buffer for signal processing - TimeSpan waitTime = expirationTime - DateTime.UtcNow + TimeSpan.FromSeconds(1); - if (waitTime > TimeSpan.Zero) - { - await Task.Delay(waitTime, this.TestTimeoutToken); - } - - // Poll the entity state until it's deleted (with timeout) - DateTime pollTimeout = DateTime.UtcNow.AddSeconds(10); - bool entityDeleted = false; - while (DateTime.UtcNow < pollTimeout && !entityDeleted) - { - entity = await client.Entities.GetEntityAsync(sessionId, true, this.TestTimeoutToken); - entityDeleted = entity is null; - - if (!entityDeleted) - { - await Task.Delay(TimeSpan.FromSeconds(1), this.TestTimeoutToken); - } - } - - // Assert: Verify entity state is deleted - Assert.True(entityDeleted, "Entity should have been deleted after TTL expiration"); - } - - [Fact] - public async Task EntityTTLResetsOnInteractionAsync() - { - // Arrange: Create agent with short TTL - TimeSpan ttl = TimeSpan.FromSeconds(6); - AIAgent simpleAgent = TestHelper.GetAzureOpenAIChatClient(s_configuration).AsAIAgent( - name: "TTLResetTestAgent", - instructions: "You are a helpful assistant." - ); - - using TestHelper testHelper = TestHelper.Start( - this._outputHelper, - options => - { - options.DefaultTimeToLive = ttl; - options.MinimumTimeToLiveSignalDelay = TimeSpan.FromSeconds(1); - options.AddAIAgent(simpleAgent); - }); - - AIAgent agentProxy = simpleAgent.AsDurableAgentProxy(testHelper.Services); - AgentSession session = await agentProxy.CreateSessionAsync(this.TestTimeoutToken); - DurableTaskClient client = testHelper.GetClient(); - AgentSessionId sessionId = session.GetService(); - - // Act: Send first message - await agentProxy.RunAsync( - message: "Hello!", - session, - cancellationToken: this.TestTimeoutToken); - - EntityMetadata? entity = await client.Entities.GetEntityAsync(sessionId, true, this.TestTimeoutToken); - Assert.NotNull(entity); - Assert.True(entity.IncludesState); - - DurableAgentState state = entity.State.ReadAs(); - DateTime firstExpirationTime = state.Data.ExpirationTimeUtc!.Value; - - // Wait partway through TTL - await Task.Delay(TimeSpan.FromSeconds(3), this.TestTimeoutToken); - - // Send second message (should reset TTL) - await agentProxy.RunAsync( - message: "Hello again!", - session, - cancellationToken: this.TestTimeoutToken); - - // Verify expiration time was updated - entity = await client.Entities.GetEntityAsync(sessionId, true, this.TestTimeoutToken); - Assert.NotNull(entity); - Assert.True(entity.IncludesState); - - state = entity.State.ReadAs(); - DateTime secondExpirationTime = state.Data.ExpirationTimeUtc!.Value; - Assert.True(secondExpirationTime > firstExpirationTime); - - // Calculate when the original expiration time would have been - DateTime originalExpirationTime = firstExpirationTime; - TimeSpan waitUntilOriginalExpiration = originalExpirationTime - DateTime.UtcNow + TimeSpan.FromSeconds(2); - - if (waitUntilOriginalExpiration > TimeSpan.Zero) - { - await Task.Delay(waitUntilOriginalExpiration, this.TestTimeoutToken); - } - - // Assert: Entity should still exist because TTL was reset - // The new expiration time should be in the future - entity = await client.Entities.GetEntityAsync(sessionId, true, this.TestTimeoutToken); - Assert.NotNull(entity); - Assert.True(entity.IncludesState); - - state = entity.State.ReadAs(); - Assert.NotNull(state); - Assert.NotNull(state.Data.ExpirationTimeUtc); - Assert.True( - state.Data.ExpirationTimeUtc > DateTime.UtcNow, - "Entity should still be valid because TTL was reset"); - - // Wait for the entity to be deleted - DateTime pollTimeout = DateTime.UtcNow.AddSeconds(10); - bool entityDeleted = false; - while (DateTime.UtcNow < pollTimeout && !entityDeleted) - { - entity = await client.Entities.GetEntityAsync(sessionId, true, this.TestTimeoutToken); - entityDeleted = entity is null; - - if (!entityDeleted) - { - await Task.Delay(TimeSpan.FromSeconds(1), this.TestTimeoutToken); - } - } - - // Assert: Entity should have been deleted - Assert.True(entityDeleted, "Entity should have been deleted after TTL expiration"); - } -} diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/WorkflowConsoleAppSamplesValidation.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/WorkflowConsoleAppSamplesValidation.cs deleted file mode 100644 index 2834e314115..00000000000 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/WorkflowConsoleAppSamplesValidation.cs +++ /dev/null @@ -1,566 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -namespace Microsoft.Agents.AI.DurableTask.IntegrationTests; - -/// -/// Integration tests for validating the durable workflow console app samples -/// located in samples/04-hosting/DurableWorkflows/ConsoleApps. -/// -[Collection("Samples")] -[Trait("Category", "SampleValidation")] -public sealed class WorkflowConsoleAppSamplesValidation(ITestOutputHelper outputHelper) : SamplesValidationBase(outputHelper) -{ - // In CI, `dotnet run` builds samples from scratch and LLM calls add latency, so 60s is not enough. - private static readonly TimeSpan s_testTimeout = TimeSpan.FromSeconds(180); - - private static readonly string s_samplesPath = Path.GetFullPath( - Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "..", "..", "..", "..", "..", "samples", "04-hosting", "DurableWorkflows", "ConsoleApps")); - - /// - protected override string SamplesPath => s_samplesPath; - - /// - protected override string TaskHubPrefix => "workflow"; - - [RetryFact(2, 5000)] - public async Task SequentialWorkflowSampleValidationAsync() - { - using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts(s_testTimeout); - string samplePath = Path.Combine(s_samplesPath, "01_SequentialWorkflow"); - - await this.RunSampleTestAsync(samplePath, async (process, logs) => - { - bool inputSent = false; - bool workflowCompleted = false; - bool foundOrderLookup = false; - bool foundOrderCancel = false; - bool foundSendEmail = false; - - string? line; - while ((line = this.ReadLogLine(logs, testTimeoutCts.Token)) != null) - { - if (!inputSent && line.Contains("Enter an order ID", StringComparison.OrdinalIgnoreCase)) - { - await this.WriteInputAsync(process, "12345", testTimeoutCts.Token); - inputSent = true; - } - - if (inputSent) - { - foundOrderLookup |= line.Contains("[Activity] OrderLookup:", StringComparison.Ordinal); - foundOrderCancel |= line.Contains("[Activity] OrderCancel:", StringComparison.Ordinal); - foundSendEmail |= line.Contains("[Activity] SendEmail:", StringComparison.Ordinal); - - if (line.Contains("Workflow completed. Cancellation email sent for order 12345", StringComparison.OrdinalIgnoreCase)) - { - workflowCompleted = true; - break; - } - } - - this.AssertNoError(line); - } - - Assert.True(inputSent, "Input was not sent to the workflow."); - Assert.True(foundOrderLookup, "OrderLookup executor log entry not found."); - Assert.True(foundOrderCancel, "OrderCancel executor log entry not found."); - Assert.True(foundSendEmail, "SendEmail executor log entry not found."); - Assert.True(workflowCompleted, "Workflow did not complete successfully."); - - await this.WriteInputAsync(process, "exit", testTimeoutCts.Token); - }); - } - - [RetryFact(2, 5000, Skip = "Disabled due to persistent CI failures. See #6732.")] - public async Task ConcurrentWorkflowSampleValidationAsync() - { - using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts(s_testTimeout); - string samplePath = Path.Combine(s_samplesPath, "02_ConcurrentWorkflow"); - - await this.RunSampleTestAsync(samplePath, async (process, logs) => - { - bool inputSent = false; - bool workflowCompleted = false; - bool foundParseQuestion = false; - bool foundAggregator = false; - bool foundAggregatorReceived2Responses = false; - - string? line; - while ((line = this.ReadLogLine(logs, testTimeoutCts.Token)) != null) - { - if (!inputSent && line.Contains("Enter a science question", StringComparison.OrdinalIgnoreCase)) - { - await this.WriteInputAsync(process, "What is gravity?", testTimeoutCts.Token); - inputSent = true; - } - - if (inputSent) - { - foundParseQuestion |= line.Contains("[ParseQuestion]", StringComparison.Ordinal); - foundAggregator |= line.Contains("[Aggregator]", StringComparison.Ordinal); - foundAggregatorReceived2Responses |= line.Contains("Received 2 AI agent responses", StringComparison.Ordinal); - - if (line.Contains("Aggregation complete", StringComparison.OrdinalIgnoreCase)) - { - workflowCompleted = true; - break; - } - } - - this.AssertNoError(line); - } - - Assert.True(inputSent, "Input was not sent to the workflow."); - Assert.True(foundParseQuestion, "ParseQuestion executor log entry not found."); - Assert.True(foundAggregator, "Aggregator executor log entry not found."); - Assert.True(foundAggregatorReceived2Responses, "Aggregator did not receive 2 AI agent responses."); - Assert.True(workflowCompleted, "Workflow did not complete successfully."); - - await this.WriteInputAsync(process, "exit", testTimeoutCts.Token); - }); - } - - [RetryFact(2, 5000)] - public async Task ConditionalEdgesWorkflowSampleValidationAsync() - { - using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts(s_testTimeout); - string samplePath = Path.Combine(s_samplesPath, "03_ConditionalEdges"); - - await this.RunSampleTestAsync(samplePath, async (process, logs) => - { - bool validOrderSent = false; - bool blockedOrderSent = false; - bool validOrderCompleted = false; - bool blockedOrderCompleted = false; - - string? line; - while ((line = this.ReadLogLine(logs, testTimeoutCts.Token)) != null) - { - // Send a valid order first (no 'B' in ID) - if (!validOrderSent && line.Contains("Enter an order ID", StringComparison.OrdinalIgnoreCase)) - { - await this.WriteInputAsync(process, "12345", testTimeoutCts.Token); - validOrderSent = true; - } - - // Check valid order completed (routed to PaymentProcessor) - if (validOrderSent && !validOrderCompleted && - line.Contains("PaymentReferenceNumber", StringComparison.OrdinalIgnoreCase)) - { - validOrderCompleted = true; - - // Send a blocked order (contains 'B') - await this.WriteInputAsync(process, "ORDER-B-999", testTimeoutCts.Token); - blockedOrderSent = true; - } - - // Check blocked order completed (routed to NotifyFraud) - if (blockedOrderSent && line.Contains("flagged as fraudulent", StringComparison.OrdinalIgnoreCase)) - { - blockedOrderCompleted = true; - break; - } - - this.AssertNoError(line); - } - - Assert.True(validOrderSent, "Valid order input was not sent."); - Assert.True(validOrderCompleted, "Valid order did not complete (PaymentProcessor path)."); - Assert.True(blockedOrderSent, "Blocked order input was not sent."); - Assert.True(blockedOrderCompleted, "Blocked order did not complete (NotifyFraud path)."); - - await this.WriteInputAsync(process, "exit", testTimeoutCts.Token); - }); - } - - private void AssertNoError(string line) - { - if (line.Contains("Failed:", StringComparison.OrdinalIgnoreCase) || - line.Contains("Error:", StringComparison.OrdinalIgnoreCase)) - { - Assert.Fail($"Workflow failed: {line}"); - } - } - - [RetryFact(2, 5000, Skip = "KeyNotFoundException in workflow execution. See https://github.com/microsoft/agent-framework/issues/6404")] - public async Task WorkflowEventsSampleValidationAsync() - { - using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts(s_testTimeout); - string samplePath = Path.Combine(s_samplesPath, "05_WorkflowEvents"); - - await this.RunSampleTestAsync(samplePath, async (process, logs) => - { - bool inputSent = false; - bool foundStartedRun = false; - bool foundExecutorInvoked = false; - bool foundExecutorCompleted = false; - bool foundLookupStarted = false; - bool foundOrderFound = false; - bool foundCancelProgress = false; - bool foundOrderCancelled = false; - bool foundEmailSent = false; - bool foundYieldedOutput = false; - bool foundWorkflowCompleted = false; - bool foundCompletionResult = false; - List eventLines = []; - - string? line; - while ((line = this.ReadLogLine(logs, testTimeoutCts.Token)) != null) - { - if (!inputSent && line.Contains("Enter order ID", StringComparison.OrdinalIgnoreCase)) - { - await this.WriteInputAsync(process, "12345", testTimeoutCts.Token); - inputSent = true; - } - - if (inputSent) - { - foundStartedRun |= line.Contains("Started run:", StringComparison.Ordinal); - foundExecutorInvoked |= line.Contains("ExecutorInvokedEvent", StringComparison.Ordinal); - foundExecutorCompleted |= line.Contains("ExecutorCompletedEvent", StringComparison.Ordinal); - foundLookupStarted |= line.Contains("[Lookup] Looking up order", StringComparison.Ordinal); - foundOrderFound |= line.Contains("[Lookup] Found:", StringComparison.Ordinal); - foundCancelProgress |= line.Contains("[Cancel]", StringComparison.Ordinal) && line.Contains('%'); - foundOrderCancelled |= line.Contains("[Cancel] Done", StringComparison.Ordinal); - foundEmailSent |= line.Contains("[Email] Sent to", StringComparison.Ordinal); - foundYieldedOutput |= line.Contains("[Output]", StringComparison.Ordinal); - foundWorkflowCompleted |= line.Contains("DurableWorkflowCompletedEvent", StringComparison.Ordinal); - - if (line.Contains("Completed:", StringComparison.Ordinal)) - { - foundCompletionResult = line.Contains("12345", StringComparison.Ordinal); - break; - } - - // Collect event lines for ordering verification - if (line.Contains("[Lookup]", StringComparison.Ordinal) - || line.Contains("[Cancel]", StringComparison.Ordinal) - || line.Contains("[Email]", StringComparison.Ordinal) - || line.Contains("[Output]", StringComparison.Ordinal)) - { - eventLines.Add(line); - } - } - - this.AssertNoError(line); - } - - Assert.True(inputSent, "Input was not sent to the workflow."); - Assert.True(foundStartedRun, "Streaming run was not started."); - Assert.True(foundExecutorInvoked, "ExecutorInvokedEvent not found in stream."); - Assert.True(foundExecutorCompleted, "ExecutorCompletedEvent not found in stream."); - Assert.True(foundLookupStarted, "OrderLookupStartedEvent not found in stream."); - Assert.True(foundOrderFound, "OrderFoundEvent not found in stream."); - Assert.True(foundCancelProgress, "CancellationProgressEvent not found in stream."); - Assert.True(foundOrderCancelled, "OrderCancelledEvent not found in stream."); - Assert.True(foundEmailSent, "EmailSentEvent not found in stream."); - Assert.True(foundYieldedOutput, "WorkflowOutputEvent not found in stream."); - Assert.True(foundWorkflowCompleted, "DurableWorkflowCompletedEvent not found in stream."); - Assert.True(foundCompletionResult, "Completion result does not contain the order ID."); - - // Verify event ordering: lookup events appear before cancel events, which appear before email events - int lastLookupIndex = eventLines.FindLastIndex(l => l.Contains("[Lookup]", StringComparison.Ordinal)); - int firstCancelIndex = eventLines.FindIndex(l => l.Contains("[Cancel]", StringComparison.Ordinal)); - int lastCancelIndex = eventLines.FindLastIndex(l => l.Contains("[Cancel]", StringComparison.Ordinal)); - int firstEmailIndex = eventLines.FindIndex(l => l.Contains("[Email]", StringComparison.Ordinal)); - - if (lastLookupIndex >= 0 && firstCancelIndex >= 0) - { - Assert.True(lastLookupIndex < firstCancelIndex, "Lookup events should appear before cancel events."); - } - - if (lastCancelIndex >= 0 && firstEmailIndex >= 0) - { - Assert.True(lastCancelIndex < firstEmailIndex, "Cancel events should appear before email events."); - } - - await this.WriteInputAsync(process, "exit", testTimeoutCts.Token); - }); - } - - [RetryFact(2, 5000, Skip = "KeyNotFoundException in workflow execution. See https://github.com/microsoft/agent-framework/issues/6404")] - public async Task WorkflowSharedStateSampleValidationAsync() - { - using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts(s_testTimeout); - string samplePath = Path.Combine(s_samplesPath, "06_WorkflowSharedState"); - - await this.RunSampleTestAsync(samplePath, async (process, logs) => - { - bool inputSent = false; - bool foundStartedRun = false; - bool foundValidateOutput = false; - bool foundEnrichOutput = false; - bool foundPaymentOutput = false; - bool foundInvoiceOutput = false; - bool foundTaxCalculation = false; - bool foundAuditTrail = false; - bool foundWorkflowCompleted = false; - List outputLines = []; - - string? line; - while ((line = this.ReadLogLine(logs, testTimeoutCts.Token)) != null) - { - if (!inputSent && line.Contains("Enter an order ID", StringComparison.OrdinalIgnoreCase)) - { - await this.WriteInputAsync(process, "ORD-001", testTimeoutCts.Token); - inputSent = true; - } - - if (inputSent) - { - foundStartedRun |= line.Contains("Started run:", StringComparison.Ordinal); - - if (line.Contains("[Output]", StringComparison.Ordinal)) - { - foundValidateOutput |= line.Contains("ValidateOrder:", StringComparison.Ordinal) && line.Contains("validated", StringComparison.OrdinalIgnoreCase); - foundEnrichOutput |= line.Contains("EnrichOrder:", StringComparison.Ordinal) && line.Contains("enriched", StringComparison.OrdinalIgnoreCase); - foundPaymentOutput |= line.Contains("ProcessPayment:", StringComparison.Ordinal) && line.Contains("Payment processed", StringComparison.OrdinalIgnoreCase); - foundInvoiceOutput |= line.Contains("GenerateInvoice:", StringComparison.Ordinal) && line.Contains("Invoice complete", StringComparison.OrdinalIgnoreCase); - - // Verify shared state: tax rate was read by ProcessPayment - foundTaxCalculation |= line.Contains("tax:", StringComparison.OrdinalIgnoreCase); - - // Verify shared state: audit trail was accumulated across executors - foundAuditTrail |= line.Contains("Audit trail:", StringComparison.Ordinal) - && line.Contains("ValidateOrder", StringComparison.Ordinal) - && line.Contains("EnrichOrder", StringComparison.Ordinal) - && line.Contains("ProcessPayment", StringComparison.Ordinal); - - outputLines.Add(line); - } - - foundWorkflowCompleted |= line.Contains("DurableWorkflowCompletedEvent", StringComparison.Ordinal) - || line.Contains("Completed:", StringComparison.Ordinal); - - if (line.Contains("Completed:", StringComparison.Ordinal)) - { - break; - } - } - - this.AssertNoError(line); - } - - Assert.True(inputSent, "Input was not sent to the workflow."); - Assert.True(foundStartedRun, "Streaming run was not started."); - Assert.True(foundValidateOutput, "ValidateOrder output not found in stream."); - Assert.True(foundEnrichOutput, "EnrichOrder output not found in stream."); - Assert.True(foundPaymentOutput, "ProcessPayment output not found in stream."); - Assert.True(foundInvoiceOutput, "GenerateInvoice output not found in stream."); - Assert.True(foundTaxCalculation, "Tax calculation (shared state read) not found."); - Assert.True(foundAuditTrail, "Audit trail (shared state accumulation) not found."); - Assert.True(foundWorkflowCompleted, "Workflow completion not found in stream."); - - // Verify output ordering: ValidateOrder -> EnrichOrder -> ProcessPayment -> GenerateInvoice - int validateIndex = outputLines.FindIndex(l => l.Contains("ValidateOrder:", StringComparison.Ordinal) && l.Contains("validated", StringComparison.OrdinalIgnoreCase)); - int enrichIndex = outputLines.FindIndex(l => l.Contains("EnrichOrder:", StringComparison.Ordinal)); - int paymentIndex = outputLines.FindIndex(l => l.Contains("ProcessPayment:", StringComparison.Ordinal)); - int invoiceIndex = outputLines.FindIndex(l => l.Contains("GenerateInvoice:", StringComparison.Ordinal)); - - if (validateIndex >= 0 && enrichIndex >= 0) - { - Assert.True(validateIndex < enrichIndex, "ValidateOrder output should appear before EnrichOrder."); - } - - if (enrichIndex >= 0 && paymentIndex >= 0) - { - Assert.True(enrichIndex < paymentIndex, "EnrichOrder output should appear before ProcessPayment."); - } - - if (paymentIndex >= 0 && invoiceIndex >= 0) - { - Assert.True(paymentIndex < invoiceIndex, "ProcessPayment output should appear before GenerateInvoice."); - } - - await this.WriteInputAsync(process, "exit", testTimeoutCts.Token); - }); - } - - [RetryFact(2, 5000, Skip = "KeyNotFoundException in workflow execution. See https://github.com/microsoft/agent-framework/issues/6404")] - public async Task SubWorkflowsSampleValidationAsync() - { - using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts(s_testTimeout); - string samplePath = Path.Combine(s_samplesPath, "07_SubWorkflows"); - - await this.RunSampleTestAsync(samplePath, async (process, logs) => - { - bool inputSent = false; - bool foundOrderReceived = false; - bool foundValidatePayment = false; - bool foundAnalyzePatterns = false; - bool foundCalculateRiskScore = false; - bool foundChargePayment = false; - bool foundSelectCarrier = false; - bool foundCreateShipment = false; - bool foundOrderCompleted = false; - bool foundFraudRiskEvent = false; - bool workflowCompleted = false; - - string? line; - while ((line = this.ReadLogLine(logs, testTimeoutCts.Token)) != null) - { - if (!inputSent && line.Contains("Enter an order ID", StringComparison.OrdinalIgnoreCase)) - { - await this.WriteInputAsync(process, "ORD-001", testTimeoutCts.Token); - inputSent = true; - } - - if (inputSent) - { - // Main workflow executors - foundOrderReceived |= line.Contains("[OrderReceived]", StringComparison.Ordinal); - foundOrderCompleted |= line.Contains("[OrderCompleted]", StringComparison.Ordinal); - - // Payment sub-workflow executors - foundValidatePayment |= line.Contains("[Payment/ValidatePayment]", StringComparison.Ordinal); - foundChargePayment |= line.Contains("[Payment/ChargePayment]", StringComparison.Ordinal); - - // FraudCheck sub-sub-workflow executors (nested inside Payment) - foundAnalyzePatterns |= line.Contains("[Payment/FraudCheck/AnalyzePatterns]", StringComparison.Ordinal); - foundCalculateRiskScore |= line.Contains("[Payment/FraudCheck/CalculateRiskScore]", StringComparison.Ordinal); - - // Shipping sub-workflow executors - foundSelectCarrier |= line.Contains("[Shipping/SelectCarrier]", StringComparison.Ordinal); - foundCreateShipment |= line.Contains("[Shipping/CreateShipment]", StringComparison.Ordinal); - - // Custom event from nested sub-workflow (streamed to client) - foundFraudRiskEvent |= line.Contains("[Event from sub-workflow] FraudRiskAssessedEvent", StringComparison.Ordinal); - - if (line.Contains("Order completed", StringComparison.OrdinalIgnoreCase)) - { - workflowCompleted = true; - break; - } - } - - this.AssertNoError(line); - } - - Assert.True(inputSent, "Input was not sent to the workflow."); - Assert.True(foundOrderReceived, "OrderReceived executor log not found."); - Assert.True(foundValidatePayment, "Payment/ValidatePayment executor log not found."); - Assert.True(foundAnalyzePatterns, "Payment/FraudCheck/AnalyzePatterns executor log not found."); - Assert.True(foundCalculateRiskScore, "Payment/FraudCheck/CalculateRiskScore executor log not found."); - Assert.True(foundChargePayment, "Payment/ChargePayment executor log not found."); - Assert.True(foundSelectCarrier, "Shipping/SelectCarrier executor log not found."); - Assert.True(foundCreateShipment, "Shipping/CreateShipment executor log not found."); - Assert.True(foundOrderCompleted, "OrderCompleted executor log not found."); - Assert.True(foundFraudRiskEvent, "FraudRiskAssessedEvent from nested sub-workflow not found."); - Assert.True(workflowCompleted, "Workflow did not complete successfully."); - - await this.WriteInputAsync(process, "exit", testTimeoutCts.Token); - }); - } - - [RetryFact(2, 5000, Skip = "KeyNotFoundException in workflow execution. See https://github.com/microsoft/agent-framework/issues/6404")] - public async Task WorkflowHITLSampleValidationAsync() - { - using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts(s_testTimeout); - string samplePath = Path.Combine(s_samplesPath, "08_WorkflowHITL"); - - await this.RunSampleTestAsync(samplePath, (process, logs) => - { - bool foundStarted = false; - bool foundManagerApprovalPause = false; - bool foundManagerApprovalInput = false; - bool foundManagerResponseSent = false; - bool foundBudgetApprovalPause = false; - bool foundBudgetResponseSent = false; - bool foundComplianceApprovalPause = false; - bool foundComplianceResponseSent = false; - bool foundWorkflowCompleted = false; - - string? line; - while ((line = this.ReadLogLine(logs, testTimeoutCts.Token)) != null) - { - foundStarted |= line.Contains("Starting expense reimbursement workflow", StringComparison.Ordinal); - foundManagerApprovalPause |= line.Contains("Workflow paused at RequestPort: ManagerApproval", StringComparison.Ordinal); - foundManagerApprovalInput |= line.Contains("Approval for: Jerry", StringComparison.Ordinal); - foundManagerResponseSent |= line.Contains("Response sent: Approved=True", StringComparison.Ordinal) && foundManagerApprovalPause && !foundBudgetApprovalPause && !foundComplianceApprovalPause; - foundBudgetApprovalPause |= line.Contains("Workflow paused at RequestPort: BudgetApproval", StringComparison.Ordinal); - foundBudgetResponseSent |= line.Contains("Response sent: Approved=True", StringComparison.Ordinal) && foundBudgetApprovalPause; - foundComplianceApprovalPause |= line.Contains("Workflow paused at RequestPort: ComplianceApproval", StringComparison.Ordinal); - foundComplianceResponseSent |= line.Contains("Response sent: Approved=True", StringComparison.Ordinal) && foundComplianceApprovalPause; - - if (line.Contains("Workflow completed: Expense reimbursed at", StringComparison.Ordinal)) - { - foundWorkflowCompleted = true; - break; - } - - this.AssertNoError(line); - } - - Assert.True(foundStarted, "Workflow start message not found."); - Assert.True(foundManagerApprovalPause, "Manager approval pause not found."); - Assert.True(foundManagerApprovalInput, "Manager approval input (Jerry) not found."); - Assert.True(foundManagerResponseSent, "Manager approval response not sent."); - Assert.True(foundBudgetApprovalPause, "Budget approval pause not found."); - Assert.True(foundBudgetResponseSent, "Budget approval response not sent."); - Assert.True(foundComplianceApprovalPause, "Compliance approval pause not found."); - Assert.True(foundComplianceResponseSent, "Compliance approval response not sent."); - Assert.True(foundWorkflowCompleted, "Workflow did not complete successfully."); - - return Task.CompletedTask; - }); - } - - [RetryFact(2, 5000, Skip = "Disabled due to persistent CI failures. See #6732.")] - public async Task WorkflowAndAgentsSampleValidationAsync() - { - using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts(s_testTimeout); - string samplePath = Path.Combine(s_samplesPath, "04_WorkflowAndAgents"); - - await this.RunSampleTestAsync(samplePath, (process, logs) => - { - // Arrange - bool foundDemo1 = false; - bool foundBiologistResponse = false; - bool foundChemistResponse = false; - bool foundDemo2 = false; - bool foundPhysicsWorkflow = false; - bool foundDemo3 = false; - bool foundExpertTeamWorkflow = false; - bool foundDemo4 = false; - bool foundChemistryWorkflow = false; - bool allDemosCompleted = false; - - // Act - string? line; - while ((line = this.ReadLogLine(logs, testTimeoutCts.Token)) != null) - { - foundDemo1 |= line.Contains("DEMO 1:", StringComparison.Ordinal); - foundBiologistResponse |= line.Contains("Biologist:", StringComparison.Ordinal); - foundChemistResponse |= line.Contains("Chemist:", StringComparison.Ordinal); - foundDemo2 |= line.Contains("DEMO 2:", StringComparison.Ordinal); - foundPhysicsWorkflow |= line.Contains("PhysicsExpertReview", StringComparison.Ordinal); - foundDemo3 |= line.Contains("DEMO 3:", StringComparison.Ordinal); - foundExpertTeamWorkflow |= line.Contains("ExpertTeamReview", StringComparison.Ordinal); - foundDemo4 |= line.Contains("DEMO 4:", StringComparison.Ordinal); - foundChemistryWorkflow |= line.Contains("ChemistryExpertReview", StringComparison.Ordinal); - - if (line.Contains("All demos completed", StringComparison.OrdinalIgnoreCase)) - { - allDemosCompleted = true; - break; - } - - this.AssertNoError(line); - } - - // Assert - Assert.True(foundDemo1, "DEMO 1 (Direct Agent Conversation) not found."); - Assert.True(foundBiologistResponse, "Biologist agent response not found."); - Assert.True(foundChemistResponse, "Chemist agent response not found."); - Assert.True(foundDemo2, "DEMO 2 (Single-Agent Workflow) not found."); - Assert.True(foundPhysicsWorkflow, "PhysicsExpertReview workflow not found."); - Assert.True(foundDemo3, "DEMO 3 (Multi-Agent Workflow) not found."); - Assert.True(foundExpertTeamWorkflow, "ExpertTeamReview workflow not found."); - Assert.True(foundDemo4, "DEMO 4 (Chemistry Workflow) not found."); - Assert.True(foundChemistryWorkflow, "ChemistryExpertReview workflow not found."); - Assert.True(allDemosCompleted, "Sample did not complete all demos successfully."); - - return Task.CompletedTask; - }); - } -} diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/AgentSessionIdTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/AgentSessionIdTests.cs deleted file mode 100644 index bcc6df48a2d..00000000000 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/AgentSessionIdTests.cs +++ /dev/null @@ -1,68 +0,0 @@ -// 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; - }); - } - - // Ensures the 2-arg constructor treats the key as opaque and never re-interprets - // it as a serialized session id, so the resulting Name always comes from the first - // argument regardless of the key's shape. - [Fact] - public void ConstructorTreatsKeyAsOpaqueValue() - { - AgentSessionId sessionId = new("agentA", "@dafx-agentB@some-key"); - - Assert.Equal("agentA", sessionId.Name); - Assert.Equal("@dafx-agentB@some-key", sessionId.Key); - } -} diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/DurableAIAgentProxyTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/DurableAIAgentProxyTests.cs deleted file mode 100644 index 97b88b4a7b8..00000000000 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/DurableAIAgentProxyTests.cs +++ /dev/null @@ -1,87 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Extensions.AI; - -namespace Microsoft.Agents.AI.DurableTask.UnitTests; - -public sealed class DurableAIAgentProxyTests -{ - // Verifies the proxy rejects a session whose agent name differs from its own, - // and that the durable client is never called when this happens. - [Fact] - public async Task RunAsync_ThrowsWhenSessionBelongsToDifferentAgentAsync() - { - StubDurableAgentClient client = new(); - DurableAIAgentProxy proxy = new("agentA", client); - DurableAgentSession session = new(new AgentSessionId("agentB", "shared-key")); - - ArgumentException ex = await Assert.ThrowsAsync(() => - proxy.RunAsync(new ChatMessage(ChatRole.User, "hello"), session)); - - Assert.Equal("session", ex.ParamName); - Assert.Contains("agentB", ex.Message, StringComparison.Ordinal); - Assert.Contains("agentA", ex.Message, StringComparison.Ordinal); - Assert.Equal(0, client.CallCount); - } - - // Control test: when the session's agent name matches the proxy's name, - // the request is forwarded to the durable client. - [Fact] - public async Task RunAsync_AllowsSessionWhenAgentNameMatchesAsync() - { - AgentSessionId sessionId = new("agentA", "shared-key"); - InvalidOperationException sentinel = new("reached the client"); - StubDurableAgentClient client = new() { Throw = sentinel }; - DurableAIAgentProxy proxy = new("agentA", client); - DurableAgentSession session = new(sessionId); - - // Reaching the durable client (and therefore propagating the sentinel) proves the - // name-matching guard accepted this session. - InvalidOperationException ex = await Assert.ThrowsAsync(() => - proxy.RunAsync(new ChatMessage(ChatRole.User, "hello"), session)); - - Assert.Same(sentinel, ex); - Assert.Equal(1, client.CallCount); - Assert.Equal(sessionId, client.LastSessionId); - } - - // Ensures the agent-name comparison is case-insensitive, so casing differences - // are neither a false-positive rejection nor a bypass. - [Fact] - public async Task RunAsync_AgentNameComparisonIsCaseInsensitiveAsync() - { - AgentSessionId sessionId = new("AGENTA", "shared-key"); - InvalidOperationException sentinel = new("reached the client"); - StubDurableAgentClient client = new() { Throw = sentinel }; - DurableAIAgentProxy proxy = new("agentA", client); - DurableAgentSession session = new(sessionId); - - InvalidOperationException ex = await Assert.ThrowsAsync(() => - proxy.RunAsync(new ChatMessage(ChatRole.User, "hello"), session)); - - Assert.Same(sentinel, ex); - Assert.Equal(1, client.CallCount); - } - - private sealed class StubDurableAgentClient : IDurableAgentClient - { - public int CallCount { get; private set; } - public AgentSessionId LastSessionId { get; private set; } - public Exception? Throw { get; set; } - - public Task RunAgentAsync( - AgentSessionId sessionId, - RunRequest request, - CancellationToken cancellationToken) - { - this.CallCount++; - this.LastSessionId = sessionId; - if (this.Throw is not null) - { - return Task.FromException(this.Throw); - } - - throw new InvalidOperationException("Test did not configure a response."); - } - } -} diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/DurableAgentRunOptionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/DurableAgentRunOptionsTests.cs deleted file mode 100644 index 77012f49570..00000000000 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/DurableAgentRunOptionsTests.cs +++ /dev/null @@ -1,94 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Extensions.AI; - -namespace Microsoft.Agents.AI.DurableTask.UnitTests; - -/// -/// Unit tests for the class. -/// -public sealed class DurableAgentRunOptionsTests -{ - [Fact] - public void CloneReturnsNewInstanceWithSameValues() - { - // Arrange - DurableAgentRunOptions options = new() - { - EnableToolCalls = false, - EnableToolNames = new List { "tool1", "tool2" }, - IsFireAndForget = true, - AllowBackgroundResponses = true, - ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }), - AdditionalProperties = new AdditionalPropertiesDictionary - { - ["key1"] = "value1", - ["key2"] = 42 - }, - ResponseFormat = ChatResponseFormat.Json - }; - - // Act - AgentRunOptions cloneAsBase = options.Clone(); - - // Assert - Assert.NotNull(cloneAsBase); - Assert.IsType(cloneAsBase); - DurableAgentRunOptions clone = (DurableAgentRunOptions)cloneAsBase; - Assert.NotSame(options, clone); - Assert.Equal(options.EnableToolCalls, clone.EnableToolCalls); - Assert.NotNull(clone.EnableToolNames); - Assert.NotSame(options.EnableToolNames, clone.EnableToolNames); - Assert.Equal(2, clone.EnableToolNames.Count); - Assert.Contains("tool1", clone.EnableToolNames); - Assert.Contains("tool2", clone.EnableToolNames); - Assert.Equal(options.IsFireAndForget, clone.IsFireAndForget); - Assert.Equal(options.AllowBackgroundResponses, clone.AllowBackgroundResponses); - Assert.Same(options.ContinuationToken, clone.ContinuationToken); - Assert.NotNull(clone.AdditionalProperties); - Assert.NotSame(options.AdditionalProperties, clone.AdditionalProperties); - Assert.Equal("value1", clone.AdditionalProperties["key1"]); - Assert.Equal(42, clone.AdditionalProperties["key2"]); - Assert.Same(options.ResponseFormat, clone.ResponseFormat); - } - - [Fact] - public void CloneCreatesIndependentEnableToolNamesList() - { - // Arrange - DurableAgentRunOptions options = new() - { - EnableToolNames = new List { "tool1" } - }; - - // Act - DurableAgentRunOptions clone = (DurableAgentRunOptions)options.Clone(); - clone.EnableToolNames!.Add("tool2"); - - // Assert - Assert.Equal(2, clone.EnableToolNames.Count); - Assert.Single(options.EnableToolNames); - Assert.DoesNotContain("tool2", options.EnableToolNames); - } - - [Fact] - public void CloneCreatesIndependentAdditionalPropertiesDictionary() - { - // Arrange - DurableAgentRunOptions options = new() - { - AdditionalProperties = new AdditionalPropertiesDictionary - { - ["key1"] = "value1" - } - }; - - // Act - DurableAgentRunOptions clone = (DurableAgentRunOptions)options.Clone(); - clone.AdditionalProperties!["key2"] = "value2"; - - // Assert - Assert.True(clone.AdditionalProperties.ContainsKey("key2")); - Assert.False(options.AdditionalProperties.ContainsKey("key2")); - } -} diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/DurableAgentSessionTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/DurableAgentSessionTests.cs deleted file mode 100644 index bc06c35ab88..00000000000 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/DurableAgentSessionTests.cs +++ /dev/null @@ -1,79 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Text.Json; - -namespace Microsoft.Agents.AI.DurableTask.UnitTests; - -public sealed class DurableAgentSessionTests -{ - [Fact] - public void BuiltInSerialization() - { - AgentSessionId sessionId = AgentSessionId.WithRandomKey("test-agent"); - DurableAgentSession session = new(sessionId); - - JsonElement serializedSession = session.Serialize(); - - // Expected format: "{\"sessionId\":\"@dafx-test-agent@\"}" - string expectedSerializedSession = $"{{\"sessionId\":\"@dafx-{sessionId.Name}@{sessionId.Key}\",\"stateBag\":{{}}}}"; - Assert.Equal(expectedSerializedSession, serializedSession.ToString()); - - DurableAgentSession deserializedSession = DurableAgentSession.Deserialize(serializedSession); - Assert.Equal(sessionId, deserializedSession.SessionId); - } - - [Fact] - public void STJSerialization() - { - AgentSessionId sessionId = AgentSessionId.WithRandomKey("test-agent"); - AgentSession session = new DurableAgentSession(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 serializedSession = JsonSerializer.Serialize(session, typeof(DurableAgentSession)); - - // Expected format: "{\"sessionId\":\"@dafx-test-agent@\"}" - string expectedSerializedSession = $"{{\"sessionId\":\"@dafx-{sessionId.Name}@{sessionId.Key}\",\"stateBag\":{{}}}}"; - Assert.Equal(expectedSerializedSession, serializedSession); - - DurableAgentSession? deserializedSession = JsonSerializer.Deserialize(serializedSession); - Assert.NotNull(deserializedSession); - Assert.Equal(sessionId, deserializedSession.SessionId); - } - - [Fact] - public void BuiltInSerialization_RoundTrip_PreservesStateBag() - { - // Arrange - AgentSessionId sessionId = AgentSessionId.WithRandomKey("test-agent"); - DurableAgentSession session = new(sessionId); - session.StateBag.SetValue("durableKey", "durableValue"); - - // Act - JsonElement serializedSession = session.Serialize(); - DurableAgentSession deserializedSession = DurableAgentSession.Deserialize(serializedSession); - - // Assert - Assert.Equal(sessionId, deserializedSession.SessionId); - Assert.True(deserializedSession.StateBag.TryGetValue("durableKey", out var value)); - Assert.Equal("durableValue", value); - } - - [Fact] - public void STJSerialization_RoundTrip_PreservesStateBag() - { - // Arrange - AgentSessionId sessionId = AgentSessionId.WithRandomKey("test-agent"); - DurableAgentSession session = new(sessionId); - session.StateBag.SetValue("stjKey", "stjValue"); - - // Act - string serializedSession = JsonSerializer.Serialize(session, typeof(DurableAgentSession)); - DurableAgentSession? deserializedSession = JsonSerializer.Deserialize(serializedSession); - - // Assert - Assert.NotNull(deserializedSession); - Assert.True(deserializedSession.StateBag.TryGetValue("stjKey", out var value)); - Assert.Equal("stjValue", value); - } -} 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 deleted file mode 100644 index 335d8e401b6..00000000000 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Microsoft.Agents.AI.DurableTask.UnitTests.csproj +++ /dev/null @@ -1,13 +0,0 @@ - - - - $(TargetFrameworksCore) - enable - - - - - - - - diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateContentTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateContentTests.cs deleted file mode 100644 index 2fda1178e17..00000000000 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateContentTests.cs +++ /dev/null @@ -1,324 +0,0 @@ -// 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 deleted file mode 100644 index 343644d9119..00000000000 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateMessageTests.cs +++ /dev/null @@ -1,47 +0,0 @@ -// 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/DurableAgentStateRequestTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateRequestTests.cs deleted file mode 100644 index acdc6021652..00000000000 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateRequestTests.cs +++ /dev/null @@ -1,34 +0,0 @@ -// 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 DurableAgentStateRequestTests -{ - [Fact] - public void RequestSerializationDeserialization() - { - // Arrange - RunRequest originalRequest = new("Hello, world!") - { - OrchestrationId = "orch-456" - }; - DurableAgentStateRequest originalDurableRequest = DurableAgentStateRequest.FromRunRequest(originalRequest); - - // Act - string jsonContent = JsonSerializer.Serialize( - originalDurableRequest, - DurableAgentStateJsonContext.Default.GetTypeInfo(typeof(DurableAgentStateRequest))!); - - DurableAgentStateRequest? convertedJsonContent = (DurableAgentStateRequest?)JsonSerializer.Deserialize( - jsonContent, - DurableAgentStateJsonContext.Default.GetTypeInfo(typeof(DurableAgentStateRequest))!); - - // Assert - Assert.NotNull(convertedJsonContent); - Assert.Equal(originalRequest.CorrelationId, convertedJsonContent.CorrelationId); - Assert.Equal(originalRequest.OrchestrationId, convertedJsonContent.OrchestrationId); - } -} diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateResponseTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateResponseTests.cs deleted file mode 100644 index a974f9d9741..00000000000 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateResponseTests.cs +++ /dev/null @@ -1,142 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Agents.AI.DurableTask.State; -using Microsoft.Extensions.AI; - -namespace Microsoft.Agents.AI.DurableTask.Tests.Unit.State; - -public sealed class DurableAgentStateResponseTests -{ - [Fact] - public void FromResponseDropsMessagesContainingOnlyOpaqueContent() - { - // Arrange: one message with real text, one with only opaque AIContent - ChatMessage usefulMessage = new(ChatRole.Assistant, "Hello, world!") - { - CreatedAt = DateTimeOffset.UtcNow - }; - ChatMessage opaqueOnlyMessage = new(ChatRole.Assistant, [ - new AIContent - { - RawRepresentation = new { kind = "sessionEvent", sessionId = "s123" } - }]) - { - CreatedAt = DateTimeOffset.UtcNow.AddSeconds(1) - }; - - AgentResponse response = new(new List { usefulMessage, opaqueOnlyMessage }) - { - CreatedAt = DateTimeOffset.UtcNow - }; - - // Act - DurableAgentStateResponse durableResponse = DurableAgentStateResponse.FromResponse("corr-123", response); - - // Assert: only the useful message survives - DurableAgentStateMessage durableMessage = Assert.Single(durableResponse.Messages); - Assert.Equal(ChatRole.Assistant.Value, durableMessage.Role); - - // Round-trip to verify the content is correct - AgentResponse convertedResponse = durableResponse.ToResponse(); - ChatMessage convertedMessage = Assert.Single(convertedResponse.Messages); - TextContent textContent = Assert.IsType(Assert.Single(convertedMessage.Contents)); - Assert.Equal("Hello, world!", textContent.Text); - } - - [Fact] - public void FromResponseKeepsMessagesWithMixedContent() - { - // Arrange: one message with both real text and opaque AIContent - ChatMessage mixedMessage = new(ChatRole.Assistant, [ - new TextContent("Some useful text"), - new AIContent { RawRepresentation = new { kind = "metadata" } }]) - { - CreatedAt = DateTimeOffset.UtcNow - }; - - AgentResponse response = new(new List { mixedMessage }) - { - CreatedAt = DateTimeOffset.UtcNow - }; - - // Act - DurableAgentStateResponse durableResponse = DurableAgentStateResponse.FromResponse("corr-456", response); - - // Assert: the message is kept because it contains at least one serializable content - DurableAgentStateMessage durableMessage = Assert.Single(durableResponse.Messages); - Assert.Equal(ChatRole.Assistant.Value, durableMessage.Role); - } - - [Fact] - public void FromResponseDropsAllMessagesWhenAllAreOpaque() - { - // Arrange: all messages contain only opaque AIContent - ChatMessage opaque1 = new(ChatRole.Assistant, [ - new AIContent { RawRepresentation = new { kind = "event1" } }]) - { - CreatedAt = DateTimeOffset.UtcNow - }; - ChatMessage opaque2 = new(ChatRole.Assistant, [ - new AIContent { RawRepresentation = new { kind = "event2" } }]) - { - CreatedAt = DateTimeOffset.UtcNow.AddSeconds(1) - }; - - AgentResponse response = new(new List { opaque1, opaque2 }) - { - CreatedAt = DateTimeOffset.UtcNow - }; - - // Act - DurableAgentStateResponse durableResponse = DurableAgentStateResponse.FromResponse("corr-789", response); - - // Assert: no messages stored - Assert.Empty(durableResponse.Messages); - } - - [Fact] - public void FromResponseKeepsBaseAIContentWithAnnotations() - { - // Arrange: base AIContent with annotations should be kept - AIContent contentWithAnnotations = new() - { - RawRepresentation = new { kind = "event" }, - Annotations = [new AIAnnotation() { AdditionalProperties = new() { ["cite"] = "ref-1" } }] - }; - ChatMessage message = new(ChatRole.Assistant, [contentWithAnnotations]) - { - CreatedAt = DateTimeOffset.UtcNow - }; - - AgentResponse response = new([message]) { CreatedAt = DateTimeOffset.UtcNow }; - - // Act - DurableAgentStateResponse durableResponse = DurableAgentStateResponse.FromResponse("corr-ann", response); - - // Assert: message is kept because the AIContent has annotations - Assert.Single(durableResponse.Messages); - } - - [Fact] - public void FromResponseKeepsBaseAIContentWithAdditionalProperties() - { - // Arrange: base AIContent with additional properties should be kept - AIContent contentWithProps = new() - { - RawRepresentation = new { kind = "event" }, - AdditionalProperties = new() { ["custom_key"] = "custom_value" } - }; - ChatMessage message = new(ChatRole.Assistant, [contentWithProps]) - { - CreatedAt = DateTimeOffset.UtcNow - }; - - AgentResponse response = new([message]) { CreatedAt = DateTimeOffset.UtcNow }; - - // Act - DurableAgentStateResponse durableResponse = DurableAgentStateResponse.FromResponse("corr-props", response); - - // Assert: message is kept because the AIContent has additional properties - Assert.Single(durableResponse.Messages); - } -} diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateTests.cs deleted file mode 100644 index f8ce5c6decb..00000000000 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateTests.cs +++ /dev/null @@ -1,170 +0,0 @@ -// 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.DurableTask.UnitTests/Workflows/DurableActivityExecutorResolveInputTypeTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableActivityExecutorResolveInputTypeTests.cs deleted file mode 100644 index bf6c47ad3f7..00000000000 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableActivityExecutorResolveInputTypeTests.cs +++ /dev/null @@ -1,104 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Agents.AI.DurableTask.Workflows; -using Microsoft.Extensions.AI; - -namespace Microsoft.Agents.AI.DurableTask.UnitTests.Workflows; - -/// -/// Verifies that -/// matches persisted assembly-qualified type-name strings against the executor's supported -/// input types even when the persisted name carries a different assembly version, culture, -/// or public key token than the loaded assemblies. -/// -public sealed class DurableActivityExecutorResolveInputTypeTests -{ - [Fact] - public void ResolveInputType_NullInput_ReturnsFirstSupportedType() - { - Type result = DurableActivityExecutor.ResolveInputType(null, new HashSet { typeof(int) }); - - Assert.Equal(typeof(int), result); - } - - [Fact] - public void ResolveInputType_EmptyInputAndNoSupportedTypes_FallsBackToString() - { - Type result = DurableActivityExecutor.ResolveInputType(string.Empty, new HashSet()); - - Assert.Equal(typeof(string), result); - } - - [Fact] - public void ResolveInputType_LoadedAssemblyQualifiedName_ReturnsSupportedType() - { - Type supported = typeof(ChatMessage); - ISet supportedTypes = new HashSet { supported }; - - Type result = DurableActivityExecutor.ResolveInputType(supported.AssemblyQualifiedName, supportedTypes); - - Assert.Same(supported, result); - } - - [Fact] - public void ResolveInputType_MutatedAssemblyVersion_ReturnsSupportedType() - { - Type supported = typeof(ChatMessage); - string simpleAssemblyName = supported.Assembly.GetName().Name!; - string mutated = $"{supported.FullName}, {simpleAssemblyName}, Version=99.0.0.0, Culture=neutral, PublicKeyToken=null"; - ISet supportedTypes = new HashSet { supported }; - - Type result = DurableActivityExecutor.ResolveInputType(mutated, supportedTypes); - - Assert.Same(supported, result); - } - - [Fact] - public void ResolveInputType_MutatedGenericArgumentVersion_ReturnsSupportedType() - { - Type supported = typeof(List); - string outerSimple = supported.Assembly.GetName().Name!; - string innerSimple = typeof(ChatMessage).Assembly.GetName().Name!; - string mutated = - $"System.Collections.Generic.List`1[[Microsoft.Extensions.AI.ChatMessage, {innerSimple}, " + - "Version=99.0.0.0, Culture=neutral, PublicKeyToken=null]], " + - $"{outerSimple}, Version=99.0.0.0, Culture=neutral, PublicKeyToken=null"; - ISet supportedTypes = new HashSet { supported }; - - Type result = DurableActivityExecutor.ResolveInputType(mutated, supportedTypes); - - Assert.Same(supported, result); - } - - [Fact] - public void ResolveInputType_ShortNameMatch_ReturnsSupportedType() - { - Type supported = typeof(ChatMessage); - ISet supportedTypes = new HashSet { supported }; - - Type result = DurableActivityExecutor.ResolveInputType(supported.Name, supportedTypes); - - Assert.Same(supported, result); - } - - [Fact] - public void ResolveInputType_FullNameMatch_ReturnsSupportedType() - { - Type supported = typeof(ChatMessage); - ISet supportedTypes = new HashSet { supported }; - - Type result = DurableActivityExecutor.ResolveInputType(supported.FullName, supportedTypes); - - Assert.Same(supported, result); - } - - [Fact] - public void ResolveInputType_StringFallback_ReturnsFirstSupportedTypeWhenStringUnsupported() - { - ISet supportedTypes = new HashSet { typeof(int) }; - - Type result = DurableActivityExecutor.ResolveInputType(typeof(string).AssemblyQualifiedName, supportedTypes); - - Assert.Equal(typeof(int), result); - } -} diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableActivityExecutorTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableActivityExecutorTests.cs deleted file mode 100644 index e3b549e3651..00000000000 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableActivityExecutorTests.cs +++ /dev/null @@ -1,235 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Text.Json; -using Microsoft.Agents.AI.DurableTask.Workflows; - -namespace Microsoft.Agents.AI.DurableTask.UnitTests.Workflows; - -public sealed class DurableActivityExecutorTests -{ - private static readonly JsonSerializerOptions s_camelCaseOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - PropertyNameCaseInsensitive = true - }; - - #region DeserializeInput - - [Fact] - public void DeserializeInput_StringType_ReturnsInputAsIs() - { - // Arrange - const string Input = "hello world"; - - // Act - object result = DurableActivityExecutor.DeserializeInput(Input, typeof(string)); - - // Assert - Assert.Equal("hello world", result); - } - - [Fact] - public void DeserializeInput_SimpleObject_DeserializesCorrectly() - { - // Arrange - string input = JsonSerializer.Serialize(new TestRecord("EXP-001", 100.50m), s_camelCaseOptions); - - // Act - object result = DurableActivityExecutor.DeserializeInput(input, typeof(TestRecord)); - - // Assert - TestRecord record = Assert.IsType(result); - Assert.Equal("EXP-001", record.Id); - Assert.Equal(100.50m, record.Amount); - } - - [Fact] - public void DeserializeInput_StringArray_DeserializesDirectly() - { - // Arrange - string input = JsonSerializer.Serialize((string[])["a", "b", "c"]); - - // Act - object result = DurableActivityExecutor.DeserializeInput(input, typeof(string[])); - - // Assert - string[] array = Assert.IsType(result); - Assert.Equal(["a", "b", "c"], array); - } - - [Fact] - public void DeserializeInput_TypedArrayFromFanIn_DeserializesEachElement() - { - // Arrange — fan-in produces a JSON array of serialized strings - TestRecord r1 = new("EXP-001", 100m); - TestRecord r2 = new("EXP-002", 200m); - string[] serializedElements = - [ - JsonSerializer.Serialize(r1, s_camelCaseOptions), - JsonSerializer.Serialize(r2, s_camelCaseOptions) - ]; - string input = JsonSerializer.Serialize(serializedElements); - - // Act - object result = DurableActivityExecutor.DeserializeInput(input, typeof(TestRecord[])); - - // Assert - TestRecord[] records = Assert.IsType(result); - Assert.Equal(2, records.Length); - Assert.Equal("EXP-001", records[0].Id); - Assert.Equal(100m, records[0].Amount); - Assert.Equal("EXP-002", records[1].Id); - Assert.Equal(200m, records[1].Amount); - } - - [Fact] - public void DeserializeInput_TypedArrayWithSingleElement_DeserializesCorrectly() - { - // Arrange - TestRecord r1 = new("EXP-001", 50m); - string[] serializedElements = [JsonSerializer.Serialize(r1, s_camelCaseOptions)]; - string input = JsonSerializer.Serialize(serializedElements); - - // Act - object result = DurableActivityExecutor.DeserializeInput(input, typeof(TestRecord[])); - - // Assert - TestRecord[] records = Assert.IsType(result); - Assert.Single(records); - Assert.Equal("EXP-001", records[0].Id); - } - - [Fact] - public void DeserializeInput_TypedArrayWithNullElement_ThrowsInvalidOperationException() - { - // Arrange — one element is "null" - string input = JsonSerializer.Serialize((string[])["null"]); - - // Act & Assert - Assert.Throws( - () => DurableActivityExecutor.DeserializeInput(input, typeof(TestRecord[]))); - } - - [Fact] - public void DeserializeInput_InvalidJson_ThrowsJsonException() - { - // Arrange - const string Input = "not valid json"; - - // Act & Assert - Assert.ThrowsAny( - () => DurableActivityExecutor.DeserializeInput(Input, typeof(TestRecord))); - } - - #endregion - - #region ResolveInputType - - [Fact] - public void ResolveInputType_NullTypeName_ReturnsFirstSupportedType() - { - // Arrange - HashSet supportedTypes = [typeof(TestRecord), typeof(string)]; - - // Act - Type result = DurableActivityExecutor.ResolveInputType(null, supportedTypes); - - // Assert - Assert.Equal(typeof(TestRecord), result); - } - - [Fact] - public void ResolveInputType_EmptyTypeName_ReturnsFirstSupportedType() - { - // Arrange - HashSet supportedTypes = [typeof(TestRecord)]; - - // Act - Type result = DurableActivityExecutor.ResolveInputType(string.Empty, supportedTypes); - - // Assert - Assert.Equal(typeof(TestRecord), result); - } - - [Fact] - public void ResolveInputType_EmptySupportedTypes_DefaultsToString() - { - // Arrange - HashSet supportedTypes = []; - - // Act - Type result = DurableActivityExecutor.ResolveInputType(null, supportedTypes); - - // Assert - Assert.Equal(typeof(string), result); - } - - [Fact] - public void ResolveInputType_MatchesByFullName() - { - // Arrange - HashSet supportedTypes = [typeof(TestRecord)]; - - // Act - Type result = DurableActivityExecutor.ResolveInputType(typeof(TestRecord).FullName, supportedTypes); - - // Assert - Assert.Equal(typeof(TestRecord), result); - } - - [Fact] - public void ResolveInputType_MatchesByName() - { - // Arrange - HashSet supportedTypes = [typeof(TestRecord)]; - - // Act - Type result = DurableActivityExecutor.ResolveInputType("TestRecord", supportedTypes); - - // Assert - Assert.Equal(typeof(TestRecord), result); - } - - [Fact] - public void ResolveInputType_StringArrayFallsBackToSupportedType() - { - // Arrange — fan-in sends string[] but executor expects TestRecord[] - HashSet supportedTypes = [typeof(TestRecord[])]; - - // Act - Type result = DurableActivityExecutor.ResolveInputType(typeof(string[]).FullName, supportedTypes); - - // Assert - Assert.Equal(typeof(TestRecord[]), result); - } - - [Fact] - public void ResolveInputType_StringFallsBackToSupportedType() - { - // Arrange — executor doesn't support string - HashSet supportedTypes = [typeof(TestRecord)]; - - // Act - Type result = DurableActivityExecutor.ResolveInputType(typeof(string).FullName, supportedTypes); - - // Assert - Assert.Equal(typeof(TestRecord), result); - } - - [Fact] - public void ResolveInputType_StringArrayRetainedWhenSupported() - { - // Arrange — executor explicitly supports string[] - HashSet supportedTypes = [typeof(string[])]; - - // Act - Type result = DurableActivityExecutor.ResolveInputType(typeof(string[]).FullName, supportedTypes); - - // Assert - Assert.Equal(typeof(string[]), result); - } - - #endregion - - private sealed record TestRecord(string Id, decimal Amount); -} diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableStreamingWorkflowRunTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableStreamingWorkflowRunTests.cs deleted file mode 100644 index 404ed3496d6..00000000000 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableStreamingWorkflowRunTests.cs +++ /dev/null @@ -1,814 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Text.Json; -using Microsoft.Agents.AI.DurableTask.Workflows; -using Microsoft.Agents.AI.Workflows; -using Microsoft.DurableTask; -using Microsoft.DurableTask.Client; -using Moq; - -namespace Microsoft.Agents.AI.DurableTask.UnitTests.Workflows; - -public sealed class DurableStreamingWorkflowRunTests -{ - private const string InstanceId = "test-instance-123"; - private const string WorkflowTestName = "TestWorkflow"; - - private static Workflow CreateTestWorkflow() => - new WorkflowBuilder(new FunctionExecutor("start", (_, _, _) => default)) - .WithName(WorkflowTestName) - .Build(); - - private static OrchestrationMetadata CreateMetadata( - OrchestrationRuntimeStatus status, - string? serializedCustomStatus = null, - string? serializedOutput = null, - TaskFailureDetails? failureDetails = null) - { - return new OrchestrationMetadata(WorkflowTestName, InstanceId) - { - RuntimeStatus = status, - SerializedCustomStatus = serializedCustomStatus, - SerializedOutput = serializedOutput, - FailureDetails = failureDetails, - }; - } - - private static string SerializeCustomStatus(List events) - { - DurableWorkflowLiveStatus status = new() { Events = events }; - return JsonSerializer.Serialize(status, DurableSerialization.Options); - } - - private static string SerializeCustomStatusWithPendingEvents( - List events, - List pendingEvents) - { - DurableWorkflowLiveStatus status = new() { Events = events, PendingEvents = pendingEvents }; - return JsonSerializer.Serialize(status, DurableSerialization.Options); - } - - private static Workflow CreateTestWorkflowWithRequestPort(string requestPortId) - { - FunctionExecutor start = new("start", (_, _, _) => default); - RequestPort requestPort = RequestPort.Create(requestPortId); - FunctionExecutor end = new("end", (_, _, _) => default); - return new WorkflowBuilder(start) - .WithName(WorkflowTestName) - .AddEdge(start, requestPort) - .AddEdge(requestPort, end) - .Build(); - } - - private static string SerializeWorkflowResult(string? result, List events) - { - DurableWorkflowResult workflowResult = new() { Result = result, Events = events }; - return JsonSerializer.Serialize(workflowResult, DurableWorkflowJsonContext.Default.DurableWorkflowResult); - } - - private static string SerializeEvent(WorkflowEvent evt) - { - Type eventType = evt.GetType(); - TypedPayload wrapper = new() - { - TypeName = eventType.AssemblyQualifiedName, - Data = JsonSerializer.Serialize(evt, eventType, DurableSerialization.Options) - }; - - return JsonSerializer.Serialize(wrapper, DurableWorkflowJsonContext.Default.TypedPayload); - } - - #region Constructor and Properties - - [Fact] - public void Constructor_SetsRunIdAndWorkflowName() - { - // Arrange - Mock mockClient = new("test"); - - // Act - DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow()); - - // Assert - Assert.Equal(InstanceId, run.RunId); - Assert.Equal(WorkflowTestName, run.WorkflowName); - } - - [Fact] - public void Constructor_NoWorkflowName_SetsEmptyString() - { - // Arrange - Mock mockClient = new("test"); - Workflow workflow = new WorkflowBuilder(new FunctionExecutor("start", (_, _, _) => default)).Build(); - - // Act - DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, workflow); - - // Assert - Assert.Equal(string.Empty, run.WorkflowName); - } - - #endregion - - #region GetStatusAsync - - [Theory] - [InlineData(OrchestrationRuntimeStatus.Pending, DurableRunStatus.Pending)] - [InlineData(OrchestrationRuntimeStatus.Running, DurableRunStatus.Running)] - [InlineData(OrchestrationRuntimeStatus.Completed, DurableRunStatus.Completed)] - [InlineData(OrchestrationRuntimeStatus.Failed, DurableRunStatus.Failed)] - [InlineData(OrchestrationRuntimeStatus.Terminated, DurableRunStatus.Terminated)] - [InlineData(OrchestrationRuntimeStatus.Suspended, DurableRunStatus.Suspended)] - - public async Task GetStatusAsync_MapsRuntimeStatusCorrectlyAsync( - OrchestrationRuntimeStatus runtimeStatus, - DurableRunStatus expectedStatus) - { - // Arrange - Mock mockClient = new("test"); - mockClient.Setup(c => c.GetInstanceAsync(InstanceId, false, It.IsAny())) - .ReturnsAsync(CreateMetadata(runtimeStatus)); - - DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow()); - - // Act - DurableRunStatus status = await run.GetStatusAsync(); - - // Assert - Assert.Equal(expectedStatus, status); - } - - [Fact] - public async Task GetStatusAsync_InstanceNotFound_ReturnsNotFoundAsync() - { - // Arrange - Mock mockClient = new("test"); - mockClient.Setup(c => c.GetInstanceAsync(InstanceId, false, It.IsAny())) - .ReturnsAsync((OrchestrationMetadata?)null); - - DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow()); - - // Act - DurableRunStatus status = await run.GetStatusAsync(); - - // Assert - Assert.Equal(DurableRunStatus.NotFound, status); - } - - #endregion - - #region WatchStreamAsync - - [Fact] - public async Task WatchStreamAsync_InstanceNotFound_YieldsNoEventsAsync() - { - // Arrange - Mock mockClient = new("test"); - mockClient.Setup(c => c.GetInstanceAsync(InstanceId, true, It.IsAny())) - .ReturnsAsync((OrchestrationMetadata?)null); - - DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow()); - - // Act - List events = []; - await foreach (WorkflowEvent evt in run.WatchStreamAsync()) - { - events.Add(evt); - } - - // Assert - Assert.Empty(events); - } - - [Fact] - public async Task WatchStreamAsync_CompletedWithResult_YieldsCompletedEventAsync() - { - // Arrange - string serializedOutput = SerializeWorkflowResult("done", []); - Mock mockClient = new("test"); - mockClient.Setup(c => c.GetInstanceAsync(InstanceId, true, It.IsAny())) - .ReturnsAsync(CreateMetadata(OrchestrationRuntimeStatus.Completed, serializedOutput: serializedOutput)); - - DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow()); - - // Act - List events = []; - await foreach (WorkflowEvent evt in run.WatchStreamAsync()) - { - events.Add(evt); - } - - // Assert - Assert.Single(events); - DurableWorkflowCompletedEvent completedEvent = Assert.IsType(events[0]); - Assert.Equal("done", completedEvent.Data); - } - - [Fact] - public async Task WatchStreamAsync_CompletedWithEventsInOutput_YieldsEventsAndCompletionAsync() - { - // Arrange - DurableHaltRequestedEvent haltEvent = new("executor-1"); - string serializedEvent = SerializeEvent(haltEvent); - string serializedOutput = SerializeWorkflowResult("result", [serializedEvent]); - - Mock mockClient = new("test"); - mockClient.Setup(c => c.GetInstanceAsync(InstanceId, true, It.IsAny())) - .ReturnsAsync(CreateMetadata(OrchestrationRuntimeStatus.Completed, serializedOutput: serializedOutput)); - - DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow()); - - // Act - List events = []; - await foreach (WorkflowEvent evt in run.WatchStreamAsync()) - { - events.Add(evt); - } - - // Assert - Assert.Equal(2, events.Count); - DurableHaltRequestedEvent haltResult = Assert.IsType(events[0]); - Assert.Equal("executor-1", haltResult.ExecutorId); - DurableWorkflowCompletedEvent completedResult = Assert.IsType(events[1]); - Assert.Equal("result", completedResult.Result); - } - - [Fact] - public async Task WatchStreamAsync_CompletedWithoutWrapper_YieldsFailedEventAsync() - { - // Arrange — output not wrapped in DurableWorkflowResult (indicates a bug) - Mock mockClient = new("test"); - mockClient.Setup(c => c.GetInstanceAsync(InstanceId, true, It.IsAny())) - .ReturnsAsync(CreateMetadata(OrchestrationRuntimeStatus.Completed, serializedOutput: "\"raw output\"")); - - DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow()); - - // Act - List events = []; - await foreach (WorkflowEvent evt in run.WatchStreamAsync()) - { - events.Add(evt); - } - - // Assert — yields a failed event with diagnostic message instead of crashing - Assert.Single(events); - DurableWorkflowFailedEvent failedEvent = Assert.IsType(events[0]); - Assert.Contains("could not be parsed", failedEvent.ErrorMessage); - } - - [Fact] - public async Task WatchStreamAsync_Failed_YieldsFailedEventAsync() - { - // Arrange - Mock mockClient = new("test"); - TaskFailureDetails failureDetails = new("ErrorType", "Something went wrong", null, null, null); - mockClient.Setup(c => c.GetInstanceAsync(InstanceId, true, It.IsAny())) - .ReturnsAsync(CreateMetadata( - OrchestrationRuntimeStatus.Failed, - failureDetails: failureDetails)); - - DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow()); - - // Act - List events = []; - await foreach (WorkflowEvent evt in run.WatchStreamAsync()) - { - events.Add(evt); - } - - // Assert - Assert.Single(events); - DurableWorkflowFailedEvent failedEvent = Assert.IsType(events[0]); - Assert.Equal("Something went wrong", failedEvent.ErrorMessage); - Assert.NotNull(failedEvent.FailureDetails); - Assert.Equal("ErrorType", failedEvent.FailureDetails.ErrorType); - Assert.Equal("Something went wrong", failedEvent.FailureDetails.ErrorMessage); - } - - [Fact] - public async Task WatchStreamAsync_FailedWithNoDetails_YieldsDefaultMessageAsync() - { - // Arrange - Mock mockClient = new("test"); - mockClient.Setup(c => c.GetInstanceAsync(InstanceId, true, It.IsAny())) - .ReturnsAsync(CreateMetadata(OrchestrationRuntimeStatus.Failed)); - - DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow()); - - // Act - List events = []; - await foreach (WorkflowEvent evt in run.WatchStreamAsync()) - { - events.Add(evt); - } - - // Assert - Assert.Single(events); - DurableWorkflowFailedEvent failedEvent = Assert.IsType(events[0]); - Assert.Equal("Workflow execution failed.", failedEvent.ErrorMessage); - Assert.Null(failedEvent.FailureDetails); - } - - [Fact] - public async Task WatchStreamAsync_Terminated_YieldsFailedEventAsync() - { - // Arrange - Mock mockClient = new("test"); - mockClient.Setup(c => c.GetInstanceAsync(InstanceId, true, It.IsAny())) - .ReturnsAsync(CreateMetadata(OrchestrationRuntimeStatus.Terminated)); - - DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow()); - - // Act - List events = []; - await foreach (WorkflowEvent evt in run.WatchStreamAsync()) - { - events.Add(evt); - } - - // Assert - Assert.Single(events); - DurableWorkflowFailedEvent failedEvent = Assert.IsType(events[0]); - Assert.Equal("Workflow was terminated.", failedEvent.ErrorMessage); - Assert.Null(failedEvent.FailureDetails); - } - - [Fact] - public async Task WatchStreamAsync_EventsInCustomStatus_YieldsEventsBeforeCompletionAsync() - { - // Arrange - DurableHaltRequestedEvent haltEvent = new("exec-1"); - string serializedEvent = SerializeEvent(haltEvent); - string customStatus = SerializeCustomStatus([serializedEvent]); - string serializedOutput = SerializeWorkflowResult("final", []); - - int callCount = 0; - Mock mockClient = new("test"); - mockClient.Setup(c => c.GetInstanceAsync(InstanceId, true, It.IsAny())) - .ReturnsAsync(() => - { - callCount++; - if (callCount == 1) - { - return CreateMetadata(OrchestrationRuntimeStatus.Running, serializedCustomStatus: customStatus); - } - - return CreateMetadata(OrchestrationRuntimeStatus.Completed, serializedOutput: serializedOutput); - }); - - DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow()); - - // Act - List events = []; - await foreach (WorkflowEvent evt in run.WatchStreamAsync()) - { - events.Add(evt); - } - - // Assert - Assert.Equal(2, events.Count); - DurableHaltRequestedEvent haltResult = Assert.IsType(events[0]); - Assert.Equal("exec-1", haltResult.ExecutorId); - DurableWorkflowCompletedEvent completedResult = Assert.IsType(events[1]); - Assert.Equal("final", completedResult.Result); - } - - [Fact] - public async Task WatchStreamAsync_EventTypeNameHasMutatedAssemblyVersion_StillDeserializesAsync() - { - // Arrange — model what happens after a package upgrade: the persisted TypedPayload.TypeName - // carries an assembly Version= that no longer matches any loaded assembly. - DurableHaltRequestedEvent haltEvent = new("exec-1"); - Type eventType = haltEvent.GetType(); - string outerSimpleName = eventType.Assembly.GetName().Name!; - string mutatedTypeName = $"{eventType.FullName}, {outerSimpleName}, Version=99.0.0.0, Culture=neutral, PublicKeyToken=null"; - TypedPayload wrapper = new() - { - TypeName = mutatedTypeName, - Data = JsonSerializer.Serialize(haltEvent, eventType, DurableSerialization.Options) - }; - string serializedEvent = JsonSerializer.Serialize(wrapper, DurableWorkflowJsonContext.Default.TypedPayload); - string customStatus = SerializeCustomStatus([serializedEvent]); - string serializedOutput = SerializeWorkflowResult("final", []); - - int callCount = 0; - Mock mockClient = new("test"); - mockClient.Setup(c => c.GetInstanceAsync(InstanceId, true, It.IsAny())) - .ReturnsAsync(() => - { - callCount++; - if (callCount == 1) - { - return CreateMetadata(OrchestrationRuntimeStatus.Running, serializedCustomStatus: customStatus); - } - - return CreateMetadata(OrchestrationRuntimeStatus.Completed, serializedOutput: serializedOutput); - }); - - DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow()); - - // Act - List events = []; - await foreach (WorkflowEvent evt in run.WatchStreamAsync()) - { - events.Add(evt); - } - - // Assert - Assert.Equal(2, events.Count); - DurableHaltRequestedEvent haltResult = Assert.IsType(events[0]); - Assert.Equal("exec-1", haltResult.ExecutorId); - DurableWorkflowCompletedEvent completedResult = Assert.IsType(events[1]); - Assert.Equal("final", completedResult.Result); - } - - [Fact] - public async Task WatchStreamAsync_IncrementalEvents_YieldsOnlyNewEventsPerPollAsync() - { - // Arrange — simulate 3 poll cycles where events accumulate in custom status, - // then a final completion poll. This validates: - // 1. Events arriving across multiple poll cycles are yielded incrementally - // 2. Already-seen events are not re-yielded (lastReadEventIndex dedup) - // 3. Completion event follows all streamed events - DurableHaltRequestedEvent event1 = new("executor-1"); - DurableHaltRequestedEvent event2 = new("executor-2"); - DurableHaltRequestedEvent event3 = new("executor-3"); - - string serializedEvent1 = SerializeEvent(event1); - string serializedEvent2 = SerializeEvent(event2); - string serializedEvent3 = SerializeEvent(event3); - - // Poll 1: 1 event in custom status - string customStatus1 = SerializeCustomStatus([serializedEvent1]); - // Poll 2: same event + 1 new event (accumulating list) - string customStatus2 = SerializeCustomStatus([serializedEvent1, serializedEvent2]); - // Poll 3: all 3 events accumulated - string customStatus3 = SerializeCustomStatus([serializedEvent1, serializedEvent2, serializedEvent3]); - // Poll 4: completed, all events also in output - string serializedOutput = SerializeWorkflowResult("done", [serializedEvent1, serializedEvent2, serializedEvent3]); - - int callCount = 0; - Mock mockClient = new("test"); - mockClient.Setup(c => c.GetInstanceAsync(InstanceId, true, It.IsAny())) - .ReturnsAsync(() => - { - callCount++; - return callCount switch - { - 1 => CreateMetadata(OrchestrationRuntimeStatus.Running, serializedCustomStatus: customStatus1), - 2 => CreateMetadata(OrchestrationRuntimeStatus.Running, serializedCustomStatus: customStatus2), - 3 => CreateMetadata(OrchestrationRuntimeStatus.Running, serializedCustomStatus: customStatus3), - _ => CreateMetadata(OrchestrationRuntimeStatus.Completed, serializedOutput: serializedOutput), - }; - }); - - DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow()); - - // Act - List events = []; - await foreach (WorkflowEvent evt in run.WatchStreamAsync()) - { - events.Add(evt); - } - - // Assert — exactly 4 events: 3 incremental halt events + 1 completion - Assert.Equal(4, events.Count); - DurableHaltRequestedEvent halt1 = Assert.IsType(events[0]); - DurableHaltRequestedEvent halt2 = Assert.IsType(events[1]); - DurableHaltRequestedEvent halt3 = Assert.IsType(events[2]); - Assert.Equal("executor-1", halt1.ExecutorId); - Assert.Equal("executor-2", halt2.ExecutorId); - Assert.Equal("executor-3", halt3.ExecutorId); - DurableWorkflowCompletedEvent completed = Assert.IsType(events[3]); - Assert.Equal("done", completed.Data); - } - - [Fact] - public async Task WatchStreamAsync_NoNewEventsOnRepoll_DoesNotDuplicateAsync() - { - // Arrange — simulate polling where custom status doesn't change between polls, - // validating that events are not duplicated when the list is unchanged. - DurableHaltRequestedEvent event1 = new("executor-1"); - string serializedEvent1 = SerializeEvent(event1); - string customStatus = SerializeCustomStatus([serializedEvent1]); - string serializedOutput = SerializeWorkflowResult("result", [serializedEvent1]); - - int callCount = 0; - Mock mockClient = new("test"); - mockClient.Setup(c => c.GetInstanceAsync(InstanceId, true, It.IsAny())) - .ReturnsAsync(() => - { - callCount++; - return callCount switch - { - // First 3 polls return the same custom status (no new events after first) - <= 3 => CreateMetadata(OrchestrationRuntimeStatus.Running, serializedCustomStatus: customStatus), - _ => CreateMetadata(OrchestrationRuntimeStatus.Completed, serializedOutput: serializedOutput), - }; - }); - - DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow()); - - // Act - List events = []; - await foreach (WorkflowEvent evt in run.WatchStreamAsync()) - { - events.Add(evt); - } - - // Assert — event1 appears exactly once despite 3 polls with the same status - Assert.Equal(2, events.Count); - DurableHaltRequestedEvent haltResult = Assert.IsType(events[0]); - Assert.Equal("executor-1", haltResult.ExecutorId); - DurableWorkflowCompletedEvent completedResult = Assert.IsType(events[1]); - Assert.Equal("result", completedResult.Result); - } - - [Fact] - public async Task WatchStreamAsync_Cancellation_EndsGracefullyAsync() - { - // Arrange - using CancellationTokenSource cts = new(); - int pollCount = 0; - Mock mockClient = new("test"); - mockClient.Setup(c => c.GetInstanceAsync(InstanceId, true, It.IsAny())) - .ReturnsAsync(() => - { - if (++pollCount >= 2) - { - cts.Cancel(); - } - - return CreateMetadata(OrchestrationRuntimeStatus.Running); - }); - - DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow()); - - // Act - List events = []; - await foreach (WorkflowEvent evt in run.WatchStreamAsync(cts.Token)) - { - events.Add(evt); - } - - // Assert — no exception thrown, stream ends cleanly - Assert.Empty(events); - } - - [Fact] - public async Task WatchStreamAsync_PendingRequestPort_YieldsWaitingForInputEventAsync() - { - // Arrange - string customStatus = SerializeCustomStatusWithPendingEvents( - [], - [new PendingRequestPortStatus("ApprovalPort", """{"amount":100}""")]); - string serializedOutput = SerializeWorkflowResult("approved", []); - - int callCount = 0; - Mock mockClient = new("test"); - mockClient.Setup(c => c.GetInstanceAsync(InstanceId, true, It.IsAny())) - .ReturnsAsync(() => - { - callCount++; - return callCount == 1 - ? CreateMetadata(OrchestrationRuntimeStatus.Running, serializedCustomStatus: customStatus) - : CreateMetadata(OrchestrationRuntimeStatus.Completed, serializedOutput: serializedOutput); - }); - - Workflow workflow = CreateTestWorkflowWithRequestPort("ApprovalPort"); - DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, workflow); - - // Act - List events = []; - await foreach (WorkflowEvent evt in run.WatchStreamAsync()) - { - events.Add(evt); - } - - // Assert - Assert.Equal(2, events.Count); - DurableWorkflowWaitingForInputEvent waitingEvent = Assert.IsType(events[0]); - Assert.Equal("ApprovalPort", waitingEvent.RequestPort.Id); - Assert.Contains("amount", waitingEvent.Input); - DurableWorkflowCompletedEvent completedEvent = Assert.IsType(events[1]); - Assert.Equal("approved", completedEvent.Result); - } - - [Fact] - public async Task WatchStreamAsync_PendingRequestPort_DoesNotDuplicateOnSubsequentPollsAsync() - { - // Arrange — same pending event across 2 polls, then completion - string customStatus = SerializeCustomStatusWithPendingEvents( - [], - [new PendingRequestPortStatus("ApprovalPort", """{"amount":100}""")]); - string serializedOutput = SerializeWorkflowResult("done", []); - - int callCount = 0; - Mock mockClient = new("test"); - mockClient.Setup(c => c.GetInstanceAsync(InstanceId, true, It.IsAny())) - .ReturnsAsync(() => - { - callCount++; - return callCount switch - { - <= 2 => CreateMetadata(OrchestrationRuntimeStatus.Running, serializedCustomStatus: customStatus), - _ => CreateMetadata(OrchestrationRuntimeStatus.Completed, serializedOutput: serializedOutput), - }; - }); - - Workflow workflow = CreateTestWorkflowWithRequestPort("ApprovalPort"); - DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, workflow); - - // Act - List events = []; - await foreach (WorkflowEvent evt in run.WatchStreamAsync()) - { - events.Add(evt); - } - - // Assert — WaitingForInputEvent yielded only once despite 2 polls - Assert.Equal(2, events.Count); - Assert.IsType(events[0]); - Assert.IsType(events[1]); - } - - #endregion - - #region SendResponseAsync - - [Fact] - public async Task SendResponseAsync_SerializesAndRaisesEventAsync() - { - // Arrange - Mock mockClient = new("test"); - mockClient.Setup(c => c.RaiseEventAsync( - InstanceId, - "ApprovalPort", - It.IsAny(), - It.IsAny())) - .Returns(Task.CompletedTask); - - RequestPort approvalPort = RequestPort.Create("ApprovalPort"); - DurableWorkflowWaitingForInputEvent requestEvent = new("""{"amount":100}""", approvalPort); - Workflow workflow = CreateTestWorkflowWithRequestPort("ApprovalPort"); - DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, workflow); - - // Act - await run.SendResponseAsync(requestEvent, new { approved = true, comments = "Looks good" }); - - // Assert - mockClient.Verify(c => c.RaiseEventAsync( - InstanceId, - "ApprovalPort", - It.Is(s => s.Contains("approved") && s.Contains("true")), - It.IsAny()), Times.Once); - } - - [Fact] - public async Task SendResponseAsync_NullRequestEvent_ThrowsAsync() - { - // Arrange - Mock mockClient = new("test"); - DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow()); - - // Act & Assert - await Assert.ThrowsAsync(() => - run.SendResponseAsync(null!, "response").AsTask()); - } - - #endregion - - #region WaitForCompletionAsync - - [Fact] - public async Task WaitForCompletionAsync_Completed_ReturnsResultAsync() - { - // Arrange - string serializedOutput = SerializeWorkflowResult("hello world", []); - Mock mockClient = new("test"); - mockClient.Setup(c => c.WaitForInstanceCompletionAsync(InstanceId, true, It.IsAny())) - .ReturnsAsync(CreateMetadata(OrchestrationRuntimeStatus.Completed, serializedOutput: serializedOutput)); - - DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow()); - - // Act - string? result = await run.WaitForCompletionAsync(); - - // Assert - Assert.Equal("hello world", result); - } - - [Fact] - public async Task WaitForCompletionAsync_Failed_ThrowsTaskFailedExceptionAsync() - { - // Arrange - Mock mockClient = new("test"); - mockClient.Setup(c => c.WaitForInstanceCompletionAsync(InstanceId, true, It.IsAny())) - .ReturnsAsync(CreateMetadata( - OrchestrationRuntimeStatus.Failed, - failureDetails: new TaskFailureDetails("Error", "kaboom", null, null, null))); - - DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow()); - - // Act & Assert - TaskFailedException ex = await Assert.ThrowsAsync( - () => run.WaitForCompletionAsync().AsTask()); - Assert.Equal("kaboom", ex.FailureDetails.ErrorMessage); - } - - [Fact] - public async Task WaitForCompletionAsync_UnexpectedStatus_ThrowsAsync() - { - // Arrange - Mock mockClient = new("test"); - mockClient.Setup(c => c.WaitForInstanceCompletionAsync(InstanceId, true, It.IsAny())) - .ReturnsAsync(CreateMetadata(OrchestrationRuntimeStatus.Terminated)); - - DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow()); - - // Act & Assert - await Assert.ThrowsAsync( - () => run.WaitForCompletionAsync().AsTask()); - } - - #endregion - - #region ExtractResult - - [Fact] - public void ExtractResult_NullOutput_ReturnsDefault() - { - // Act - string? result = DurableStreamingWorkflowRun.ExtractResult(null); - - // Assert - Assert.Null(result); - } - - [Fact] - public void ExtractResult_WrappedStringResult_ReturnsUnwrappedString() - { - // Arrange - string serializedOutput = SerializeWorkflowResult("hello", []); - - // Act - string? result = DurableStreamingWorkflowRun.ExtractResult(serializedOutput); - - // Assert - Assert.Equal("hello", result); - } - - [Fact] - public void ExtractResult_UnwrappedOutput_ThrowsInvalidOperationException() - { - // Arrange — raw output not wrapped in DurableWorkflowResult - string serializedOutput = JsonSerializer.Serialize("raw value"); - - // Act & Assert - Assert.Throws( - () => DurableStreamingWorkflowRun.ExtractResult(serializedOutput)); - } - - [Fact] - public void ExtractResult_WrappedObjectResult_DeserializesCorrectly() - { - // Arrange - TestPayload original = new() { Name = "test", Value = 42 }; - string resultJson = JsonSerializer.Serialize(original); - string serializedOutput = SerializeWorkflowResult(resultJson, []); - - // Act - TestPayload? result = DurableStreamingWorkflowRun.ExtractResult(serializedOutput); - - // Assert - Assert.NotNull(result); - Assert.Equal("test", result.Name); - Assert.Equal(42, result.Value); - } - - [Fact] - public void ExtractResult_CamelCaseSerializedObject_DeserializesToPascalCaseMembers() - { - // Arrange — executor outputs are serialized with DurableSerialization.Options (camelCase) - TestPayload original = new() { Name = "camel", Value = 99 }; - string resultJson = JsonSerializer.Serialize(original, DurableSerialization.Options); - string serializedOutput = SerializeWorkflowResult(resultJson, []); - - // Act - TestPayload? result = DurableStreamingWorkflowRun.ExtractResult(serializedOutput); - - // Assert - Assert.NotNull(result); - Assert.Equal("camel", result.Name); - Assert.Equal(99, result.Value); - } - - #endregion - - private sealed class TestPayload - { - public string? Name { get; set; } - - public int Value { get; set; } - } -} diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableTaskTypeResolverTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableTaskTypeResolverTests.cs deleted file mode 100644 index 624042fc765..00000000000 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableTaskTypeResolverTests.cs +++ /dev/null @@ -1,75 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Agents.AI.DurableTask.Workflows; -using Microsoft.Extensions.AI; - -namespace Microsoft.Agents.AI.DurableTask.UnitTests.Workflows; - -/// -/// Verifies that resolves persisted -/// assembly-qualified type-name strings to a loaded across assembly -/// version, culture, and public key token mutations. -/// -public sealed class DurableTaskTypeResolverTests -{ - [Fact] - public void Resolve_LoadedAssemblyQualifiedName_ReturnsLiveType() - { - Type live = typeof(List); - string aqn = live.AssemblyQualifiedName!; - - Type? resolved = DurableTaskTypeResolver.Resolve(aqn); - - Assert.Same(live, resolved); - } - - [Fact] - public void Resolve_MutatedOuterAssemblyVersion_ReturnsLiveType() - { - Type live = typeof(ChatMessage); - string outerSimpleName = live.Assembly.GetName().Name!; - string mutated = $"{live.FullName}, {outerSimpleName}, Version=99.0.0.0, Culture=neutral, PublicKeyToken=null"; - - Type? resolved = DurableTaskTypeResolver.Resolve(mutated); - - Assert.Same(live, resolved); - } - - [Fact] - public void Resolve_MutatedGenericArgumentVersion_ReturnsLiveType() - { - Type live = typeof(List); - string outerSimpleName = live.Assembly.GetName().Name!; - string innerSimpleName = typeof(ChatMessage).Assembly.GetName().Name!; - string mutated = - $"System.Collections.Generic.List`1[[Microsoft.Extensions.AI.ChatMessage, {innerSimpleName}, " + - "Version=99.0.0.0, Culture=neutral, PublicKeyToken=null]], " + - $"{outerSimpleName}, Version=99.0.0.0, Culture=neutral, PublicKeyToken=null"; - - Type? resolved = DurableTaskTypeResolver.Resolve(mutated); - - Assert.Same(live, resolved); - } - - [Fact] - public void Resolve_UnknownType_ReturnsNull() - { - Type? resolved = DurableTaskTypeResolver.Resolve( - "Some.Unknown.Namespace.MissingType, Some.Unloaded.Assembly, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"); - - Assert.Null(resolved); - } - - [Fact] - public void Resolve_CachesResults() - { - Type live = typeof(ChatMessage); - string aqn = live.AssemblyQualifiedName!; - - Type? first = DurableTaskTypeResolver.Resolve(aqn); - Type? second = DurableTaskTypeResolver.Resolve(aqn); - - Assert.Same(first, second); - Assert.Same(live, second); - } -} diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableWorkflowContextTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableWorkflowContextTests.cs deleted file mode 100644 index 4ceba544a28..00000000000 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableWorkflowContextTests.cs +++ /dev/null @@ -1,504 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Agents.AI.DurableTask.Workflows; -using Microsoft.Agents.AI.Workflows; - -namespace Microsoft.Agents.AI.DurableTask.UnitTests.Workflows; - -public sealed class DurableWorkflowContextTests -{ - private static FunctionExecutor CreateTestExecutor(string id = "test-executor") - => new(id, (_, _, _) => default, outputTypes: [typeof(string)]); - - #region ReadStateAsync - - [Fact] - public async Task ReadStateAsync_KeyExistsInInitialState_ReturnsValueAsync() - { - // Arrange - Dictionary state = new() { ["__default__:counter"] = "42" }; - DurableWorkflowContext context = new(state, CreateTestExecutor()); - - // Act - int? result = await context.ReadStateAsync("counter"); - - // Assert - Assert.Equal(42, result); - } - - [Fact] - public async Task ReadStateAsync_KeyDoesNotExist_ReturnsNullAsync() - { - // Arrange - DurableWorkflowContext context = new(null, CreateTestExecutor()); - - // Act - string? result = await context.ReadStateAsync("missing"); - - // Assert - Assert.Null(result); - } - - [Fact] - public async Task ReadStateAsync_LocalUpdateTakesPriorityOverInitialStateAsync() - { - // Arrange - Dictionary state = new() { ["__default__:key"] = "\"old\"" }; - DurableWorkflowContext context = new(state, CreateTestExecutor()); - await context.QueueStateUpdateAsync("key", "new"); - - // Act - string? result = await context.ReadStateAsync("key"); - - // Assert - Assert.Equal("new", result); - } - - [Fact] - public async Task ReadStateAsync_ScopeCleared_IgnoresInitialStateAsync() - { - // Arrange - Dictionary state = new() { ["__default__:key"] = "\"value\"" }; - DurableWorkflowContext context = new(state, CreateTestExecutor()); - await context.QueueClearScopeAsync(); - - // Act - string? result = await context.ReadStateAsync("key"); - - // Assert - Assert.Null(result); - } - - [Fact] - public async Task ReadStateAsync_WithNamedScope_ReadsFromCorrectScopeAsync() - { - // Arrange - Dictionary state = new() - { - ["scopeA:key"] = "\"fromA\"", - ["scopeB:key"] = "\"fromB\"" - }; - DurableWorkflowContext context = new(state, CreateTestExecutor()); - - // Act - string? resultA = await context.ReadStateAsync("key", "scopeA"); - string? resultB = await context.ReadStateAsync("key", "scopeB"); - - // Assert - Assert.Equal("fromA", resultA); - Assert.Equal("fromB", resultB); - } - - [Theory] - [InlineData(null)] - [InlineData("")] - public async Task ReadStateAsync_NullOrEmptyKey_ThrowsArgumentExceptionAsync(string? key) - { - // Arrange - DurableWorkflowContext context = new(null, CreateTestExecutor()); - - // Act & Assert - await Assert.ThrowsAnyAsync(() => context.ReadStateAsync(key!).AsTask()); - } - - #endregion - - #region ReadOrInitStateAsync - - [Fact] - public async Task ReadOrInitStateAsync_KeyDoesNotExist_CallsFactoryAndQueuesUpdateAsync() - { - // Arrange - DurableWorkflowContext context = new(null, CreateTestExecutor()); - - // Act - string result = await context.ReadOrInitStateAsync("key", () => "initialized"); - - // Assert - Assert.Equal("initialized", result); - Assert.True(context.StateUpdates.ContainsKey("__default__:key")); - } - - [Fact] - public async Task ReadOrInitStateAsync_KeyExists_ReturnsExistingValueAsync() - { - // Arrange - Dictionary state = new() { ["__default__:key"] = "\"existing\"" }; - DurableWorkflowContext context = new(state, CreateTestExecutor()); - bool factoryCalled = false; - - // Act - string result = await context.ReadOrInitStateAsync("key", () => - { - factoryCalled = true; - return "should-not-be-used"; - }); - - // Assert - Assert.Equal("existing", result); - Assert.False(factoryCalled); - } - - [Theory] - [InlineData(null)] - [InlineData("")] - public async Task ReadOrInitStateAsync_NullOrEmptyKey_ThrowsArgumentExceptionAsync(string? key) - { - // Arrange - DurableWorkflowContext context = new(null, CreateTestExecutor()); - - // Act & Assert - await Assert.ThrowsAnyAsync( - () => context.ReadOrInitStateAsync(key!, () => "value").AsTask()); - } - - [Fact] - public async Task ReadOrInitStateAsync_ValueType_MissingKey_CallsFactoryAsync() - { - // Arrange - // Validates that ReadStateAsync returns null (not 0) for missing keys, - // because the return type is int? (Nullable). This ensures the factory - // is correctly invoked for value types when the key does not exist. - DurableWorkflowContext context = new(null, CreateTestExecutor()); - - // Act - int result = await context.ReadOrInitStateAsync("counter", () => 42); - - // Assert - Assert.Equal(42, result); - Assert.True(context.StateUpdates.ContainsKey("__default__:counter")); - } - - [Fact] - public async Task ReadOrInitStateAsync_NullFactory_ThrowsArgumentNullExceptionAsync() - { - // Arrange - DurableWorkflowContext context = new(null, CreateTestExecutor()); - - // Act & Assert - await Assert.ThrowsAsync( - () => context.ReadOrInitStateAsync("key", null!).AsTask()); - } - - #endregion - - #region QueueStateUpdateAsync - - [Fact] - public async Task QueueStateUpdateAsync_SetsValue_VisibleToSubsequentReadAsync() - { - // Arrange - DurableWorkflowContext context = new(null, CreateTestExecutor()); - - // Act - await context.QueueStateUpdateAsync("key", "hello"); - string? result = await context.ReadStateAsync("key"); - - // Assert - Assert.Equal("hello", result); - } - - [Fact] - public async Task QueueStateUpdateAsync_NullValue_RecordsDeletionAsync() - { - // Arrange - Dictionary state = new() { ["__default__:key"] = "\"value\"" }; - DurableWorkflowContext context = new(state, CreateTestExecutor()); - - // Act - await context.QueueStateUpdateAsync("key", null); - - // Assert - Assert.True(context.StateUpdates.ContainsKey("__default__:key")); - Assert.Null(context.StateUpdates["__default__:key"]); - } - - [Theory] - [InlineData(null)] - [InlineData("")] - public async Task QueueStateUpdateAsync_NullOrEmptyKey_ThrowsArgumentExceptionAsync(string? key) - { - // Arrange - DurableWorkflowContext context = new(null, CreateTestExecutor()); - - // Act & Assert - await Assert.ThrowsAnyAsync( - () => context.QueueStateUpdateAsync(key!, "value").AsTask()); - } - - #endregion - - #region QueueClearScopeAsync - - [Fact] - public async Task QueueClearScopeAsync_DefaultScope_ClearsStateAndPendingUpdatesAsync() - { - // Arrange - Dictionary state = new() { ["__default__:key"] = "\"value\"" }; - DurableWorkflowContext context = new(state, CreateTestExecutor()); - await context.QueueStateUpdateAsync("pending", "data"); - - // Act - await context.QueueClearScopeAsync(); - - // Assert - Assert.Contains("__default__", context.ClearedScopes); - Assert.Empty(context.StateUpdates); - } - - [Fact] - public async Task QueueClearScopeAsync_NamedScope_OnlyClearsThatScopeAsync() - { - // Arrange - DurableWorkflowContext context = new(null, CreateTestExecutor()); - await context.QueueStateUpdateAsync("keyA", "valueA", scopeName: "scopeA"); - await context.QueueStateUpdateAsync("keyB", "valueB", scopeName: "scopeB"); - - // Act - await context.QueueClearScopeAsync("scopeA"); - - // Assert - Assert.DoesNotContain("scopeA:keyA", context.StateUpdates.Keys); - Assert.Contains("scopeB:keyB", context.StateUpdates.Keys); - } - - #endregion - - #region ReadStateKeysAsync - - [Fact] - public async Task ReadStateKeysAsync_ReturnsKeysFromInitialStateAsync() - { - // Arrange - Dictionary state = new() - { - ["__default__:alpha"] = "\"a\"", - ["__default__:beta"] = "\"b\"" - }; - DurableWorkflowContext context = new(state, CreateTestExecutor()); - - // Act - HashSet keys = await context.ReadStateKeysAsync(); - - // Assert - Assert.Equal(2, keys.Count); - Assert.Contains("alpha", keys); - Assert.Contains("beta", keys); - } - - [Fact] - public async Task ReadStateKeysAsync_MergesLocalUpdatesAndDeletionsAsync() - { - // Arrange - Dictionary state = new() - { - ["__default__:existing"] = "\"val\"", - ["__default__:toDelete"] = "\"val\"" - }; - DurableWorkflowContext context = new(state, CreateTestExecutor()); - await context.QueueStateUpdateAsync("newKey", "value"); - await context.QueueStateUpdateAsync("toDelete", null); - - // Act - HashSet keys = await context.ReadStateKeysAsync(); - - // Assert - Assert.Contains("existing", keys); - Assert.Contains("newKey", keys); - Assert.DoesNotContain("toDelete", keys); - } - - [Fact] - public async Task ReadStateKeysAsync_AfterClearScope_ExcludesInitialStateAsync() - { - // Arrange - Dictionary state = new() { ["__default__:old"] = "\"val\"" }; - DurableWorkflowContext context = new(state, CreateTestExecutor()); - await context.QueueClearScopeAsync(); - await context.QueueStateUpdateAsync("new", "value"); - - // Act - HashSet keys = await context.ReadStateKeysAsync(); - - // Assert - Assert.DoesNotContain("old", keys); - Assert.Contains("new", keys); - } - - [Fact] - public async Task ReadStateKeysAsync_WithNamedScope_OnlyReturnsKeysFromThatScopeAsync() - { - // Arrange - Dictionary state = new() - { - ["scopeA:key1"] = "\"val\"", - ["scopeB:key2"] = "\"val\"" - }; - DurableWorkflowContext context = new(state, CreateTestExecutor()); - - // Act - HashSet keysA = await context.ReadStateKeysAsync("scopeA"); - - // Assert - Assert.Single(keysA); - Assert.Contains("key1", keysA); - } - - #endregion - - #region AddEventAsync - - [Fact] - public async Task AddEventAsync_AddsEventToCollectionAsync() - { - // Arrange - DurableWorkflowContext context = new(null, CreateTestExecutor()); - WorkflowEvent evt = new ExecutorInvokedEvent("test", "test-data"); - - // Act - await context.AddEventAsync(evt); - - // Assert - Assert.Single(context.OutboundEvents); - Assert.Same(evt, context.OutboundEvents[0]); - } - - [Fact] - public async Task AddEventAsync_NullEvent_DoesNotAddAsync() - { - // Arrange - DurableWorkflowContext context = new(null, CreateTestExecutor()); - - // Act -#pragma warning disable CS8625 // Cannot convert null literal to non-nullable reference type. - await context.AddEventAsync(null); -#pragma warning restore CS8625 - - // Assert - Assert.Empty(context.OutboundEvents); - } - - #endregion - - #region SendMessageAsync - - [Fact] - public async Task SendMessageAsync_SerializesMessageWithTypeNameAsync() - { - // Arrange - DurableWorkflowContext context = new(null, CreateTestExecutor()); - - // Act - await context.SendMessageAsync("hello"); - - // Assert - Assert.Single(context.SentMessages); - Assert.Equal(typeof(string).AssemblyQualifiedName, context.SentMessages[0].TypeName); - Assert.NotNull(context.SentMessages[0].Data); - } - - [Fact] - public async Task SendMessageAsync_NullMessage_DoesNotAddAsync() - { - // Arrange - DurableWorkflowContext context = new(null, CreateTestExecutor()); - - // Act -#pragma warning disable CS8625 // Cannot convert null literal to non-nullable reference type. - await context.SendMessageAsync(null); -#pragma warning restore CS8625 - - // Assert - Assert.Empty(context.SentMessages); - } - - #endregion - - #region YieldOutputAsync - - [Fact] - public async Task YieldOutputAsync_AddsWorkflowOutputEventAsync() - { - // Arrange - DurableWorkflowContext context = new(null, CreateTestExecutor()); - - // Act - await context.YieldOutputAsync("result"); - - // Assert - Assert.Single(context.OutboundEvents); - WorkflowOutputEvent outputEvent = Assert.IsType(context.OutboundEvents[0]); - Assert.Equal("result", outputEvent.Data); - } - - [Fact] - public async Task YieldOutputAsync_NullOutput_DoesNotAddAsync() - { - // Arrange - DurableWorkflowContext context = new(null, CreateTestExecutor()); - - // Act -#pragma warning disable CS8625 // Cannot convert null literal to non-nullable reference type. - await context.YieldOutputAsync(null); -#pragma warning restore CS8625 - - // Assert - Assert.Empty(context.OutboundEvents); - } - - #endregion - - #region RequestHaltAsync - - [Fact] - public async Task RequestHaltAsync_SetsHaltRequestedAndAddsEventAsync() - { - // Arrange - DurableWorkflowContext context = new(null, CreateTestExecutor()); - - // Act - await context.RequestHaltAsync(); - - // Assert - Assert.True(context.HaltRequested); - Assert.Single(context.OutboundEvents); - Assert.IsType(context.OutboundEvents[0]); - } - - #endregion - - #region Properties - - [Fact] - public void TraceContext_ReturnsNull() - { - // Arrange - DurableWorkflowContext context = new(null, CreateTestExecutor()); - - // Assert - Assert.Null(context.TraceContext); - } - - [Fact] - public void ConcurrentRunsEnabled_ReturnsFalse() - { - // Arrange - DurableWorkflowContext context = new(null, CreateTestExecutor()); - - // Assert - Assert.False(context.ConcurrentRunsEnabled); - } - - [Fact] - public async Task Constructor_NullInitialState_CreatesEmptyStateAsync() - { - // Arrange & Act - DurableWorkflowContext context = new(null, CreateTestExecutor()); - - // Assert - string? result = await context.ReadStateAsync("anything"); - Assert.Null(result); - } - - #endregion -} diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/WorkflowNamingHelperTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/WorkflowNamingHelperTests.cs deleted file mode 100644 index 780cf1275d1..00000000000 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/WorkflowNamingHelperTests.cs +++ /dev/null @@ -1,90 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Agents.AI.DurableTask.Workflows; - -namespace Microsoft.Agents.AI.DurableTask.UnitTests.Workflows; - -public sealed class WorkflowNamingHelperTests -{ - [Fact] - public void ToOrchestrationFunctionName_ValidWorkflowName_ReturnsPrefixedName() - { - string result = WorkflowNamingHelper.ToOrchestrationFunctionName("MyWorkflow"); - - Assert.Equal("dafx-MyWorkflow", result); - } - - [Theory] - [InlineData(null)] - [InlineData("")] - public void ToOrchestrationFunctionName_NullOrEmpty_ThrowsArgumentException(string? workflowName) - { - Assert.ThrowsAny(() => WorkflowNamingHelper.ToOrchestrationFunctionName(workflowName!)); - } - - [Fact] - public void ToWorkflowName_ValidOrchestrationFunctionName_ReturnsWorkflowName() - { - string result = WorkflowNamingHelper.ToWorkflowName("dafx-MyWorkflow"); - - Assert.Equal("MyWorkflow", result); - } - - [Theory] - [InlineData(null)] - [InlineData("")] - public void ToWorkflowName_NullOrEmpty_ThrowsArgumentException(string? orchestrationFunctionName) - { - Assert.ThrowsAny(() => WorkflowNamingHelper.ToWorkflowName(orchestrationFunctionName!)); - } - - [Theory] - [InlineData("MyWorkflow")] - [InlineData("invalid-prefix-MyWorkflow")] - [InlineData("dafx")] - [InlineData("dafx-")] - public void ToWorkflowName_InvalidOrMissingPrefix_ThrowsArgumentException(string orchestrationFunctionName) - { - Assert.Throws(() => WorkflowNamingHelper.ToWorkflowName(orchestrationFunctionName)); - } - - [Fact] - public void GetExecutorName_SimpleExecutorId_ReturnsSameName() - { - string result = WorkflowNamingHelper.GetExecutorName("OrderParser"); - - Assert.Equal("OrderParser", result); - } - - [Fact] - public void GetExecutorName_ExecutorIdWithGuidSuffix_ReturnsNameWithoutSuffix() - { - string result = WorkflowNamingHelper.GetExecutorName("Physicist_8884e71021334ce49517fa2b17b1695b"); - - Assert.Equal("Physicist", result); - } - - [Fact] - public void GetExecutorName_NameWithUnderscoresAndGuidSuffix_ReturnsFullName() - { - string result = WorkflowNamingHelper.GetExecutorName("my_agent_8884e71021334ce49517fa2b17b1695b"); - - Assert.Equal("my_agent", result); - } - - [Fact] - public void GetExecutorName_NameWithUnderscoreButNoGuidSuffix_ReturnsSameName() - { - string result = WorkflowNamingHelper.GetExecutorName("my_custom_executor"); - - Assert.Equal("my_custom_executor", result); - } - - [Theory] - [InlineData(null)] - [InlineData("")] - public void GetExecutorName_NullOrEmpty_ThrowsArgumentException(string? executorId) - { - Assert.ThrowsAny(() => WorkflowNamingHelper.GetExecutorName(executorId!)); - } -} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/AzureFunctionsTestHelper.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/AzureFunctionsTestHelper.cs deleted file mode 100644 index b4150e6a58f..00000000000 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/AzureFunctionsTestHelper.cs +++ /dev/null @@ -1,117 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Diagnostics; - -namespace Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests; - -/// -/// Shared test helpers for Azure Functions integration tests. -/// -internal static class AzureFunctionsTestHelper -{ - private static readonly TimeSpan s_buildTimeout = TimeSpan.FromMinutes(5); - - /// - /// Builds the sample project, failing fast if the build fails or times out. - /// - internal static async Task BuildSampleAsync( - string samplePath, - string buildArgs, - ITestOutputHelper outputHelper) - { - outputHelper.WriteLine($"Building sample at {samplePath}..."); - - ProcessStartInfo buildInfo = new() - { - FileName = "dotnet", - Arguments = $"build {buildArgs}", - WorkingDirectory = samplePath, - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - }; - - using Process buildProcess = new() { StartInfo = buildInfo }; - buildProcess.Start(); - - // Read both streams asynchronously to avoid deadlocks from filled pipe buffers - Task stdoutTask = buildProcess.StandardOutput.ReadToEndAsync(); - Task stderrTask = buildProcess.StandardError.ReadToEndAsync(); - - using CancellationTokenSource buildCts = new(s_buildTimeout); - try - { - await buildProcess.WaitForExitAsync(buildCts.Token); - } - catch (OperationCanceledException) - { - buildProcess.Kill(entireProcessTree: true); - throw new TimeoutException($"Build timed out after {s_buildTimeout.TotalMinutes} minutes for sample at {samplePath}."); - } - - await Task.WhenAll(stdoutTask, stderrTask); - - string stdout = stdoutTask.Result; - string stderr = stderrTask.Result; - if (buildProcess.ExitCode != 0) - { - throw new InvalidOperationException($"Failed to build sample at {samplePath}:\n{stdout}\n{stderr}"); - } - - outputHelper.WriteLine($"Build completed for {samplePath}."); - } - - /// - /// Polls the Azure Functions host until it responds to an HTTP HEAD request, - /// failing fast if the host process exits unexpectedly. - /// - internal static async Task WaitForFunctionsReadyAsync( - Process funcProcess, - string port, - HttpClient httpClient, - ITestOutputHelper outputHelper, - TimeSpan timeout, - string? samplePath = null) - { - outputHelper.WriteLine( - $"Waiting for Azure Functions Core Tools to be ready at http://localhost:{port}/..."); - - using CancellationTokenSource cts = new(timeout); - while (true) - { - // Fail fast if the host process has exited (e.g. build or startup failure) - if (funcProcess.HasExited) - { - string context = samplePath != null ? $" for sample '{samplePath}'" : string.Empty; - throw new InvalidOperationException( - $"The Azure Functions host process exited unexpectedly with code {funcProcess.ExitCode}{context}."); - } - - try - { - using HttpRequestMessage request = new(HttpMethod.Head, $"http://localhost:{port}/"); - using HttpResponseMessage response = await httpClient.SendAsync(request); - outputHelper.WriteLine($"Azure Functions Core Tools response: {response.StatusCode}"); - if (response.IsSuccessStatusCode) - { - return; - } - } - catch (HttpRequestException) - { - // Expected when the app isn't yet ready - } - - try - { - await Task.Delay(TimeSpan.FromSeconds(1), cts.Token); - } - catch (OperationCanceledException) when (cts.IsCancellationRequested) - { - string context = samplePath != null ? $" for sample '{samplePath}'" : string.Empty; - throw new TimeoutException( - $"Timeout waiting for 'Azure Functions Core Tools is ready'{context}"); - } - } - } -} 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 deleted file mode 100644 index 010e9c9650e..00000000000 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests.csproj +++ /dev/null @@ -1,16 +0,0 @@ - - - - $(TargetFrameworksCore) - enable - - - - - - - - - - - diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/SamplesValidation.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/SamplesValidation.cs deleted file mode 100644 index bd670ac9aaa..00000000000 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/SamplesValidation.cs +++ /dev/null @@ -1,996 +0,0 @@ -// 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; - -namespace Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests; - -[Collection("Samples")] -[Trait("Category", "SampleValidation")] -public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLifetime -{ - private const string DisabledDueToFailingCiJob = "Disabled due to persistent CI failures. See #6732."; - - private const string AzureFunctionsPort = "7071"; - private const string AzuritePort = "10000"; - private const string DtsPort = "8080"; - private const string RedisPort = "6379"; - - private static readonly string s_dotnetTargetFramework = GetTargetFramework(); - -#if DEBUG - private const string BuildConfiguration = "Debug"; -#else - private const string BuildConfiguration = "Release"; -#endif - private static readonly HttpClient s_sharedHttpClient = new() { Timeout = TimeSpan.FromMinutes(3) }; - private static readonly IConfiguration s_configuration = - new ConfigurationBuilder() - .AddEnvironmentVariables() - .AddUserSecrets(Assembly.GetExecutingAssembly()) - .Build(); - - private static bool s_infrastructureStarted; - private static readonly TimeSpan s_orchestrationTimeout = TimeSpan.FromMinutes(3); - - // In CI, `dotnet run` builds the Functions project from scratch before the host starts, so 60s is not enough. - private static readonly TimeSpan s_functionsReadyTimeout = TimeSpan.FromSeconds(180); - - private static readonly string s_samplesPath = Path.GetFullPath( - Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "..", "..", "..", "..", "..", "samples", "04-hosting", "DurableAgents", "AzureFunctions")); - - private readonly ITestOutputHelper _outputHelper = outputHelper; - - async ValueTask IAsyncLifetime.InitializeAsync() - { - if (!s_infrastructureStarted) - { - await this.StartSharedInfrastructureAsync(); - s_infrastructureStarted = true; - } - } - - async ValueTask IAsyncDisposable.DisposeAsync() - { - // Nothing to clean up - await Task.CompletedTask; - } - - [RetryFact(2, 5000, Skip = DisabledDueToFailingCiJob)] - 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 session ID, which can be used to continue the conversation. - string? sessionId = response.Headers.GetValues("x-ms-thread-id")?.FirstOrDefault(); - Assert.NotNull(sessionId); - Assert.NotEmpty(sessionId); - - this._outputHelper.WriteLine($"Agent session ID: {sessionId}"); - - // 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(sessionId)); - return Task.FromResult(exists); - } - }, - message: "Agent response is available", - timeout: TimeSpan.FromSeconds(30)); - }); - } - - [Fact(Skip = "Flaky: LLM non-determinism can produce null orchestration results")] - 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 = JsonElement.Parse(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 = JsonElement.Parse(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"); - }); - } - - [RetryFact(2, 5000, Skip = DisabledDueToFailingCiJob)] - 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 = JsonElement.Parse(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 = JsonElement.Parse(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); - }); - } - - [RetryFact(2, 5000, Skip = DisabledDueToFailingCiJob)] - 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); - }); - } - - [RetryFact(2, 5000, Skip = "Disabled due to persistent CI failures.")] - 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 = JsonElement.Parse(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 = JsonElement.Parse(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()); - }); - } - - [RetryFact(2, 5000, Skip = DisabledDueToFailingCiJob)] - 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 AgentResponse object and have a valid session ID - startResponse.Headers.TryGetValues("x-ms-thread-id", out IEnumerable? agentIdValues); - string? sessionId = agentIdValues?.FirstOrDefault(); - Assert.NotNull(sessionId); - Assert.NotEmpty(sessionId); - - // 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(180)); - - // Approve the content - Uri approvalUri = new($"{runAgentUri}?thread_id={sessionId}"); - 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(180)); - - // Verify the final orchestration status by asking the agent for the status - Uri statusUri = new($"{runAgentUri}?thread_id={sessionId}"); - 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(180)); - }); - } - - [RetryFact(2, 5000, Skip = DisabledDueToFailingCiJob)] - 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)); - }); - } - - [RetryFact(2, 5000, Skip = "Disabled due to persistent CI failures.")] - public async Task ReliableStreamingSampleValidationAsync() - { - string samplePath = Path.Combine(s_samplesPath, "08_ReliableStreaming"); - await this.RunSampleTestAsync(samplePath, async (logs) => - { - Uri createUri = new($"http://localhost:{AzureFunctionsPort}/api/agent/create"); - this._outputHelper.WriteLine($"Starting reliable streaming agent via POST request to {createUri}..."); - - // Test the agent endpoint with a simple prompt - const string RequestBody = "Plan a 3-day trip to Seattle. Include daily activities."; - using HttpContent content = new StringContent(RequestBody, Encoding.UTF8, "text/plain"); - using HttpRequestMessage request = new(HttpMethod.Post, createUri) - { - Content = content - }; - request.Headers.Add("Accept", "text/plain"); - - using HttpResponseMessage response = await s_sharedHttpClient.SendAsync( - request, - HttpCompletionOption.ResponseHeadersRead); - - // The response should be successful - Assert.True(response.IsSuccessStatusCode, $"Agent request failed with status: {response.StatusCode}"); - Assert.Equal("text/plain", response.Content.Headers.ContentType?.MediaType); - - // The response headers should include the conversation ID - string? conversationId = response.Headers.GetValues("x-conversation-id")?.FirstOrDefault(); - Assert.NotNull(conversationId); - Assert.NotEmpty(conversationId); - this._outputHelper.WriteLine($"Agent conversation ID: {conversationId}"); - - // Read the streamed response - using Stream responseStream = await response.Content.ReadAsStreamAsync(); - using StreamReader reader = new(responseStream); - StringBuilder responseText = new(); - char[] buffer = new char[1024]; - int bytesRead; - - // Read for a reasonable amount of time to get some content - using CancellationTokenSource readTimeout = new(TimeSpan.FromSeconds(30)); - try - { - while (!readTimeout.Token.IsCancellationRequested) - { - bytesRead = await reader.ReadAsync(buffer, 0, buffer.Length); - if (bytesRead == 0) - { - // Check if we've received enough content - if (responseText.Length > 50) - { - break; - } - await Task.Delay(100, readTimeout.Token); - continue; - } - - responseText.Append(buffer, 0, bytesRead); - if (responseText.Length > 200) - { - // We've received enough content to validate - break; - } - } - } - catch (OperationCanceledException) - { - // Timeout is acceptable if we got some content - } - - string responseContent = responseText.ToString(); - Assert.True(responseContent.Length > 0, "Expected to receive some streamed content"); - this._outputHelper.WriteLine($"Received {responseContent.Length} characters of streamed content"); - - // Test resumption by calling the stream endpoint - Uri streamUri = new($"http://localhost:{AzureFunctionsPort}/api/agent/stream/{conversationId}"); - this._outputHelper.WriteLine($"Testing stream resumption via GET request to {streamUri}..."); - - using HttpRequestMessage streamRequest = new(HttpMethod.Get, streamUri); - streamRequest.Headers.Add("Accept", "text/plain"); - - using HttpResponseMessage streamResponse = await s_sharedHttpClient.SendAsync( - streamRequest, - HttpCompletionOption.ResponseHeadersRead); - Assert.True(streamResponse.IsSuccessStatusCode, $"Stream request failed with status: {streamResponse.StatusCode}"); - Assert.Equal("text/plain", streamResponse.Content.Headers.ContentType?.MediaType); - - // Verify the conversation ID header is present - string? resumedConversationId = streamResponse.Headers.GetValues("x-conversation-id")?.FirstOrDefault(); - Assert.Equal(conversationId, resumedConversationId); - - // Read some content from the resumed stream - using Stream resumedStream = await streamResponse.Content.ReadAsStreamAsync(); - using StreamReader resumedReader = new(resumedStream); - StringBuilder resumedText = new(); - - using CancellationTokenSource resumedReadTimeout = new(TimeSpan.FromSeconds(10)); - try - { - while (!resumedReadTimeout.Token.IsCancellationRequested) - { - bytesRead = await resumedReader.ReadAsync(buffer, 0, buffer.Length); - if (bytesRead == 0) - { - if (resumedText.Length > 50) - { - break; - } - await Task.Delay(100, resumedReadTimeout.Token); - continue; - } - - resumedText.Append(buffer, 0, bytesRead); - if (resumedText.Length > 100) - { - break; - } - } - } - catch (OperationCanceledException) - { - // Timeout is acceptable if we got some content - } - - string resumedContent = resumedText.ToString(); - Assert.True(resumedContent.Length > 0, "Expected to receive some content from resumed stream"); - this._outputHelper.WriteLine($"Received {resumedContent.Length} characters from resumed stream"); - }); - } - - 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 = JsonElement.Parse(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 = JsonElement.Parse(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)); - } - - // Start Redis if it's not already running - if (!await this.IsRedisRunningAsync()) - { - await this.StartDockerContainerAsync( - containerName: "redis", - image: "redis:latest", - ports: ["-p", "6379:6379"]); - - // Wait for Redis - await this.WaitForConditionAsync( - condition: this.IsRedisRunningAsync, - message: "Redis 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 IsRedisRunningAsync() - { - this._outputHelper.WriteLine($"Checking if Redis is running at localhost:{RedisPort}..."); - - try - { - using CancellationTokenSource timeoutCts = new(TimeSpan.FromSeconds(30)); - ProcessStartInfo startInfo = new() - { - FileName = "docker", - Arguments = "exec redis redis-cli ping", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - - using Process process = new() { StartInfo = startInfo }; - if (!process.Start()) - { - this._outputHelper.WriteLine("Failed to start docker exec command"); - return false; - } - - string output = await process.StandardOutput.ReadToEndAsync(timeoutCts.Token); - await process.WaitForExitAsync(timeoutCts.Token); - - if (process.ExitCode == 0 && output.Contains("PONG", StringComparison.OrdinalIgnoreCase)) - { - this._outputHelper.WriteLine("Redis is running"); - return true; - } - - this._outputHelper.WriteLine($"Redis is not running. Exit code: {process.ExitCode}, Output: {output}"); - return false; - } - catch (Exception ex) - { - this._outputHelper.WriteLine($"Redis 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) - { - // Build the sample project first (it may not have been built as part of the solution) - await AzureFunctionsTestHelper.BuildSampleAsync( - samplePath, $"-f {s_dotnetTargetFramework} -c {BuildConfiguration}", this._outputHelper); - - // Start the Azure Functions app - List logsContainer = []; - using Process funcProcess = this.StartFunctionApp(samplePath, logsContainer); - try - { - // Wait for the app to be ready - await AzureFunctionsTestHelper.WaitForFunctionsReadyAsync( - funcProcess, AzureFunctionsPort, s_sharedHttpClient, this._outputHelper, s_functionsReadyTimeout, samplePath); - - // 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 --no-build -f {s_dotnetTargetFramework} -c {BuildConfiguration} --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_DEPLOYMENT_NAME"] ?? - throw new InvalidOperationException("The required AZURE_OPENAI_DEPLOYMENT_NAME env variable is not set."); - - // Set required environment variables for the function app (see local.settings.json for required settings) - startInfo.EnvironmentVariables["FUNCTIONS_WORKER_RUNTIME"] = "dotnet-isolated"; - startInfo.EnvironmentVariables["AZURE_OPENAI_ENDPOINT"] = openAiEndpoint; - startInfo.EnvironmentVariables["AZURE_OPENAI_DEPLOYMENT_NAME"] = openAiDeployment; - startInfo.EnvironmentVariables["DURABLE_TASK_SCHEDULER_CONNECTION_STRING"] = - $"Endpoint=http://localhost:{DtsPort};TaskHub=default;Authentication=None"; - startInfo.EnvironmentVariables["AzureWebJobsStorage"] = "UseDevelopmentStorage=true"; - startInfo.EnvironmentVariables["REDIS_CONNECTION_STRING"] = $"localhost:{RedisPort}"; - - 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 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 = JsonElement.Parse(responseText); - - if (result.TryGetProperty("runtimeStatus", out JsonElement statusElement) && - statusElement.GetString() is "Completed" or "Failed" or "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.IntegrationTests/WorkflowSamplesValidation.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/WorkflowSamplesValidation.cs deleted file mode 100644 index 22071699dc9..00000000000 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/WorkflowSamplesValidation.cs +++ /dev/null @@ -1,727 +0,0 @@ -// 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; -namespace Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests; - -/// -/// Integration tests for validating the durable workflow Azure Functions samples -/// located in samples/04-hosting/DurableWorkflows/AzureFunctions. -/// -[Collection("Samples")] -[Trait("Category", "SampleValidation")] -public sealed class WorkflowSamplesValidation(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(); - -#if DEBUG - private const string BuildConfiguration = "Debug"; -#else - private const string BuildConfiguration = "Release"; -#endif - 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); - - // Timeout for the Azure Functions host to become ready after building. - private static readonly TimeSpan s_functionsReadyTimeout = TimeSpan.FromSeconds(180); - - private static readonly string s_samplesPath = Path.GetFullPath( - Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "..", "..", "..", "..", "..", "samples", "04-hosting", "DurableWorkflows", "AzureFunctions")); - - private readonly ITestOutputHelper _outputHelper = outputHelper; - - public async ValueTask InitializeAsync() - { - if (!s_infrastructureStarted) - { - await this.StartSharedInfrastructureAsync(); - s_infrastructureStarted = true; - } - } - - public ValueTask DisposeAsync() - { - GC.SuppressFinalize(this); - return default; - } - - [Fact] - public async Task SequentialWorkflowSampleValidationAsync() - { - string samplePath = Path.Combine(s_samplesPath, "01_SequentialWorkflow"); - await this.RunSampleTestAsync(samplePath, requiresOpenAI: false, async (logs) => - { - // Test the CancelOrder workflow - Uri cancelOrderUri = new($"http://localhost:{AzureFunctionsPort}/api/workflows/CancelOrder/run"); - this._outputHelper.WriteLine($"Starting CancelOrder workflow via POST request to {cancelOrderUri}..."); - - using HttpContent cancelContent = new StringContent("12345", Encoding.UTF8, "text/plain"); - using HttpResponseMessage cancelResponse = await s_sharedHttpClient.PostAsync(cancelOrderUri, cancelContent); - - Assert.True(cancelResponse.IsSuccessStatusCode, $"CancelOrder request failed with status: {cancelResponse.StatusCode}"); - string cancelResponseText = await cancelResponse.Content.ReadAsStringAsync(); - Assert.Contains("CancelOrder", cancelResponseText); - this._outputHelper.WriteLine($"CancelOrder response: {cancelResponseText}"); - - // Wait for the CancelOrder workflow to complete by checking logs - await this.WaitForConditionAsync( - condition: () => - { - lock (logs) - { - bool exists = logs.Any(log => log.Message.Contains("Workflow completed")); - return Task.FromResult(exists); - } - }, - message: "CancelOrder workflow completed", - timeout: s_orchestrationTimeout); - - // Verify the executor activities ran in sequence - lock (logs) - { - Assert.True(logs.Any(log => log.Message.Contains("[Activity] OrderLookup:")), "OrderLookup activity not found in logs."); - Assert.True(logs.Any(log => log.Message.Contains("[Activity] OrderCancel:")), "OrderCancel activity not found in logs."); - Assert.True(logs.Any(log => log.Message.Contains("[Activity] SendEmail:")), "SendEmail activity not found in logs."); - } - - // Test the OrderStatus workflow (shares OrderLookup executor with CancelOrder) - Uri orderStatusUri = new($"http://localhost:{AzureFunctionsPort}/api/workflows/OrderStatus/run"); - this._outputHelper.WriteLine($"Starting OrderStatus workflow via POST request to {orderStatusUri}..."); - - using HttpContent statusContent = new StringContent("67890", Encoding.UTF8, "text/plain"); - using HttpResponseMessage statusResponse = await s_sharedHttpClient.PostAsync(orderStatusUri, statusContent); - - Assert.True(statusResponse.IsSuccessStatusCode, $"OrderStatus request failed with status: {statusResponse.StatusCode}"); - string statusResponseText = await statusResponse.Content.ReadAsStringAsync(); - Assert.Contains("OrderStatus", statusResponseText); - this._outputHelper.WriteLine($"OrderStatus response: {statusResponseText}"); - - // Wait for the OrderStatus workflow to complete - await this.WaitForConditionAsync( - condition: () => - { - lock (logs) - { - // Look for StatusReport activity which is unique to OrderStatus workflow - bool exists = logs.Any(log => log.Message.Contains("[Activity] StatusReport:")); - return Task.FromResult(exists); - } - }, - message: "OrderStatus workflow completed", - timeout: s_orchestrationTimeout); - - // Test the CancelOrder workflow with x-ms-wait-for-response header - this._outputHelper.WriteLine("Starting CancelOrder workflow with x-ms-wait-for-response: true..."); - - using HttpRequestMessage waitRequest = new(HttpMethod.Post, cancelOrderUri); - waitRequest.Content = new StringContent("55555", Encoding.UTF8, "text/plain"); - waitRequest.Headers.Add("x-ms-wait-for-response", "true"); - using HttpResponseMessage waitResponse = await s_sharedHttpClient.SendAsync(waitRequest); - - Assert.True(waitResponse.IsSuccessStatusCode, $"CancelOrder wait-for-response request failed with status: {waitResponse.StatusCode}"); - string waitResponseText = await waitResponse.Content.ReadAsStringAsync(); - this._outputHelper.WriteLine($"CancelOrder wait-for-response result: {waitResponseText}"); - - // The response should contain the workflow result (not just "started for CancelOrder") - Assert.DoesNotContain("Workflow orchestration started", waitResponseText); - Assert.Contains("55555", waitResponseText); - - // Test the wait-for-response with Accept: application/json header - this._outputHelper.WriteLine("Starting CancelOrder workflow with x-ms-wait-for-response and Accept: application/json..."); - - using HttpRequestMessage jsonWaitRequest = new(HttpMethod.Post, cancelOrderUri); - jsonWaitRequest.Content = new StringContent("77777", Encoding.UTF8, "text/plain"); - jsonWaitRequest.Headers.Add("x-ms-wait-for-response", "true"); - jsonWaitRequest.Headers.Add("Accept", "application/json"); - - using CancellationTokenSource jsonWaitCts = new(s_orchestrationTimeout); - using HttpResponseMessage jsonWaitResponse = await s_sharedHttpClient.SendAsync(jsonWaitRequest, jsonWaitCts.Token); - - Assert.True(jsonWaitResponse.IsSuccessStatusCode, $"CancelOrder JSON wait-for-response request failed with status: {jsonWaitResponse.StatusCode}"); - string jsonWaitResponseText = await jsonWaitResponse.Content.ReadAsStringAsync(); - this._outputHelper.WriteLine($"CancelOrder JSON wait-for-response result: {jsonWaitResponseText}"); - - using JsonDocument jsonDoc = JsonDocument.Parse(jsonWaitResponseText); - JsonElement root = jsonDoc.RootElement; - Assert.True(root.TryGetProperty("runId", out _), "JSON response missing 'runId' property"); - Assert.True(root.TryGetProperty("workflowStatus", out JsonElement statusEl), "JSON response missing 'workflowStatus' property"); - Assert.Equal("Completed", statusEl.GetString()); - Assert.True(root.TryGetProperty("result", out JsonElement resultEl), "JSON response missing 'result' property"); - Assert.Contains("77777", resultEl.GetString()); - }); - } - - [Fact] - public async Task HITLWorkflowSampleValidationAsync() - { - string samplePath = Path.Combine(s_samplesPath, "03_WorkflowHITL"); - await this.RunSampleTestAsync(samplePath, requiresOpenAI: false, async (logs) => - { - // Use a unique run ID to avoid conflicts with previous test runs - string runId = $"hitl-test-{Guid.NewGuid():N}"; - - // Step 1: Start the expense reimbursement workflow - Uri runUri = new($"http://localhost:{AzureFunctionsPort}/api/workflows/ExpenseReimbursement/run?runId={runId}"); - this._outputHelper.WriteLine($"Starting ExpenseReimbursement workflow via POST request to {runUri}..."); - - using HttpContent runContent = new StringContent("EXP-2025-001", Encoding.UTF8, "text/plain"); - using HttpResponseMessage runResponse = await s_sharedHttpClient.PostAsync(runUri, runContent); - - Assert.True(runResponse.IsSuccessStatusCode, $"Run request failed with status: {runResponse.StatusCode}"); - string runResponseText = await runResponse.Content.ReadAsStringAsync(); - Assert.Contains("ExpenseReimbursement", runResponseText); - this._outputHelper.WriteLine($"Run response: {runResponseText}"); - - // Step 2: Wait for the workflow to pause at the ManagerApproval RequestPort - await this.WaitForConditionAsync( - condition: () => - { - lock (logs) - { - bool exists = logs.Any(log => log.Message.Contains("Workflow waiting for external input at RequestPort 'ManagerApproval'")); - return Task.FromResult(exists); - } - }, - message: "Workflow paused at ManagerApproval RequestPort", - timeout: s_orchestrationTimeout); - - // Step 3: Send approval response to resume the workflow - Uri respondUri = new($"http://localhost:{AzureFunctionsPort}/api/workflows/ExpenseReimbursement/respond/{runId}"); - this._outputHelper.WriteLine($"Sending approval response via POST request to {respondUri}..."); - - using HttpContent respondContent = new StringContent( - """{"eventName": "ManagerApproval", "response": {"Approved": true, "Comments": "Approved by test."}}""", - Encoding.UTF8, "application/json"); - using HttpResponseMessage respondResponse = await s_sharedHttpClient.PostAsync(respondUri, respondContent); - - Assert.True(respondResponse.IsSuccessStatusCode, $"Respond request failed with status: {respondResponse.StatusCode}"); - string respondResponseText = await respondResponse.Content.ReadAsStringAsync(); - Assert.Contains("Response sent to workflow", respondResponseText); - this._outputHelper.WriteLine($"Respond response: {respondResponseText}"); - - // Step 4: Wait for the workflow to pause at the parallel BudgetApproval and ComplianceApproval RequestPorts - await this.WaitForConditionAsync( - condition: () => - { - lock (logs) - { - bool exists = logs.Any(log => log.Message.Contains("Workflow waiting for external input at RequestPort 'BudgetApproval'")); - return Task.FromResult(exists); - } - }, - message: "Workflow paused at BudgetApproval RequestPort", - timeout: s_orchestrationTimeout); - - // Step 5a: Send budget approval response - this._outputHelper.WriteLine("Sending BudgetApproval response..."); - - using HttpContent budgetContent = new StringContent( - """{"eventName": "BudgetApproval", "response": {"Approved": true, "Comments": "Budget approved by test."}}""", - Encoding.UTF8, "application/json"); - using HttpResponseMessage budgetResponse = await s_sharedHttpClient.PostAsync(respondUri, budgetContent); - - Assert.True(budgetResponse.IsSuccessStatusCode, $"BudgetApproval request failed with status: {budgetResponse.StatusCode}"); - this._outputHelper.WriteLine($"BudgetApproval response: {await budgetResponse.Content.ReadAsStringAsync()}"); - - // Step 5b: Send compliance approval response - this._outputHelper.WriteLine("Sending ComplianceApproval response..."); - - using HttpContent complianceContent = new StringContent( - """{"eventName": "ComplianceApproval", "response": {"Approved": true, "Comments": "Compliance approved by test."}}""", - Encoding.UTF8, "application/json"); - using HttpResponseMessage complianceResponse = await s_sharedHttpClient.PostAsync(respondUri, complianceContent); - - Assert.True(complianceResponse.IsSuccessStatusCode, $"ComplianceApproval request failed with status: {complianceResponse.StatusCode}"); - this._outputHelper.WriteLine($"ComplianceApproval response: {await complianceResponse.Content.ReadAsStringAsync()}"); - - // Step 6: Wait for the workflow to complete - await this.WaitForConditionAsync( - condition: () => - { - lock (logs) - { - bool exists = logs.Any(log => log.Message.Contains("Workflow completed")); - return Task.FromResult(exists); - } - }, - message: "HITL workflow completed", - timeout: s_orchestrationTimeout); - - // Verify executor activities ran - lock (logs) - { - Assert.True(logs.Any(log => log.Message.Contains("Received external event for RequestPort 'ManagerApproval'")), - "ManagerApproval external event receipt not found in logs."); - Assert.True(logs.Any(log => log.Message.Contains("Received external event for RequestPort 'BudgetApproval'")), - "BudgetApproval external event receipt not found in logs."); - Assert.True(logs.Any(log => log.Message.Contains("Received external event for RequestPort 'ComplianceApproval'")), - "ComplianceApproval external event receipt not found in logs."); - } - }); - } - - [Fact] - public async Task WorkflowMcpToolSampleValidationAsync() - { - string samplePath = Path.Combine(s_samplesPath, "04_WorkflowMcpTool"); - await this.RunSampleTestAsync(samplePath, requiresOpenAI: false, async (logs) => - { - // Connect to the MCP endpoint exposed by the Azure Functions host - IClientTransport clientTransport = new HttpClientTransport(new() - { - Endpoint = new Uri($"http://localhost:{AzureFunctionsPort}/runtime/webhooks/mcp") - }); - - await using McpClient mcpClient = await McpClient.CreateAsync(clientTransport); - - // Verify both workflow tools are listed - IList tools = await mcpClient.ListToolsAsync(); - this._outputHelper.WriteLine($"MCP tools found: {string.Join(", ", tools.Select(t => t.Name))}"); - - Assert.Single(tools, t => t.Name == "Translate"); - Assert.Single(tools, t => t.Name == "OrderLookup"); - - // Invoke the Translate workflow via MCP tool (returns a string result) - this._outputHelper.WriteLine("Invoking MCP tool 'Translate'..."); - CallToolResult translateResult = await mcpClient.CallToolAsync( - "Translate", - arguments: new Dictionary { { "input", "hello world" } }); - - Assert.NotEmpty(translateResult.Content); - string translateResponse = Assert.IsType(translateResult.Content[0]).Text; - this._outputHelper.WriteLine($"Translate MCP tool response: {translateResponse}"); - Assert.NotEmpty(translateResponse); - Assert.Contains("HELLO WORLD", translateResponse); - - // Invoke the OrderLookup workflow via MCP tool (returns a POCO serialized as JSON) - this._outputHelper.WriteLine("Invoking MCP tool 'OrderLookup'..."); - CallToolResult orderResult = await mcpClient.CallToolAsync( - "OrderLookup", - arguments: new Dictionary { { "input", "ORD-2025-42" } }); - - Assert.NotEmpty(orderResult.Content); - string orderResponse = Assert.IsType(orderResult.Content[0]).Text; - this._outputHelper.WriteLine($"OrderLookup MCP tool response: {orderResponse}"); - Assert.NotEmpty(orderResponse); - Assert.Contains("ORD-2025-42", orderResponse); - - // Verify executor activities ran in the logs - lock (logs) - { - Assert.True(logs.Any(log => log.Message.Contains("[Activity] TranslateText:")), "TranslateText activity not found in logs."); - Assert.True(logs.Any(log => log.Message.Contains("[Activity] FormatOutput:")), "FormatOutput activity not found in logs."); - Assert.True(logs.Any(log => log.Message.Contains("[Activity] LookupOrder:")), "LookupOrder activity not found in logs."); - Assert.True(logs.Any(log => log.Message.Contains("[Activity] EnrichOrder:")), "EnrichOrder activity not found in logs."); - } - }); - } - - [Fact(Skip = "Disabled due to persistent CI failures. See #6732.")] - public async Task WorkflowAndAgentsSampleValidationAsync() - { - string samplePath = Path.Combine(s_samplesPath, "05_WorkflowAndAgents"); - await this.RunSampleTestAsync(samplePath, requiresOpenAI: true, async (logs) => - { - // Connect to the MCP endpoint exposed by the Azure Functions host - IClientTransport clientTransport = new HttpClientTransport(new() - { - Endpoint = new Uri($"http://localhost:{AzureFunctionsPort}/runtime/webhooks/mcp") - }); - - await using McpClient mcpClient = await McpClient.CreateAsync(clientTransport); - - // Verify both the agent and workflow tools are listed - IList tools = await mcpClient.ListToolsAsync(); - this._outputHelper.WriteLine($"MCP tools found: {string.Join(", ", tools.Select(t => t.Name))}"); - - Assert.Single(tools, t => t.Name == "Assistant"); - Assert.Single(tools, t => t.Name == "Translate"); - - // Invoke the Translate workflow via MCP tool - this._outputHelper.WriteLine("Invoking MCP tool 'Translate'..."); - CallToolResult translateResult = await mcpClient.CallToolAsync( - "Translate", - arguments: new Dictionary { { "input", "hello world" } }); - - Assert.NotEmpty(translateResult.Content); - string translateResponse = Assert.IsType(translateResult.Content[0]).Text; - this._outputHelper.WriteLine($"Translate MCP tool response: {translateResponse}"); - Assert.Contains("HELLO WORLD", translateResponse); - - // Invoke the Assistant agent via MCP tool - this._outputHelper.WriteLine("Invoking MCP tool 'Assistant'..."); - CallToolResult assistantResult = await mcpClient.CallToolAsync( - "Assistant", - arguments: new Dictionary { { "query", "What is 2 + 2?" } }); - - Assert.NotEmpty(assistantResult.Content); - string assistantResponse = Assert.IsType(assistantResult.Content[0]).Text; - this._outputHelper.WriteLine($"Assistant MCP tool response: {assistantResponse}"); - Assert.NotEmpty(assistantResponse); - - // Verify workflow executor activities ran in the logs - lock (logs) - { - Assert.True(logs.Any(log => log.Message.Contains("[Activity] TranslateText:")), "TranslateText activity not found in logs."); - Assert.True(logs.Any(log => log.Message.Contains("[Activity] FormatOutput:")), "FormatOutput activity not found in logs."); - } - }); - } - - [Fact(Skip = "Disabled due to persistent CI failures. See #6732.")] - public async Task ConcurrentWorkflowSampleValidationAsync() - { - string samplePath = Path.Combine(s_samplesPath, "02_ConcurrentWorkflow"); - await this.RunSampleTestAsync(samplePath, requiresOpenAI: true, async (logs) => - { - // Start the ExpertReview workflow with a science question - const string RequestBody = "What is temperature?"; - using HttpContent content = new StringContent(RequestBody, Encoding.UTF8, "text/plain"); - - Uri startUri = new($"http://localhost:{AzureFunctionsPort}/api/workflows/ExpertReview/run"); - this._outputHelper.WriteLine($"Starting ExpertReview workflow via POST request to {startUri}..."); - using HttpResponseMessage startResponse = await s_sharedHttpClient.PostAsync(startUri, content); - - Assert.True(startResponse.IsSuccessStatusCode, $"ExpertReview request failed with status: {startResponse.StatusCode}"); - string startResponseText = await startResponse.Content.ReadAsStringAsync(); - Assert.Contains("ExpertReview", startResponseText); - this._outputHelper.WriteLine($"ExpertReview response: {startResponseText}"); - - // Wait for the ParseQuestion executor to run - await this.WaitForConditionAsync( - condition: () => - { - lock (logs) - { - bool exists = logs.Any(log => log.Message.Contains("[ParseQuestion]")); - return Task.FromResult(exists); - } - }, - message: "ParseQuestion executor ran", - timeout: s_orchestrationTimeout); - - // Wait for the Aggregator to complete (indicates fan-in from parallel agents) - await this.WaitForConditionAsync( - condition: () => - { - lock (logs) - { - bool exists = logs.Any(log => log.Message.Contains("Aggregation complete")); - return Task.FromResult(exists); - } - }, - message: "Aggregator completed with parallel agent responses", - timeout: s_orchestrationTimeout); - - // Verify the aggregator received responses from both AI agents - lock (logs) - { - Assert.True( - logs.Any(log => log.Message.Contains("AI agent responses")), - "Aggregator did not log receiving AI agent responses."); - } - }); - } - - 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"]); - - 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"]); - - 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)); - 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..."); - - 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) - { - await this.RunCommandAsync("docker", ["stop", containerName]); - await this.RunCommandAsync("docker", ["rm", containerName]); - - 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 sealed record OutputLog(DateTime Timestamp, LogLevel Level, string Message); - - private async Task RunSampleTestAsync(string samplePath, bool requiresOpenAI, Func, Task> testAction) - { - // Build the sample project first (it may not have been built as part of the solution) - await AzureFunctionsTestHelper.BuildSampleAsync( - samplePath, $"-f {s_dotnetTargetFramework} -c {BuildConfiguration}", this._outputHelper); - - // Start the Azure Functions app - List logsContainer = []; - using Process funcProcess = this.StartFunctionApp(samplePath, logsContainer, requiresOpenAI); - try - { - await AzureFunctionsTestHelper.WaitForFunctionsReadyAsync( - funcProcess, AzureFunctionsPort, s_sharedHttpClient, this._outputHelper, s_functionsReadyTimeout, samplePath); - await testAction(logsContainer); - } - finally - { - await this.StopProcessAsync(funcProcess); - } - } - - private Process StartFunctionApp(string samplePath, List logs, bool requiresOpenAI) - { - ProcessStartInfo startInfo = new() - { - FileName = "dotnet", - Arguments = $"run --no-build -f {s_dotnetTargetFramework} -c {BuildConfiguration} --port {AzureFunctionsPort}", - WorkingDirectory = samplePath, - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - }; - - if (requiresOpenAI) - { - 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."); - - this._outputHelper.WriteLine($"Using Azure OpenAI endpoint: {openAiEndpoint}, deployment: {openAiDeployment}"); - - startInfo.EnvironmentVariables["AZURE_OPENAI_ENDPOINT"] = openAiEndpoint; - startInfo.EnvironmentVariables["AZURE_OPENAI_DEPLOYMENT"] = openAiDeployment; - } - - startInfo.EnvironmentVariables["FUNCTIONS_WORKER_RUNTIME"] = "dotnet-isolated"; - 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 }; - - 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 RunCommandAsync(string command, string[] args) - { - ProcessStartInfo startInfo = new() - { - FileName = command, - Arguments = string.Join(" ", args), - 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() - { - string filePath = new Uri(typeof(WorkflowSamplesValidation).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/BuiltInFunctionsWorkflowRoutingTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/BuiltInFunctionsWorkflowRoutingTests.cs deleted file mode 100644 index 5c671d3d82a..00000000000 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/BuiltInFunctionsWorkflowRoutingTests.cs +++ /dev/null @@ -1,52 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -namespace Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests; - -public sealed class BuiltInFunctionsWorkflowRoutingTests -{ - [Theory] - [InlineData("http-MyWorkflow-status", "-status", "MyWorkflow")] - [InlineData("http-OrderProcessor-status", "-status", "OrderProcessor")] - [InlineData("http-MyWorkflow-respond", "-respond", "MyWorkflow")] - [InlineData("http-Multi-Dash-Name-status", "-status", "Multi-Dash-Name")] - public void GetWorkflowName_ReturnsCorrectName(string functionName, string suffix, string expectedWorkflowName) - { - // Act - string result = BuiltInFunctions.GetWorkflowName(functionName, suffix); - - // Assert - Assert.Equal(expectedWorkflowName, result); - } - - [Theory] - [InlineData("invalid-name", "-status")] - [InlineData("http-MyWorkflow-respond", "-status")] // wrong suffix - [InlineData("mcptool-MyWorkflow-status", "-status")] // wrong prefix - public void GetWorkflowName_ThrowsForInvalidPattern(string functionName, string suffix) - { - // Act & Assert - Assert.Throws(() => - BuiltInFunctions.GetWorkflowName(functionName, suffix)); - } - - [Theory] - [InlineData("dafx-MyWorkflow", "http-MyWorkflow-status", "-status", true)] - [InlineData("dafx-MyWorkflow", "http-MyWorkflow-respond", "-respond", true)] - [InlineData("dafx-myworkflow", "http-MyWorkflow-status", "-status", true)] // case-insensitive - [InlineData("dafx-MYWORKFLOW", "http-MyWorkflow-respond", "-respond", true)] // case-insensitive - [InlineData("dafx-OtherWorkflow", "http-MyWorkflow-status", "-status", false)] // cross-workflow - [InlineData("dafx-PrivilegedWorkflow", "http-PublicWorkflow-status", "-status", false)] // attack scenario - [InlineData("dafx-PrivilegedWorkflow", "http-PublicWorkflow-respond", "-respond", false)] // attack scenario - public void IsOrchestrationOwnedByWorkflow_ValidatesCorrectly( - string orchestrationName, - string functionName, - string suffix, - bool expectedResult) - { - // Act - bool result = BuiltInFunctions.IsOrchestrationOwnedByWorkflow(orchestrationName, functionName, suffix); - - // Assert - Assert.Equal(expectedResult, result); - } -} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/DurableAgentFunctionMetadataTransformerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/DurableAgentFunctionMetadataTransformerTests.cs deleted file mode 100644 index 82824e0d8c5..00000000000 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/DurableAgentFunctionMetadataTransformerTests.cs +++ /dev/null @@ -1,225 +0,0 @@ -// 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 is "agentB" or "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]); - } - } - } - - [Fact] - public void Transform_SkipsAgents_WithoutExplicitOptions() - { - // Arrange: two agents in the dictionary, but only one has explicit FunctionsAgentOptions. - // This simulates a workflow-auto-registered agent (workflowAgent) alongside a standalone agent. - Dictionary> agents = new() - { - { "standaloneAgent", _ => new TestAgent("standaloneAgent", "Standalone agent") }, - { "workflowAgent", _ => new TestAgent("workflowAgent", "Auto-registered by workflow") } - }; - - FunctionsAgentOptions standaloneOptions = new(); - standaloneOptions.HttpTrigger.IsEnabled = true; - - // Only standaloneAgent has explicit options; workflowAgent does not. - IFunctionsAgentOptionsProvider agentOptionsProvider = new FakeOptionsProvider(new Dictionary - { - { "standaloneAgent", standaloneOptions } - }); - - List metadataList = []; - - DurableAgentFunctionMetadataTransformer transformer = new( - agents, - NullLogger.Instance, - new FakeServiceProvider(), - agentOptionsProvider); - - // Act - transformer.Transform(metadataList); - - // Assert: only standaloneAgent should have triggers (entity + http = 2). - // workflowAgent should be skipped entirely. - Assert.Equal(2, metadataList.Count); - Assert.Contains(metadataList, m => m.Name == "dafx-standaloneAgent"); - Assert.Contains(metadataList, m => m.Name == "http-standaloneAgent"); - Assert.DoesNotContain(metadataList, m => m.Name!.Contains("workflowAgent")); - } - - 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/FunctionMetadataFactoryTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/FunctionMetadataFactoryTests.cs deleted file mode 100644 index a777c694801..00000000000 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/FunctionMetadataFactoryTests.cs +++ /dev/null @@ -1,121 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Text.Json; -using Microsoft.Azure.Functions.Worker.Core.FunctionMetadata; - -namespace Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests; - -public sealed class FunctionMetadataFactoryTests -{ - [Fact] - public void CreateEntityTrigger_SetsCorrectNameAndBindings() - { - DefaultFunctionMetadata metadata = FunctionMetadataFactory.CreateEntityTrigger("myAgent"); - - Assert.Equal("dafx-myAgent", metadata.Name); - Assert.Equal("dotnet-isolated", metadata.Language); - Assert.Equal(BuiltInFunctions.RunAgentEntityFunctionEntryPoint, metadata.EntryPoint); - Assert.NotNull(metadata.RawBindings); - Assert.Equal(2, metadata.RawBindings.Count); - Assert.Contains("entityTrigger", metadata.RawBindings[0]); - Assert.Contains("durableClient", metadata.RawBindings[1]); - } - - [Fact] - public void CreateHttpTrigger_SetsCorrectNameRouteAndDefaults() - { - DefaultFunctionMetadata metadata = FunctionMetadataFactory.CreateHttpTrigger( - "myWorkflow", "workflows/myWorkflow/run", BuiltInFunctions.RunWorkflowOrchestrationHttpFunctionEntryPoint); - - Assert.Equal("http-myWorkflow", metadata.Name); - Assert.Equal("dotnet-isolated", metadata.Language); - Assert.Equal(BuiltInFunctions.RunWorkflowOrchestrationHttpFunctionEntryPoint, metadata.EntryPoint); - Assert.NotNull(metadata.RawBindings); - Assert.Equal(3, metadata.RawBindings.Count); - Assert.Contains("httpTrigger", metadata.RawBindings[0]); - Assert.Contains("workflows/myWorkflow/run", metadata.RawBindings[0]); - Assert.Contains("\"post\"", metadata.RawBindings[0]); - Assert.Contains("http", metadata.RawBindings[1]); - Assert.Contains("durableClient", metadata.RawBindings[2]); - } - - [Fact] - public void CreateHttpTrigger_RespectsCustomMethods() - { - DefaultFunctionMetadata metadata = FunctionMetadataFactory.CreateHttpTrigger( - "status", "workflows/status/{runId}", BuiltInFunctions.GetWorkflowStatusHttpFunctionEntryPoint, methods: "\"get\""); - - Assert.NotNull(metadata.RawBindings); - Assert.Contains("\"get\"", metadata.RawBindings[0]); - Assert.DoesNotContain("\"post\"", metadata.RawBindings[0]); - } - - [Fact] - public void CreateActivityTrigger_SetsCorrectNameAndBindings() - { - DefaultFunctionMetadata metadata = FunctionMetadataFactory.CreateActivityTrigger("dafx-MyExecutor"); - - Assert.Equal("dafx-MyExecutor", metadata.Name); - Assert.Equal("dotnet-isolated", metadata.Language); - Assert.Equal(BuiltInFunctions.InvokeWorkflowActivityFunctionEntryPoint, metadata.EntryPoint); - Assert.NotNull(metadata.RawBindings); - Assert.Equal(2, metadata.RawBindings.Count); - Assert.Contains("activityTrigger", metadata.RawBindings[0]); - Assert.Contains("durableClient", metadata.RawBindings[1]); - } - - [Fact] - public void CreateOrchestrationTrigger_SetsCorrectNameAndBindings() - { - DefaultFunctionMetadata metadata = FunctionMetadataFactory.CreateOrchestrationTrigger( - "dafx-MyWorkflow", BuiltInFunctions.RunWorkflowOrchestrationFunctionEntryPoint); - - Assert.Equal("dafx-MyWorkflow", metadata.Name); - Assert.Equal("dotnet-isolated", metadata.Language); - Assert.Equal(BuiltInFunctions.RunWorkflowOrchestrationFunctionEntryPoint, metadata.EntryPoint); - Assert.NotNull(metadata.RawBindings); - Assert.Single(metadata.RawBindings); - Assert.Contains("orchestrationTrigger", metadata.RawBindings[0]); - } - - [Fact] - public void CreateWorkflowMcpToolTrigger_SetsCorrectNameAndBindings() - { - DefaultFunctionMetadata metadata = FunctionMetadataFactory.CreateWorkflowMcpToolTrigger("Translate", "Translate text"); - - Assert.Equal("mcptool-Translate", metadata.Name); - Assert.Equal("dotnet-isolated", metadata.Language); - Assert.Equal(BuiltInFunctions.RunWorkflowMcpToolFunctionEntryPoint, metadata.EntryPoint); - Assert.NotNull(metadata.RawBindings); - Assert.Equal(3, metadata.RawBindings.Count); - - // Verify all bindings are valid JSON - foreach (string binding in metadata.RawBindings) - { - JsonDocument.Parse(binding); - } - - // mcpToolTrigger binding - Assert.Contains("mcpToolTrigger", metadata.RawBindings[0]); - Assert.Contains("\"toolName\":\"Translate\"", metadata.RawBindings[0]); - Assert.Contains("\"description\":\"Translate text\"", metadata.RawBindings[0]); - Assert.Contains("toolProperties", metadata.RawBindings[0]); - - // mcpToolProperty binding for input - Assert.Contains("mcpToolProperty", metadata.RawBindings[1]); - Assert.Contains("\"propertyName\":\"input\"", metadata.RawBindings[1]); - Assert.Contains("\"isRequired\":true", metadata.RawBindings[1]); - - // durableClient binding - Assert.Contains("durableClient", metadata.RawBindings[2]); - } - - [Fact] - public void CreateWorkflowMcpToolTrigger_UsesDefaultDescription_WhenNull() - { - DefaultFunctionMetadata metadata = FunctionMetadataFactory.CreateWorkflowMcpToolTrigger("MyWorkflow", description: null); - - Assert.NotNull(metadata.RawBindings); - Assert.Contains("Run the MyWorkflow workflow", metadata.RawBindings[0]); - } -} 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 deleted file mode 100644 index 7b053abe832..00000000000 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests.csproj +++ /dev/null @@ -1,12 +0,0 @@ - - - - $(TargetFrameworksCore) - 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 deleted file mode 100644 index 14b7248fdec..00000000000 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/TestAgent.cs +++ /dev/null @@ -1,36 +0,0 @@ -// 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; - - protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) => new(new DummyAgentSession()); - - protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) - => throw new NotImplementedException(); - - protected override ValueTask DeserializeSessionCoreAsync( - JsonElement serializedState, - JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => new(new DummyAgentSession()); - - protected override Task RunCoreAsync( - IEnumerable messages, - AgentSession? session = null, - AgentRunOptions? options = null, - CancellationToken cancellationToken = default) => Task.FromResult(new AgentResponse([.. messages])); - - protected override IAsyncEnumerable RunCoreStreamingAsync( - IEnumerable messages, - AgentSession? session = null, - AgentRunOptions? options = null, - CancellationToken cancellationToken = default) => throw new NotSupportedException(); - - private sealed class DummyAgentSession : AgentSession; -} diff --git a/python/AGENTS.md b/python/AGENTS.md index 2efd1e89686..5a6e89a7e60 100644 --- a/python/AGENTS.md +++ b/python/AGENTS.md @@ -109,7 +109,8 @@ python/ - [azure-contentunderstanding](packages/azure-contentunderstanding/AGENTS.md) - Azure Content Understanding context provider - [azure-ai-search](packages/azure-ai-search/AGENTS.md) - Azure AI Search RAG - [azure-cosmos](packages/azure-cosmos/AGENTS.md) - Azure Cosmos DB-backed history provider -- [azurefunctions](packages/azurefunctions/AGENTS.md) - Azure Functions hosting + +Durable Task and Azure Functions integrations are maintained in the [Durable Agent Framework extension](https://github.com/microsoft/agent-framework-durable-extension). ### Protocols & UI - [a2a](packages/a2a/AGENTS.md) - Agent-to-Agent protocol @@ -126,7 +127,6 @@ python/ ### Infrastructure - [copilotstudio](packages/copilotstudio/AGENTS.md) - Microsoft Copilot Studio - [declarative](packages/declarative/AGENTS.md) - YAML/JSON agent definitions -- [durabletask](packages/durabletask/AGENTS.md) - Durable execution - [github_copilot](packages/github_copilot/AGENTS.md) - GitHub Copilot extensions - [purview](packages/purview/AGENTS.md) - Data governance diff --git a/python/CHANGELOG.md b/python/CHANGELOG.md index 705d0cd020c..651e86a1501 100644 --- a/python/CHANGELOG.md +++ b/python/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed +- **agent-framework-azurefunctions**, **agent-framework-durabletask**: The Durable Task and Azure Functions integrations (package sources and samples) have moved to the [Durable Agent Framework extension repository](https://github.com/microsoft/agent-framework-durable-extension) and are now developed and published from there. **agent-framework-core** continues to re-export their public symbols via `agent_framework.azure` and to include both packages in its `all` extra (installed from PyPI), so existing imports and `pip install agent-framework[all]` are unaffected. + ## [1.13.0] - 2026-07-30 ### Added diff --git a/python/PACKAGE_STATUS.md b/python/PACKAGE_STATUS.md index 12ca2fa766d..37053838277 100644 --- a/python/PACKAGE_STATUS.md +++ b/python/PACKAGE_STATUS.md @@ -22,7 +22,6 @@ Status is grouped into these buckets: | `agent-framework-azure-ai-search` | `python/packages/azure-ai-search` | `beta` | | `agent-framework-azure-cosmos` | `python/packages/azure-cosmos` | `beta` | | `agent-framework-azure-cosmos-memory` | `python/packages/azure-cosmos-memory` | `alpha` | -| `agent-framework-azurefunctions` | `python/packages/azurefunctions` | `beta` | | `agent-framework-bedrock` | `python/packages/bedrock` | `beta` | | `agent-framework-chatkit` | `python/packages/chatkit` | `beta` | | `agent-framework-claude` | `python/packages/claude` | `beta` | @@ -30,7 +29,6 @@ Status is grouped into these buckets: | `agent-framework-core` | `python/packages/core` | `released` | | `agent-framework-declarative` | `python/packages/declarative` | `released` | | `agent-framework-devui` | `python/packages/devui` | `beta` | -| `agent-framework-durabletask` | `python/packages/durabletask` | `beta` | | `agent-framework-foundry` | `python/packages/foundry` | `released` | | `agent-framework-foundry-hosting` | `python/packages/foundry_hosting` | `beta` | | `agent-framework-foundry-local` | `python/packages/foundry_local` | `beta` | diff --git a/python/packages/azurefunctions/AGENTS.md b/python/packages/azurefunctions/AGENTS.md deleted file mode 100644 index 0bd74a17a54..00000000000 --- a/python/packages/azurefunctions/AGENTS.md +++ /dev/null @@ -1,23 +0,0 @@ -# Azure Functions Package (agent-framework-azurefunctions) - -Hosting agents as Azure Functions. - -## Main Classes - -- **`AgentFunctionApp`** - Azure Functions app wrapper for agents - -## Usage - -```python -from agent_framework.azure import AgentFunctionApp - -app = AgentFunctionApp(agent=my_agent) -``` - -## Import Path - -```python -from agent_framework.azure import AgentFunctionApp -# or directly: -from agent_framework_azurefunctions import AgentFunctionApp -``` diff --git a/python/packages/azurefunctions/LICENSE b/python/packages/azurefunctions/LICENSE deleted file mode 100644 index 9e841e7a26e..00000000000 --- a/python/packages/azurefunctions/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ - 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 deleted file mode 100644 index 5e49671da0e..00000000000 --- a/python/packages/azurefunctions/README.md +++ /dev/null @@ -1,28 +0,0 @@ -# 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 deleted file mode 100644 index 2976a38858d..00000000000 --- a/python/packages/azurefunctions/agent_framework_azurefunctions/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import importlib.metadata - -from ._app import AgentFunctionApp -from ._hitl_context import WorkflowHitlContext - -try: - __version__ = importlib.metadata.version(__name__) -except importlib.metadata.PackageNotFoundError: - __version__ = "0.0.0" # Fallback for development mode - -__all__ = [ - "AgentFunctionApp", - "WorkflowHitlContext", - "__version__", -] diff --git a/python/packages/azurefunctions/agent_framework_azurefunctions/_app.py b/python/packages/azurefunctions/agent_framework_azurefunctions/_app.py deleted file mode 100644 index 99578663c12..00000000000 --- a/python/packages/azurefunctions/agent_framework_azurefunctions/_app.py +++ /dev/null @@ -1,1629 +0,0 @@ -# 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. -""" - -from __future__ import annotations - -import asyncio -import json -import logging -import re -import uuid -from collections.abc import Callable, Mapping -from dataclasses import asdict, dataclass, is_dataclass -from datetime import datetime, timezone -from typing import TYPE_CHECKING, Any, TypeVar, cast - -import azure.durable_functions as df -import azure.functions as func -from agent_framework import SupportsAgentRun, Workflow -from agent_framework._telemetry import mark_feature_used -from agent_framework_durabletask import ( - DEFAULT_MAX_POLL_RETRIES, - DEFAULT_POLL_INTERVAL_SECONDS, - MIMETYPE_APPLICATION_JSON, - MIMETYPE_TEXT_PLAIN, - REQUEST_RESPONSE_FORMAT_JSON, - REQUEST_RESPONSE_FORMAT_TEXT, - THREAD_ID_FIELD, - THREAD_ID_HEADER, - WAIT_FOR_RESPONSE_FIELD, - WAIT_FOR_RESPONSE_HEADER, - AgentResponseCallbackProtocol, - AgentSessionId, - ApiResponseFields, - DurableAgentState, - DurableAIAgent, - RunRequest, - deserialize_workflow_output, - execute_workflow_activity, - plan_workflow_registration, -) -from agent_framework_durabletask._workflows.naming import ( - SUBWORKFLOW_REQUEST_SEPARATOR, - split_subworkflow_request_id, - validate_executor_id, - validate_workflow_name, - workflow_executor_activity_name, - workflow_orchestrator_name, - workflow_scoped_executor_id, -) -from agent_framework_durabletask._workflows.registration import collect_hosted_workflows -from agent_framework_durabletask._workflows.serialization import strip_pickle_markers, strip_subworkflow_markers - -from ._entities import create_agent_entity -from ._errors import IncomingRequestError -from ._feature_usage import FeatureIndex -from ._orchestration import AgentOrchestrationContextType, AgentTask, AzureFunctionsAgentExecutor -from ._routes import build_workflow_respond_url, build_workflow_status_url, split_request_url -from ._workflow import run_workflow_orchestrator - -logger = logging.getLogger("agent_framework.azurefunctions") - -EntityHandler = Callable[[df.DurableEntityContext], None] -HandlerT = TypeVar("HandlerT", bound=Callable[..., Any]) - - -def _json_default(obj: Any) -> Any: - """JSON fallback encoder for reconstructed workflow outputs. - - A workflow's yielded outputs are reconstructed (see ``deserialize_workflow_output``) - before they reach the HTTP response, so they may be framework models - (e.g. ``AgentResponse``), dataclasses, or other non-JSON-native objects. - Prefer the type's own serialization so the response carries clean domain - JSON, falling back to ``str`` for anything without one. - """ - to_dict = getattr(obj, "to_dict", None) - if callable(to_dict): - try: - return to_dict() - except Exception: - logger.debug("to_dict() failed while encoding %s for HTTP output", type(obj).__name__) - model_dump = getattr(obj, "model_dump", None) - if callable(model_dump): - try: - return model_dump(mode="json") - except Exception: - logger.debug("model_dump() failed while encoding %s for HTTP output", type(obj).__name__) - if is_dataclass(obj) and not isinstance(obj, type): - return asdict(obj) - return str(obj) - - -@dataclass -class AgentMetadata: - """Metadata for a registered agent. - - Attributes: - agent: The agent instance implementing SupportsAgentRun - http_endpoint_enabled: Whether HTTP endpoint is enabled for this agent - mcp_tool_enabled: Whether MCP tool endpoint is enabled for this agent - """ - - agent: SupportsAgentRun - http_endpoint_enabled: bool - mcp_tool_enabled: bool - - -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]: ... - - def mcp_tool_trigger( - self, - arg_name: str, - tool_name: str, - description: str, - tool_properties: str, - data_type: func.DataType, - ) -> 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 - from agent_framework.openai import OpenAIChatCompletionClient - - # Create agents with unique names - weather_agent = OpenAIChatCompletionClient(...).as_agent( - name="WeatherAgent", - instructions="You are a helpful weather agent.", - tools=[get_weather], - ) - - math_agent = OpenAIChatCompletionClient(...).as_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") - session = writer.create_session() - forecast_task = writer.run("What's the forecast?", session=session) - 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 SupportsAgentRun instance - enable_health_check: Whether health check endpoint is enabled - enable_http_endpoints: Whether HTTP endpoints are created for agents - enable_mcp_tool_trigger: Whether MCP tool triggers are created for agents - max_poll_retries: Maximum polling attempts when waiting for responses - poll_interval_seconds: Delay (seconds) between polling attempts - workflow: The sole hosted Workflow when exactly one is hosted, else ``None`` - (use :attr:`workflows` to access all hosted workflows by name) - """ - - _agent_metadata: dict[str, AgentMetadata] - _workflows: dict[str, Workflow] - enable_health_check: bool - enable_http_endpoints: bool - enable_mcp_tool_trigger: bool - workflow: Workflow | None - - def __init__( - self, - agents: list[SupportsAgentRun] | None = None, - workflow: Workflow | None = None, - workflows: list[Workflow] | Mapping[str, Workflow] | 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, - enable_mcp_tool_trigger: bool = False, - default_callback: AgentResponseCallbackProtocol | None = None, - ): - """Initialize the AgentFunctionApp. - - :param agents: List of agent instances to register. - :param workflow: Optional single Workflow to host. Convenience alias for - ``workflows=[workflow]``; an app may host several workflows via ``workflows``. - :param workflows: Optional workflows to host, as a list (keyed by each - ``Workflow.name``) or a name->Workflow mapping. Each workflow gets its own - ``dafx-{name}`` orchestration and ``workflow/{name}/...`` HTTP routes. - :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 enable_mcp_tool_trigger: Enable MCP tool triggers for agents (default: ``False``). - When enabled, agents will be exposed as MCP tools that can be invoked by MCP-compatible clients. - :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 agent metadata dictionary - self._agent_metadata = {} - self._workflows: dict[str, Workflow] = {} - # Every workflow whose orchestration has been registered (top-level plus - # nested sub-workflows), keyed by case-folded name -> the registered instance, - # so a shared sub-workflow is registered once while two different workflows - # whose names collide (including case-only differences) are rejected. - self._registered_orchestrations: dict[str, Workflow] = {} - self.enable_health_check = enable_health_check - self.enable_http_endpoints = enable_http_endpoints - self.enable_mcp_tool_trigger = enable_mcp_tool_trigger - 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 - - # Register each hosted workflow. ``workflow=`` is a convenience alias for a - # single-element ``workflows``; both may be combined. - for wf in self._collect_workflows(workflow, workflows): - self._register_workflow(wf) - - # Back-compat: expose the sole workflow as ``.workflow`` when exactly one is - # hosted (multi-workflow apps must address workflows by name). - self.workflow = next(iter(self._workflows.values())) if len(self._workflows) == 1 else None - - 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() - - mark_feature_used(FeatureIndex.AZUREFUNCTIONS) - logger.debug("[AgentFunctionApp] Initialization complete") - - def _collect_workflows( - self, - workflow: Workflow | None, - workflows: list[Workflow] | Mapping[str, Workflow] | None, - ) -> list[Workflow]: - """Combine the ``workflow`` alias and ``workflows`` into a de-duplicated list. - - A name->Workflow mapping must agree with each ``Workflow.name`` so a single - identity drives the durable names and HTTP routes. - - Raises: - ValueError: If a mapping key disagrees with its ``Workflow.name``. - """ - collected: list[Workflow] = [] - if workflow is not None: - collected.append(workflow) - if isinstance(workflows, Mapping): - for key, wf in workflows.items(): - if key != wf.name: - raise ValueError(f"workflows mapping key '{key}' does not match Workflow.name '{wf.name}'.") - collected.append(wf) - elif workflows is not None: - collected.extend(workflows) - return collected - - def _register_workflow(self, workflow: Workflow) -> None: - """Register a top-level workflow's durable primitives and HTTP routes. - - The "what to register" decision (agent -> entity, non-agent -> activity, - sub-workflow -> child orchestration) is shared with the standalone - durabletask host via ``plan_workflow_registration``. Nested sub-workflows - have their orchestration primitives registered (deduped by name) so the - parent can drive them as child orchestrations, but only the top-level - workflow gets HTTP ``workflow/{name}/...`` routes. - - Raises: - ValueError: If the workflow (or a nested sub-workflow) name is - missing/invalid/auto-generated, or a top-level workflow with the - same name is already registered. - """ - validate_workflow_name(workflow.name) - if any(name.casefold() == workflow.name.casefold() for name in self._workflows): - raise ValueError( - f"Workflow '{workflow.name}' is already registered on this app " - "(workflow names are compared case-insensitively)." - ) - - # Validate the whole composition (top-level plus every nested sub-workflow) - # up front, so an invalid/auto-generated nested name (or an executor id that - # would break durable naming / nested-HITL addressing) fails before any - # registration side effects leave the app partially configured. - hosted_workflows = list(collect_hosted_workflows(workflow)) - for hosted in hosted_workflows: - validate_workflow_name(hosted.name) - for executor_id in hosted.executors: - validate_executor_id(executor_id) - - # Check every cross-call collision *before* mutating any state, so a clash - # between a nested sub-workflow and an already-registered orchestration cannot - # leave the app partially configured (e.g. the top-level name added to - # ``_workflows`` while a later child fails). Registration below is then a pure - # commit step. - for hosted in hosted_workflows: - existing = self._registered_orchestrations.get(hosted.name.casefold()) - if existing is not None and existing is not hosted: - raise ValueError( - f"A different workflow named '{hosted.name}' collides with already-registered " - f"'{existing.name}' on this app. A workflow name maps to a single durable " - f"orchestration ('dafx-{hosted.name}'), compared case-insensitively; rename one " - "of them." - ) - - self._workflows[workflow.name] = workflow - - # Commit: register orchestration primitives for the top-level workflow and every - # nested sub-workflow (deduped by name). - for hosted in hosted_workflows: - if hosted.name.casefold() in self._registered_orchestrations: - continue - self._register_workflow_primitives(hosted) - - # HTTP routes are only exposed for the top-level workflow; sub-workflows are - # driven by the parent via call_sub_orchestrator, not addressed directly. - self._register_workflow_routes(workflow) - - def _register_workflow_primitives(self, workflow: Workflow) -> None: - """Register one workflow's entities, activities, and orchestrator (no routes).""" - validate_workflow_name(workflow.name) - self._registered_orchestrations[workflow.name.casefold()] = workflow - - logger.debug("[AgentFunctionApp] Registering workflow '%s'", workflow.name) - plan = plan_workflow_registration(workflow) - for agent_executor in plan.agent_executors: - # Register each workflow agent through the same surface as a standalone - # agent (so it stays tracked in ``agents`` / ``get_agent``), under the - # workflow-scoped entity id ``{workflow}-{executor}`` the orchestrator - # dispatches to. This keeps two co-hosted workflows that reuse an executor - # id from colliding on one global entity name. - self.add_agent( - agent_executor.agent, - callback=self.default_callback, - entity_id=workflow_scoped_executor_id(workflow.name, agent_executor.id), - ) - for executor in plan.activity_executors: - # Set up a Functions activity trigger for each non-agent executor, scoped - # by workflow name to match the orchestrator's dispatch. WorkflowExecutor - # nodes are not registered here: their inner workflows are registered - # separately and driven as child orchestrations. - self._setup_executor_activity(workflow, executor.id) - - self._setup_workflow_orchestration(workflow) - - def _setup_executor_activity(self, workflow: Workflow, executor_id: str) -> None: - """Register an activity for executing a specific non-agent executor. - - Args: - workflow: The owning workflow (scopes the activity name and provides the - executor instance at run time). - executor_id: The ID of the executor to create an activity for. - """ - activity_name = workflow_executor_activity_name(workflow.name, executor_id) - logger.debug(f"[AgentFunctionApp] Registering activity '{activity_name}' for executor '{executor_id}'") - - # Capture the specific workflow + executor id in the closure so the right - # executor runs even when several workflows are hosted. - captured_workflow = workflow - captured_executor_id = executor_id - - @self.function_name(activity_name) - @self.activity_trigger(input_name="inputData") - def executor_activity(inputData: str) -> str: - """Activity to execute a specific non-agent executor. - - Note: We use str type annotations instead of dict to work around - Azure Functions worker type validation issues with dict[str, Any]. - The execution body is shared with the standalone durabletask host via - ``execute_workflow_activity``. - """ - executor = captured_workflow.executors.get(captured_executor_id) - if not executor: - raise ValueError(f"Unknown executor: {captured_executor_id}") - - return execute_workflow_activity(executor, inputData, captured_workflow) - - # Ensure the function is registered (prevents garbage collection) - _ = executor_activity - - def _setup_workflow_orchestration(self, workflow: Workflow) -> None: - """Register a workflow's orchestrator function under its ``dafx-{name}`` name. - - HTTP routes are registered separately (:meth:`_register_workflow_routes`) and - only for top-level workflows; this orchestrator function is registered for - every hosted workflow (including nested sub-workflows) so the parent can drive - them via ``call_sub_orchestrator``. - """ - captured_workflow = workflow - orchestrator_name = workflow_orchestrator_name(workflow.name) - - @self.function_name(orchestrator_name) - @self.orchestration_trigger(context_name="context") - def workflow_orchestrator(context: df.DurableOrchestrationContext) -> Any: - """Generic orchestrator for running the configured workflow.""" - input_data = context.get_input() - - # Pass the deserialized client input straight to the shared engine, which - # reconstructs the start executor's declared type (see _coerce_initial_input). - initial_message = input_data - - # Create local shared state dict for cross-executor state sharing - shared_state: dict[str, Any] = {} - - outputs = yield from run_workflow_orchestrator(context, captured_workflow, initial_message, shared_state) - # Durable Functions runtime extracts return value from StopIteration - return outputs # ruff:ignore[return-in-generator] - - # Ensure the orchestrator function is registered (prevents garbage collection) - _ = workflow_orchestrator - - def _register_workflow_routes(self, workflow: Workflow) -> None: - """Register a top-level workflow's per-workflow HTTP endpoints. - - Routes are scoped by workflow name (``workflow/{name}/run`` etc.) so the URL - shape stays the same whether the app hosts one workflow or many; callers do - not have to change URLs as an app grows. - """ - workflow_name = workflow.name - orchestrator_name = workflow_orchestrator_name(workflow_name) - - @self.function_name(f"{orchestrator_name}-start") - @self.route(route=f"workflow/{workflow_name}/run", methods=["POST"]) - @self.durable_client_input(client_name="client") - async def start_workflow_orchestration( - req: func.HttpRequest, client: df.DurableOrchestrationClient - ) -> func.HttpResponse: - """HTTP endpoint to start the workflow.""" - try: - client_input: Any = req.get_json() - except ValueError: - # The orchestrator accepts plain strings as well as JSON objects, so fall - # back to the raw request body (e.g. text/plain) instead of rejecting it. - raw_body = req.get_body() - if not raw_body: - return self._build_error_response("Request body is required") - client_input = raw_body.decode("utf-8") - - # Neutralize a forged sub-workflow envelope before scheduling: only an - # internal child dispatch (post trust boundary) may carry those reserved - # keys, so stripping them here keeps untrusted input off the orchestrator's - # trusted-deserialization path (see strip_subworkflow_markers). - client_input = strip_subworkflow_markers(client_input) - client_input = strip_pickle_markers(client_input) - - instance_id = await client.start_new(orchestrator_name, client_input=client_input) - - base_url, route_prefix = split_request_url(req.url) - status_url = build_workflow_status_url(base_url, workflow_name, instance_id, prefix=route_prefix) - - return func.HttpResponse( - json.dumps({ - "instanceId": instance_id, - "statusQueryGetUri": status_url, - "respondUri": build_workflow_respond_url( - base_url, workflow_name, instance_id, "{requestId}", prefix=route_prefix - ), - "message": "Workflow started", - }), - status_code=202, - mimetype="application/json", - ) - - @self.function_name(f"{orchestrator_name}-status") - @self.route(route=f"workflow/{workflow_name}/status/{{instanceId}}", methods=["GET"]) - @self.durable_client_input(client_name="client") - async def get_workflow_status( - req: func.HttpRequest, client: df.DurableOrchestrationClient - ) -> func.HttpResponse: - """HTTP endpoint to get workflow status.""" - instance_id = req.route_params.get("instanceId") - if not instance_id: - return self._build_error_response("Instance ID is required", status_code=400) - - status = await client.get_status(instance_id) - - # Scope the endpoint to this workflow's orchestrator. The durable client - # resolves instance IDs across every orchestration in the task hub, so an ID - # belonging to a different orchestration (or a different workflow) must be - # treated as "not found" rather than leaking its status / HITL details. - if not self._is_owned_orchestration(status, workflow_name): - return self._build_error_response("Instance not found", status_code=404) - - # The workflow's yielded outputs are checkpoint-encoded by the shared - # activity (typed objects become pickle/type-marker dicts). Reconstruct - # the originals so the HTTP response carries clean domain JSON, matching - # what DurableWorkflowClient.await_workflow_output returns in-process. - # status.output is the workflow's own (trusted) orchestration result. - decoded_output = deserialize_workflow_output(status.output) if status.output is not None else None - - response = { - "instanceId": status.instance_id, - "runtimeStatus": status.runtime_status.name if status.runtime_status else None, - "customStatus": status.custom_status, - "output": decoded_output, - "error": decoded_output if status.runtime_status == df.OrchestrationRuntimeStatus.Failed else None, - "createdTime": status.created_time.isoformat() if status.created_time else None, - "lastUpdatedTime": status.last_updated_time.isoformat() if status.last_updated_time else None, - } - - # Add pending HITL requests info if available. Requests originating in a - # nested sub-workflow are bubbled up here with a qualified requestId - # ({executorId}~{ordinal}~{requestId}, nested deeper for deeper levels); the - # respondUrl always targets this top-level instance, so the caller has a - # single addressing surface. - custom_status = status.custom_status - if isinstance(custom_status, dict): - gathered = await self._gather_pending_hitl_requests(client, cast("dict[str, Any]", custom_status)) - if gathered: - base_url, route_prefix = split_request_url(req.url) - pending_requests: list[dict[str, Any]] = [ - { - "requestId": qualified_id, - "sourceExecutor": req_data.get("source_executor_id"), - "requestData": req_data.get("data"), - "requestType": req_data.get("request_type"), - "responseType": req_data.get("response_type"), - "respondUrl": build_workflow_respond_url( - base_url, workflow_name, instance_id, qualified_id, prefix=route_prefix - ), - } - for qualified_id, req_data in gathered - ] - response["pendingHumanInputRequests"] = pending_requests - - return func.HttpResponse( - json.dumps(response, default=_json_default), - status_code=200, - mimetype="application/json", - ) - - @self.function_name(f"{orchestrator_name}-respond") - @self.route(route=f"workflow/{workflow_name}/respond/{{instanceId}}/{{requestId}}", methods=["POST"]) - @self.durable_client_input(client_name="client") - async def send_hitl_response(req: func.HttpRequest, client: df.DurableOrchestrationClient) -> func.HttpResponse: - """HTTP endpoint to send a response to a pending HITL request. - - The requestId in the URL corresponds to the request_id from the RequestInfoEvent. - The request body should contain the response data matching the expected response_type. - """ - instance_id = req.route_params.get("instanceId") - request_id = req.route_params.get("requestId") - - if not instance_id or not request_id: - return self._build_error_response("Instance ID and Request ID are required.") - - # Scope the endpoint to this workflow's orchestrator before raising an - # external event, so a leaked instance ID cannot be used to inject events into - # a different orchestration (or a different workflow) in the task hub. - status = await client.get_status(instance_id) - if not self._is_owned_orchestration(status, workflow_name): - return self._build_error_response("Instance not found", status_code=404) - - try: - response_data = req.get_json() - except ValueError: - return self._build_error_response("Request body must be valid JSON.") - - # Sanitize untrusted HTTP input before it reaches pickle.loads(). - # See strip_pickle_markers() docstring for details on the attack vector. - response_data = strip_pickle_markers(response_data) - - # A qualified requestId ({executorId}~{ordinal}~{requestId}) addresses a request that - # originated in a nested sub-workflow: resolve it to the owning child - # orchestration instance and the bare request id it is waiting on. - resolved = await self._resolve_hitl_target(client, instance_id, request_id) - if resolved is None: - return self._build_error_response("Pending request not found", status_code=404) - target_instance_id, bare_request_id = resolved - - # Send the response as an external event. The (bare) request_id is used as the - # event name for correlation on the owning orchestration instance. - await client.raise_event( - instance_id=target_instance_id, - event_name=bare_request_id, - event_data=response_data, - ) - - return func.HttpResponse( - json.dumps({ - "message": "Response delivered successfully", - "instanceId": instance_id, - "requestId": request_id, - }), - status_code=200, - mimetype="application/json", - ) - - # Ensure route handlers are registered (prevents unused function warnings) - _ = start_workflow_orchestration - _ = get_workflow_status - _ = send_hitl_response - - async def _gather_pending_hitl_requests( - self, - client: df.DurableOrchestrationClient, - custom_status: dict[str, Any], - *, - prefix: str = "", - ) -> list[tuple[str, dict[str, Any]]]: - """Collect ``(qualifiedRequestId, requestData)`` pairs for an instance and its sub-workflows. - - ``custom_status`` is the already-fetched custom status of the instance at the - current level. Nested sub-workflows (listed in its ``subworkflows`` map as - ``{executorId: [childInstanceId, ...]}``) are fetched by id and recursed into, - accumulating an ``{executorId}~{ordinal}~`` prefix so a request deep in the tree - carries its full path and a node with several children this superstep keeps each - child distinctly addressable. Child instances come from the trusted parent - status, so no per-child ownership check is applied (the caller validated the - top-level instance). - """ - gathered: list[tuple[str, dict[str, Any]]] = [] - - pending = custom_status.get("pending_requests") - if isinstance(pending, dict): - for req_id, req_data in cast("dict[str, Any]", pending).items(): - if isinstance(req_data, dict): - typed = cast("dict[str, Any]", req_data) - # Use the request's own id field (the event name the orchestrator - # waits on), falling back to the map key; the durabletask client - # qualifies against the same value so a qualified id round-trips. - bare_id = typed.get("request_id", req_id) - gathered.append((f"{prefix}{bare_id}", typed)) - - subworkflows = custom_status.get("subworkflows") - if isinstance(subworkflows, dict): - sep = SUBWORKFLOW_REQUEST_SEPARATOR - for executor_id, child_ids in cast("dict[str, Any]", subworkflows).items(): - children: list[Any] = cast("list[Any]", child_ids) if isinstance(child_ids, list) else [] - for ordinal, child_instance_id in enumerate(children): - if not isinstance(child_instance_id, str): - continue - child_status = await client.get_status(child_instance_id) - child_custom = child_status.custom_status if child_status else None - if isinstance(child_custom, dict): - gathered.extend( - await self._gather_pending_hitl_requests( - client, - cast("dict[str, Any]", child_custom), - prefix=f"{prefix}{executor_id}{sep}{ordinal}{sep}", - ) - ) - - return gathered - - async def _resolve_hitl_target( - self, - client: df.DurableOrchestrationClient, - instance_id: str, - request_id: str, - ) -> tuple[str, str] | None: - """Resolve a possibly-qualified request id to ``(owningInstanceId, bareRequestId)``. - - An unqualified id (no well-formed hop) targets ``instance_id`` directly. A - qualified id ``{executorId}~{ordinal}~{rest}`` addresses a nested sub-workflow: - the executor's child instance id is read from this instance's ``subworkflows`` - custom-status map (a list selected by ``ordinal``) and the remainder resolved - recursively. Returns ``None`` when a referenced sub-workflow child is not - currently active (so the caller can return "not found"). - """ - hop = split_subworkflow_request_id(request_id) - if hop is None: - return instance_id, request_id - - executor_id, ordinal, remainder = hop - status = await client.get_status(instance_id) - custom_status = status.custom_status if status else None - if not isinstance(custom_status, dict): - return None - subworkflows = cast("dict[str, Any]", custom_status).get("subworkflows") - if not isinstance(subworkflows, dict): - return None - children_raw = cast("dict[str, Any]", subworkflows).get(executor_id) - if not isinstance(children_raw, list): - return None - children = cast("list[Any]", children_raw) - if ordinal < 0 or ordinal >= len(children): - return None - child_instance_id = children[ordinal] - if not isinstance(child_instance_id, str): - return None - return await self._resolve_hitl_target(client, child_instance_id, remainder) - - def _is_owned_orchestration(self, status: Any, workflow_name: str) -> bool: - """Return whether a durable orchestration status belongs to the named workflow. - - The ``workflow/{name}/status`` and ``.../respond`` endpoints address instances - by ``instanceId`` alone, but the durable client resolves IDs across *every* - orchestration in the task hub -- agent entities, other workflows on this app, - any user-registered orchestrations, and other apps sharing the hub. Without - this check a caller holding one instance ID could read another orchestration's - status (including pending HITL request payloads) or inject external events into - it. Scoping to the route's ``dafx-{workflow_name}`` orchestration keeps each - endpoint bound to its own workflow; anything else is treated as "not found". - - The orchestration name is compared case-insensitively so the check stays robust - to host/runtime casing differences. - """ - expected = workflow_orchestrator_name(workflow_name) - name = getattr(status, "name", None) - return isinstance(name, str) and name.casefold() == expected.casefold() - - @property - def agents(self) -> dict[str, SupportsAgentRun]: - """Returns dict of agent names to agent instances. - - Returns: - Dictionary mapping agent names to their SupportsAgentRun instances. - """ - return {name: metadata.agent for name, metadata in self._agent_metadata.items()} - - @property - def workflows(self) -> dict[str, Workflow]: - """Returns a dict of workflow name to the hosted :class:`Workflow` instances.""" - return dict(self._workflows) - - def add_agent( - self, - agent: SupportsAgentRun, - callback: AgentResponseCallbackProtocol | None = None, - enable_http_endpoint: bool | None = None, - enable_mcp_tool_trigger: bool | None = None, - *, - entity_id: str | None = None, - ) -> None: - """Add an agent to the function app after initialization. - - Args: - agent: The Microsoft Agent Framework agent instance (must implement SupportsAgentRun) - The agent must have a 'name' attribute. - callback: Optional callback invoked during agent execution - enable_http_endpoint: Optional flag to enable/disable HTTP endpoint for this agent. - The app level enable_http_endpoints setting will override this setting. - enable_mcp_tool_trigger: Optional flag to enable/disable MCP tool trigger for this agent. - The app level enable_mcp_tool_trigger setting will override this setting. - entity_id: Optional identity to register the agent under instead of - ``agent.name``. Workflow hosting passes the executor's ``id`` so the - durable entity (and the ``agents`` / ``get_agent`` key) matches the - identity the orchestrator dispatches to. Mirrors - ``DurableAIAgentWorker.add_agent(entity_id=...)``. - - Raises: - ValueError: If the agent doesn't have a 'name' attribute. - """ - # 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.") - - # The registration name keys the agent everywhere on this app (metadata, - # routes, entity). It defaults to the agent name but can be overridden so a - # workflow agent is keyed by its executor id. - registration_name = entity_id or name - - if registration_name in self._agent_metadata: - logger.warning( - "[AgentFunctionApp] Agent '%s' is already registered, skipping duplicate.", registration_name - ) - return - - effective_enable_http_endpoint = ( - self.enable_http_endpoints if enable_http_endpoint is None else self._coerce_to_bool(enable_http_endpoint) - ) - effective_enable_mcp_endpoint = ( - self.enable_mcp_tool_trigger - if enable_mcp_tool_trigger is None - else self._coerce_to_bool(enable_mcp_tool_trigger) - ) - - logger.debug(f"[AgentFunctionApp] Adding agent: {registration_name}") - logger.debug(f"[AgentFunctionApp] Route: /api/agents/{registration_name}") - logger.debug( - "[AgentFunctionApp] HTTP endpoint %s for agent '%s'", - "enabled" if effective_enable_http_endpoint else "disabled", - registration_name, - ) - logger.debug( - f"[AgentFunctionApp] MCP tool trigger: {'enabled' if effective_enable_mcp_endpoint else 'disabled'}" - ) - - # Store agent metadata - self._agent_metadata[registration_name] = AgentMetadata( - agent=agent, - http_endpoint_enabled=effective_enable_http_endpoint, - mcp_tool_enabled=effective_enable_mcp_endpoint, - ) - - effective_callback = callback or self.default_callback - - self._setup_agent_functions( - agent, registration_name, effective_callback, effective_enable_http_endpoint, effective_enable_mcp_endpoint - ) - - logger.debug(f"[AgentFunctionApp] Agent '{registration_name}' added successfully") - - def get_agent( - self, - context: AgentOrchestrationContextType, - agent_name: str, - workflow_name: str | None = None, - ) -> DurableAIAgent[AgentTask]: - """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. For an agent that - belongs to a hosted workflow, pass ``workflow_name`` to resolve it - under its workflow-scoped identity; for an agent registered standalone - via ``agents=`` / ``add_agent`` use its bare name. - workflow_name: Optional owning workflow name. When given, the agent is - resolved under the scoped id ``{workflow_name}-{agent_name}``. - - Returns: - DurableAIAgent[AgentTask] wrapper bound to the orchestration context. - - Raises: - ValueError: If the requested agent has not been registered. - """ - normalized_name = ( - workflow_scoped_executor_id(workflow_name, str(agent_name)) if workflow_name else str(agent_name) - ) - - if normalized_name not in self._agent_metadata: - raise ValueError(f"Agent '{normalized_name}' is not registered with this app.") - - executor = AzureFunctionsAgentExecutor(context) - return DurableAIAgent(executor, normalized_name) - - def _setup_agent_functions( - self, - agent: SupportsAgentRun, - agent_name: str, - callback: AgentResponseCallbackProtocol | None, - enable_http_endpoint: bool, - enable_mcp_tool_trigger: bool, - ) -> None: - """Set up the HTTP trigger, entity, and MCP tool trigger 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 to create HTTP endpoint - enable_mcp_tool_trigger: Whether to create MCP tool trigger - """ - 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) - - if enable_mcp_tool_trigger: - agent_description = agent.description - self._setup_mcp_tool_trigger(agent_name, agent_description) - else: - logger.debug(f"[AgentFunctionApp] MCP tool trigger disabled for agent '{agent_name}'") - - 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) - } - """ - request_response_format: str = REQUEST_RESPONSE_FORMAT_JSON - thread_id: str | None = None - - try: - req_body, message, request_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}, Thread ID: {thread_id}, 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, - request_response_format=request_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] Calling entity to run agent using session ID: {session_id} " - f"and correlation ID: {correlation_id}" - ) - - entity_instance_id = df.EntityId( - name=session_id.entity_name, - key=session_id.key, - ) - run_request = self._build_request_data( - req_body, - message, - correlation_id, - request_response_format, - ) - logger.debug("Signalling entity %s with request: %s", entity_instance_id, run_request) - await client.signal_entity(entity_instance_id, "run", 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, - request_response_format=request_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, - request_response_format=request_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, - request_response_format=request_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, - request_response_format=request_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, - request_response_format=request_response_format, - thread_id=thread_id, - ) - - _ = http_start - - def _setup_agent_entity( - self, - agent: SupportsAgentRun, - 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: Execute the agent with a message - - run_agent: (Deprecated) 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_mcp_tool_trigger(self, agent_name: str, agent_description: str | None) -> None: - """Register an MCP tool trigger for an agent using Azure Functions native MCP support. - - This creates a native Azure Functions MCP tool trigger that exposes the agent - as an MCP tool, allowing it to be invoked by MCP-compatible clients. - - Args: - agent_name: The agent name (used as the MCP tool name) - agent_description: Optional description for the MCP tool (shown to clients) - """ - mcp_function_name = self._build_function_name(agent_name, "mcptool") - - # Define tool properties as JSON (MCP tool parameters) - tool_properties = json.dumps([ - { - "propertyName": "query", - "propertyType": "string", - "description": "The query to send to the agent.", - "isRequired": True, - "isArray": False, - }, - { - "propertyName": "threadId", - "propertyType": "string", - "description": "Optional thread identifier for conversation continuity.", - "isRequired": False, - "isArray": False, - }, - ]) - - function_name_decorator = self.function_name(mcp_function_name) - mcp_tool_decorator = self.mcp_tool_trigger( - arg_name="context", - tool_name=agent_name, - description=agent_description or f"Interact with {agent_name} agent", - tool_properties=tool_properties, - data_type=func.DataType.UNDEFINED, - ) - durable_client_decorator = self.durable_client_input(client_name="client") - - @function_name_decorator - @mcp_tool_decorator - @durable_client_decorator - async def mcp_tool_handler(context: str, client: df.DurableOrchestrationClient) -> str: - """Handle MCP tool invocation for the agent. - - Args: - context: MCP tool invocation context containing arguments (query, threadId) - client: Durable orchestration client for entity communication - - Returns: - Agent response text - """ - logger.debug("[MCP Tool Trigger] Received invocation for agent: %s", agent_name) - return await self._handle_mcp_tool_invocation(agent_name=agent_name, context=context, client=client) - - _ = mcp_tool_handler - logger.debug("[AgentFunctionApp] Registered MCP tool trigger for agent: %s", agent_name) - - async def _handle_mcp_tool_invocation( - self, agent_name: str, context: str, client: df.DurableOrchestrationClient - ) -> str: - """Handle an MCP tool invocation. - - This method processes MCP tool requests and delegates to the agent entity. - - Args: - agent_name: Name of the agent being invoked - context: MCP tool invocation context as a JSON string - client: Durable orchestration client - - Returns: - Agent response text - - Raises: - ValueError: If required arguments are missing or context is invalid JSON - RuntimeError: If agent execution fails - """ - logger.debug("[MCP Tool Handler] Processing invocation for agent '%s'", agent_name) - - # Parse JSON context string - try: - parsed_context: Any = json.loads(context) - except json.JSONDecodeError as e: - raise ValueError(f"Invalid MCP context format: {e}") from e - - parsed_context = cast(Mapping[str, Any], parsed_context) if isinstance(parsed_context, dict) else {} - - # Extract arguments from MCP context - arguments: dict[str, Any] = parsed_context.get("arguments", {}) - - # Validate required 'query' argument - query: Any = arguments.get("query") - if not query or not isinstance(query, str): - raise ValueError("MCP Tool invocation is missing required 'query' argument of type string.") - - # Extract optional threadId - thread_id = arguments.get("threadId") - - # Create or parse session ID - if thread_id and isinstance(thread_id, str) and thread_id.strip(): - try: - session_id = AgentSessionId.parse(thread_id, agent_name=agent_name) - except ValueError as e: - logger.warning( - "Failed to parse AgentSessionId from thread_id '%s': %s. Falling back to new session ID.", - thread_id, - e, - ) - session_id = AgentSessionId(name=agent_name, key=thread_id) - else: - # Generate new session ID - session_id = AgentSessionId.with_random_key(agent_name) - - # Build entity instance ID - entity_instance_id = df.EntityId( - name=session_id.entity_name, - key=session_id.key, - ) - - # Create run request - correlation_id = self._generate_unique_id() - run_request = self._build_request_data( - req_body={"message": query, "role": "user"}, - message=query, - correlation_id=correlation_id, - request_response_format=REQUEST_RESPONSE_FORMAT_TEXT, - ) - - query_preview = query[:50] + "..." if len(query) > 50 else query - logger.info("[MCP Tool] Invoking agent '%s' with query: %s", agent_name, query_preview) - - # Signal entity to run agent - await client.signal_entity(entity_instance_id, "run", run_request) - - # Poll for response (similar to HTTP handler) - try: - result = await self._get_response_from_entity( - client=client, - entity_instance_id=entity_instance_id, - correlation_id=correlation_id, - message=query, - thread_id=str(session_id), - ) - - # Extract and return response text - if result.get("status") == "success": - response_text = str(result.get("response", "No response")) - logger.info("[MCP Tool] Agent '%s' responded successfully", agent_name) - return response_text - error_msg = result.get("error", "Unknown error") - logger.error("[MCP Tool] Agent '%s' execution failed: %s", agent_name, error_msg) - raise RuntimeError(f"Agent execution failed: {error_msg}") - - except Exception as exc: - logger.error("[MCP Tool] Error invoking agent '%s': %s", agent_name, exc, exc_info=True) - raise - - 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(metadata.agent).__name__, - "http_endpoint_enabled": metadata.http_endpoint_enabled, - "mcp_tool_enabled": metadata.mcp_tool_enabled, - } - for name, metadata in self._agent_metadata.items() - ] - return func.HttpResponse( - json.dumps({"status": "healthy", "agents": agent_info, "agent_count": len(self._agent_metadata)}), - status_code=200, - mimetype=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, - ) -> DurableAgentState | 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) - - return DurableAgentState.from_dict(typed_state_payload) - - 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.""" - 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_message=agent_response.text, - 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 - - def _build_response_payload( - self, - *, - response: str | None, - message: str, - thread_id: str, - status: str, - correlation_id: str, - extra_fields: dict[str, Any] | None = None, - ) -> dict[str, Any]: - """Create a consistent response structure and allow optional extra fields.""" - payload = { - "response": response, - "message": message, - THREAD_ID_FIELD: thread_id, - "status": status, - "correlation_id": correlation_id, - } - if extra_fields: - payload.update(extra_fields) - return payload - - async def _build_timeout_result(self, message: str, thread_id: str, correlation_id: str) -> dict[str, Any]: - """Create the timeout response.""" - return self._build_response_payload( - response="Agent is still processing or timed out...", - message=message, - thread_id=thread_id, - status="timeout", - correlation_id=correlation_id, - ) - - def _build_success_result( - self, response_message: str, message: str, thread_id: str, correlation_id: str, state: DurableAgentState - ) -> dict[str, Any]: - """Build the success result returned to the HTTP caller.""" - return self._build_response_payload( - response=response_message, - message=message, - thread_id=thread_id, - status="success", - correlation_id=correlation_id, - extra_fields={ApiResponseFields.MESSAGE_COUNT: state.message_count}, - ) - - def _build_request_data( - self, - req_body: dict[str, Any], - message: str, - correlation_id: str, - request_response_format: 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"), - request_response_format=request_response_format, - response_format=req_body.get("response_format"), - enable_tool_calls=enable_tool_calls, - correlation_id=correlation_id, - created_at=datetime.now(timezone.utc), - ).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 self._build_response_payload( - response="Agent request accepted", - message=message, - thread_id=thread_id, - status="accepted", - correlation_id=correlation_id, - ) - - def _create_http_response( - self, - payload: dict[str, Any] | str, - status_code: int, - request_response_format: str, - thread_id: str | None, - ) -> func.HttpResponse: - """Create the HTTP response using helper serializers for clarity.""" - if request_response_format == REQUEST_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 = {THREAD_ID_HEADER: thread_id} if thread_id is not None else None - return func.HttpResponse(body_text, status_code=status_code, mimetype=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=MIMETYPE_APPLICATION_JSON) - - @staticmethod - def _build_error_response(message: str, status_code: int = 400) -> func.HttpResponse: - """Return a JSON error response with the given message and status code.""" - return func.HttpResponse( - json.dumps({"error": message}), - status_code=status_code, - mimetype=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.""" - return uuid.uuid4().hex - - def _create_session_id(self, agent_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=agent_name, key=thread_id) - return AgentSessionId.with_random_key(name=agent_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) - request_response_format = self._select_request_response_format( - body_format=body_format, prefers_json=prefers_json - ) - - req_body, message = body_parser(req) - return req_body, message, request_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 - for key, value in cast(Mapping[str, str], raw_headers).items(): - headers[key.lower()] = 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 {MIMETYPE_APPLICATION_JSON} or normalized_content_type.endswith("+json"): - return self._parse_json_body, REQUEST_RESPONSE_FORMAT_JSON - return self._parse_text_body, REQUEST_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 == MIMETYPE_APPLICATION_JSON: - return True - return False - - @staticmethod - def _select_request_response_format(body_format: str, prefers_json: bool) -> str: - """Combine body format and accept preference to determine response format.""" - if body_format == REQUEST_RESPONSE_FORMAT_JSON or prefers_json: - return REQUEST_RESPONSE_FORMAT_JSON - return REQUEST_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/_context.py b/python/packages/azurefunctions/agent_framework_azurefunctions/_context.py deleted file mode 100644 index 66ecff12cc9..00000000000 --- a/python/packages/azurefunctions/agent_framework_azurefunctions/_context.py +++ /dev/null @@ -1,193 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Runner context for Azure Functions activity execution. - -This module provides the CapturingRunnerContext class that captures messages -and events produced during executor execution within Azure Functions activities. -""" - -from __future__ import annotations - -import asyncio -from copy import copy -from typing import Any - -from agent_framework import ( - CheckpointStorage, - RunnerContext, - WorkflowCheckpoint, - WorkflowEvent, - WorkflowMessage, -) -from agent_framework._workflows._runner_context import YieldOutputClassifier, YieldOutputEventType -from agent_framework._workflows._state import State - - -class CapturingRunnerContext(RunnerContext): - """A RunnerContext implementation that captures messages and events for Azure Functions activities. - - This context is designed for executing standard Executors within Azure Functions activities. - It captures all messages and events produced during execution without requiring durable - entity storage, allowing the results to be returned to the orchestrator. - - Unlike InProcRunnerContext, this implementation does NOT support checkpointing - (always returns False for has_checkpointing). The orchestrator manages state - coordination; this context just captures execution output. - """ - - def __init__(self) -> None: - """Initialize the capturing runner context.""" - self._messages: dict[str, list[WorkflowMessage]] = {} - self._event_queue: asyncio.Queue[WorkflowEvent] = asyncio.Queue() - self._pending_request_info_events: dict[str, WorkflowEvent[Any]] = {} - self._workflow_id: str | None = None - self._streaming: bool = False - self._yield_output_classifier: YieldOutputClassifier = lambda _executor_id: "output" - - # region Messaging - - async def send_message(self, message: WorkflowMessage) -> None: - """Capture a message sent by an executor.""" - self._messages.setdefault(message.source_id, []) - self._messages[message.source_id].append(message) - - async def drain_messages(self) -> dict[str, list[WorkflowMessage]]: - """Drain and return all captured messages.""" - messages = copy(self._messages) - self._messages.clear() - return messages - - async def has_messages(self) -> bool: - """Check if there are any captured messages.""" - return bool(self._messages) - - # endregion Messaging - - # region Events - - async def add_event(self, event: WorkflowEvent) -> None: - """Capture an event produced during execution.""" - await self._event_queue.put(event) - - async def drain_events(self) -> list[WorkflowEvent]: - """Drain all currently queued events without blocking.""" - events: list[WorkflowEvent] = [] - while True: - try: - events.append(self._event_queue.get_nowait()) - except asyncio.QueueEmpty: - break - return events - - async def has_events(self) -> bool: - """Check if there are any queued events.""" - return not self._event_queue.empty() - - async def next_event(self) -> WorkflowEvent: - """Wait for and return the next event.""" - return await self._event_queue.get() - - # endregion Events - - # region Checkpointing (not supported in activity context) - - def has_checkpointing(self) -> bool: - """Checkpointing is not supported in activity context.""" - return False - - def set_runtime_checkpoint_storage(self, storage: CheckpointStorage) -> None: - """No-op: checkpointing not supported in activity context.""" - pass - - def clear_runtime_checkpoint_storage(self) -> None: - """No-op: checkpointing not supported in activity context.""" - pass - - async def create_checkpoint( - self, - workflow_name: str, - graph_signature_hash: str, - state: State, - previous_checkpoint_id: str | None, - iteration_count: int, - metadata: dict[str, Any] | None = None, - ) -> str: - """Checkpointing not supported in activity context.""" - raise NotImplementedError("Checkpointing is not supported in Azure Functions activity context") - - async def build_checkpoint( - self, - workflow_name: str, - graph_signature_hash: str, - state: State, - previous_checkpoint_id: str | None, - iteration_count: int, - metadata: dict[str, Any] | None = None, - ) -> WorkflowCheckpoint: - """Checkpointing not supported in activity context.""" - raise NotImplementedError("Checkpointing is not supported in Azure Functions activity context") - - async def load_checkpoint(self, checkpoint_id: str) -> WorkflowCheckpoint | None: - """Checkpointing not supported in activity context.""" - raise NotImplementedError("Checkpointing is not supported in Azure Functions activity context") - - async def apply_checkpoint(self, checkpoint: WorkflowCheckpoint) -> None: - """Checkpointing not supported in activity context.""" - raise NotImplementedError("Checkpointing is not supported in Azure Functions activity context") - - # endregion Checkpointing - - # region Workflow Configuration - - def set_workflow_id(self, workflow_id: str) -> None: - """Set the workflow ID.""" - self._workflow_id = workflow_id - - def reset_for_new_run(self) -> None: - """Reset the context for a new run.""" - self._messages.clear() - self._event_queue = asyncio.Queue() - self._pending_request_info_events.clear() - self._streaming = False - - def set_streaming(self, streaming: bool) -> None: - """Set streaming mode (not used in activity context).""" - self._streaming = streaming - - def is_streaming(self) -> bool: - """Check if streaming mode is enabled (always False in activity context).""" - return self._streaming - - def set_yield_output_classifier(self, classifier: YieldOutputClassifier) -> None: - """Set the classifier used by WorkflowContext.yield_output().""" - self._yield_output_classifier = classifier - - def classify_yielded_output(self, executor_id: str) -> YieldOutputEventType | None: - """Classify an executor's yield_output payload as output, intermediate, or hidden.""" - return self._yield_output_classifier(executor_id) - - # endregion Workflow Configuration - - # region Request Info Events - - async def add_request_info_event(self, event: WorkflowEvent[Any]) -> None: - """Add a request_info WorkflowEvent and track it for correlation.""" - self._pending_request_info_events[event.request_id] = event - await self.add_event(event) - - async def send_request_info_response(self, request_id: str, response: Any) -> None: - """Send a response correlated to a pending request. - - Note: This is not supported in activity context since human-in-the-loop - scenarios require orchestrator-level coordination. - """ - raise NotImplementedError( - "send_request_info_response is not supported in Azure Functions activity context. " - "Human-in-the-loop scenarios should be handled at the orchestrator level." - ) - - async def get_pending_request_info_events(self) -> dict[str, WorkflowEvent[Any]]: - """Get the mapping of request IDs to their corresponding request_info events.""" - return dict(self._pending_request_info_events) - - # endregion Request Info Events diff --git a/python/packages/azurefunctions/agent_framework_azurefunctions/_entities.py b/python/packages/azurefunctions/agent_framework_azurefunctions/_entities.py deleted file mode 100644 index 9de242efaa6..00000000000 --- a/python/packages/azurefunctions/agent_framework_azurefunctions/_entities.py +++ /dev/null @@ -1,118 +0,0 @@ -# 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. -""" - -from __future__ import annotations - -import logging -from collections.abc import Callable -from typing import Any, cast - -import azure.durable_functions as df -from agent_framework import SupportsAgentRun -from agent_framework_durabletask import ( - AgentEntity, - AgentEntityStateProviderMixin, - AgentResponseCallbackProtocol, - run_agent_coroutine, -) - -logger = logging.getLogger("agent_framework.azurefunctions") - - -class AzureFunctionEntityStateProvider(AgentEntityStateProviderMixin): - """Azure Functions Durable Entity state provider for AgentEntity. - - This class utilizes the Durable Entity context from `azure-functions-durable` package - to get and set the state of the agent entity. - """ - - def __init__(self, context: df.DurableEntityContext) -> None: - self._context = context - - def _get_state_dict(self) -> dict[str, Any]: - raw_state = self._context.get_state(lambda: {}) - if not isinstance(raw_state, dict): - return {} - return cast(dict[str, Any], raw_state) - - def _set_state_dict(self, state: dict[str, Any]) -> None: - self._context.set_state(state) - - def _get_thread_id_from_entity(self) -> str: - return str(self._context.entity_key) - - -def create_agent_entity( - agent: SupportsAgentRun, - 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 SupportsAgentRun) - 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("[entity_function] Operation: %s", context.operation_name) - - state_provider = AzureFunctionEntityStateProvider(context) - entity = AgentEntity(agent, callback, state_provider=state_provider) - - operation = context.operation_name - - if operation == "run" or 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(request) - context.set_result(result.to_dict()) - - elif operation == "reset": - entity.reset() - context.set_result({"status": "reset"}) - - else: - logger.error("[entity_function] Unknown operation: %s", operation) - context.set_result({"error": f"Unknown operation: {operation}"}) - - logger.info("[entity_function] Operation %s completed successfully", operation) - - except Exception as exc: - logger.exception("[entity_function] Error executing entity operation %s", exc) - context.set_result({"error": str(exc), "status": "error"}) - - def entity_function(context: df.DurableEntityContext) -> None: - """Synchronous wrapper invoked by the Durable Functions runtime. - - All agent coroutines run on a single process-wide persistent event loop - (see ``run_agent_coroutine``). This keeps async resources created by - shared agent clients/credentials bound to a live loop across every - invocation, preventing cross-loop hangs when the host dispatches - successive entity operations onto different worker threads. - """ - try: - run_agent_coroutine(_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 deleted file mode 100644 index f4f38d32c39..00000000000 --- a/python/packages/azurefunctions/agent_framework_azurefunctions/_errors.py +++ /dev/null @@ -1,11 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Custom exception types for the durable agent framework.""" - - -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/_feature_usage.py b/python/packages/azurefunctions/agent_framework_azurefunctions/_feature_usage.py deleted file mode 100644 index 4bc0ffef613..00000000000 --- a/python/packages/azurefunctions/agent_framework_azurefunctions/_feature_usage.py +++ /dev/null @@ -1,9 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -from enum import IntEnum - - -class FeatureIndex(IntEnum): - """Azure Functions-owned feature-usage indexes.""" - - AZUREFUNCTIONS = 78 diff --git a/python/packages/azurefunctions/agent_framework_azurefunctions/_hitl_context.py b/python/packages/azurefunctions/agent_framework_azurefunctions/_hitl_context.py deleted file mode 100644 index 7c43baf842f..00000000000 --- a/python/packages/azurefunctions/agent_framework_azurefunctions/_hitl_context.py +++ /dev/null @@ -1,223 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Human-in-the-loop (HITL) addressing helper for workflow executors. - -When a MAF :class:`~agent_framework.Workflow` runs on the Azure Functions durable -host, an executor can ask a human for input via ``ctx.request_info(...)``. To notify -that human out-of-band (for example by emailing them an approval link), the executor -needs the orchestration's ``instanceId`` and the request's ``requestId`` so it can -build the ``/respond`` URL the reviewer will POST back to. - -:class:`WorkflowHitlContext` packages that addressing. It reads the orchestration -metadata the durable host surfaces on the executor's runner context (see -``CapturingRunnerContext.host_metadata``) and builds the canonical respond/status -URLs that :class:`~agent_framework_azurefunctions.AgentFunctionApp` exposes -- so the -executor never has to thread the instance id or base URL by hand. - -Typical use, from inside a notify executor reached by an edge from the executor that -called ``request_info``:: - - hitl = WorkflowHitlContext.from_context(ctx) - if hitl is not None: # None when not on the Azure Functions durable host - url = hitl.build_respond_url(request_id) - send_email(to=reviewer, body=f"Approve or reject here: {url}") -""" - -from __future__ import annotations - -import os -from dataclasses import dataclass -from typing import Any, cast - -from agent_framework_durabletask._workflows.runner_context import ( - HOST_METADATA_INSTANCE_ID, - HOST_METADATA_REQUEST_PATH_PREFIX, - HOST_METADATA_WORKFLOW_NAME, -) - -from ._routes import build_workflow_respond_url, build_workflow_status_url - -# App setting carrying the function app's host (e.g. ``myapp.azurewebsites.net``). -# Azure Functions sets this automatically in the cloud; for local ``func start`` runs -# add it to the ``Values`` map in ``local.settings.json`` (e.g. ``localhost:7071``). -WEBSITE_HOSTNAME_ENV = "WEBSITE_HOSTNAME" - -# Loopback hosts that resolve to ``http`` (not ``https``) when WEBSITE_HOSTNAME is -# host-only; covers the addresses ``func start`` can bind locally. -_LOOPBACK_HOSTS = frozenset({"localhost", "0.0.0.0", "::1"}) # ruff:ignore[hardcoded-bind-all-interfaces] # nosec B104 - - -def _is_loopback(host: str) -> bool: - """Return whether ``host`` (optionally ``host:port``) is a local loopback address. - - Handles ``localhost``, IPv4 ``127.0.0.0/8`` and ``0.0.0.0``, and IPv6 ``::1`` - (including the bracketed ``[::1]:port`` form ``func start`` prints). - """ - normalized = host.strip().lower() - if normalized.startswith("["): # bracketed IPv6 like [::1]:7071 - normalized = normalized[1 : normalized.find("]")] if "]" in normalized else normalized[1:] - elif normalized.count(":") == 1: # host:port (bare IPv6 has multiple colons) - normalized = normalized.split(":", 1)[0] - return normalized in _LOOPBACK_HOSTS or normalized.startswith("127.") - - -@dataclass(frozen=True) -class WorkflowHitlContext: - """Builds Azure Functions HITL respond/status URLs from inside a workflow executor. - - Obtain one with :meth:`from_context`. It exposes the addressable *root* - orchestration's ``instance_id`` and ``workflow_name`` and builds the URLs an - external reviewer uses to resume the workflow. When the executor runs inside a - nested sub-workflow, ``request_path_prefix`` carries the ``{executor}~{ordinal}~`` - hops from the root down to this level, so :meth:`build_respond_url` qualifies a bare - request id back to the top-level instance automatically. The base URL is resolved - lazily (see :attr:`base_url`) from an explicit override or the ``WEBSITE_HOSTNAME`` - app setting. - """ - - instance_id: str - workflow_name: str - base_url_override: str | None = None - request_path_prefix: str = "" - - @classmethod - def from_context( - cls, - ctx: Any, - *, - base_url: str | None = None, - ) -> WorkflowHitlContext | None: - """Build a HITL context from a workflow executor's ``WorkflowContext``. - - Reads the orchestration metadata the durable host attached to the executor's - runner context. Returns ``None`` when that metadata is absent -- i.e. the same - executor is running in-process rather than on the Azure Functions durable host - -- so callers can skip notification and degrade gracefully. - - Args: - ctx: The ``WorkflowContext`` passed to the executor's handler. - base_url: Optional explicit base URL (scheme + host, e.g. - ``https://contoso.example.com``). Use this when the public URL differs - from ``WEBSITE_HOSTNAME`` -- for example behind a custom domain or API - Management gateway, where ``WEBSITE_HOSTNAME`` still reports the default - ``*.azurewebsites.net`` host. When omitted, the base URL is resolved - from ``WEBSITE_HOSTNAME`` on first use. - - Returns: - A :class:`WorkflowHitlContext`, or ``None`` if not running on a durable host. - """ - runner_context = getattr(ctx, "_runner_context", None) - raw_metadata = getattr(runner_context, "host_metadata", None) - if not isinstance(raw_metadata, dict): - return None - metadata = cast("dict[str, Any]", raw_metadata) - - instance_id = metadata.get(HOST_METADATA_INSTANCE_ID) - workflow_name = metadata.get(HOST_METADATA_WORKFLOW_NAME) - if not isinstance(instance_id, str) or not isinstance(workflow_name, str): - return None - - # Present when the executor runs inside a nested sub-workflow; absent/empty at - # the top level. Defaults to "" so the request id is used unqualified. - raw_prefix = metadata.get(HOST_METADATA_REQUEST_PATH_PREFIX) - request_path_prefix = raw_prefix if isinstance(raw_prefix, str) else "" - - return cls( - instance_id=instance_id, - workflow_name=workflow_name, - base_url_override=base_url, - request_path_prefix=request_path_prefix, - ) - - @staticmethod - async def pending_request_id(ctx: Any) -> str | None: - """Return the id of the most recently emitted ``request_info`` on ``ctx``. - - Call this **immediately after** ``await ctx.request_info(...)`` to recover the - request id the framework generated, so it can be forwarded (e.g. in a message - to a downstream notify executor that builds the respond URL) without the caller - generating an id by hand. - - Why "immediately after" is the rule, and why it is safe on the durable host: - the returned id is simply the newest entry in the executor's pending - request-info set, so reading right after a call always yields *that* call's id. - On the Azure Functions durable host every executor runs in its own activity with - its own runner context, so that set only ever holds this executor's own - requests (never another executor's), and the request you just emitted is always - the latest. If a single executor emits several ``request_info`` calls in one - turn, read this after **each** call (the only case where reading once at the end - would lose the earlier ids); or pass an explicit ``request_id`` to - ``request_info`` to address them directly. - - Returns ``None`` only when no request is pending (or the runner context does not - track request-info events, e.g. in process off the durable host). - """ - runner_context = getattr(ctx, "_runner_context", None) - getter = getattr(runner_context, "get_pending_request_info_events", None) - if getter is None: - return None - events = await getter() - if not events: - return None - # Dicts preserve insertion order, so the last key is the most recent request. - return next(reversed(events)) - - @property - def base_url(self) -> str: - """The scheme + host the respond/status URLs are built on (no trailing slash). - - Resolution order: the explicit ``base_url`` passed to :meth:`from_context`, then - the ``WEBSITE_HOSTNAME`` app setting (``http`` for localhost, otherwise - ``https``). - - Raises: - RuntimeError: If neither an override nor ``WEBSITE_HOSTNAME`` is available. - """ - if self.base_url_override: - return self.base_url_override.rstrip("/") - - hostname = os.environ.get(WEBSITE_HOSTNAME_ENV) - if not hostname: - raise RuntimeError( - "Cannot build a HITL URL: no base URL is available. Set the " - f"'{WEBSITE_HOSTNAME_ENV}' app setting (present automatically on Azure " - "Functions; add it to the 'Values' map in local.settings.json for local " - "`func start` runs, e.g. 'localhost:7071'), or pass base_url=... to " - "WorkflowHitlContext.from_context()." - ) - - # WEBSITE_HOSTNAME may include a scheme (unusual but possible); otherwise it is - # host-only, so infer one (http for local loopback, https otherwise). - if hostname.startswith(("http://", "https://")): - return hostname.rstrip("/") - scheme = "http" if _is_loopback(hostname) else "https" - return f"{scheme}://{hostname.rstrip('/')}" - - def build_respond_url(self, request_id: str) -> str: - """Build the URL a reviewer POSTs their response to, resuming the workflow. - - Mirrors the ``respondUrl`` AgentFunctionApp returns from its run/status - endpoints: ``{base}/{prefix}/workflow/{name}/respond/{instanceId}/{requestId}`` - (``prefix`` is the app's ``routePrefix``, ``api`` by default), always targeting - the addressable top-level instance. - - Args: - request_id: The pending request's id -- the id passed to (or generated by) - ``ctx.request_info``. Pass the **bare** id even from inside a nested - sub-workflow: any :attr:`request_path_prefix` is prepended for you to - qualify it (``{executor}~{ordinal}~{requestId}``) back to the root. - - Returns: - The fully-qualified respond URL. - """ - qualified_id = f"{self.request_path_prefix}{request_id}" - return build_workflow_respond_url(self.base_url, self.workflow_name, self.instance_id, qualified_id) - - def build_status_url(self) -> str: - """Build the workflow status URL for this orchestration instance. - - Returns ``{base}/{prefix}/workflow/{name}/status/{instanceId}`` (``prefix`` is the - app's ``routePrefix``, ``api`` by default), the same endpoint AgentFunctionApp - exposes for polling runtime status and pending HITL requests. - """ - return build_workflow_status_url(self.base_url, self.workflow_name, self.instance_id) diff --git a/python/packages/azurefunctions/agent_framework_azurefunctions/_orchestration.py b/python/packages/azurefunctions/agent_framework_azurefunctions/_orchestration.py deleted file mode 100644 index 5e2ee5938c2..00000000000 --- a/python/packages/azurefunctions/agent_framework_azurefunctions/_orchestration.py +++ /dev/null @@ -1,222 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Orchestration Support for Durable Agents. - -This module provides support for using agents inside Durable Function orchestrations. -""" - -import logging -from collections.abc import Callable -from typing import TYPE_CHECKING, Any, TypeAlias - -import azure.durable_functions as df -from agent_framework import AgentSession -from agent_framework_durabletask import ( - DurableAgentExecutor, - RunRequest, - ensure_response_format, - load_agent_response, -) -from azure.durable_functions.models import TaskBase -from azure.durable_functions.models.actions.NoOpAction import NoOpAction -from azure.durable_functions.models.Task import CompoundTask, TaskState -from pydantic import BaseModel - -logger = logging.getLogger("agent_framework.azurefunctions") - -CompoundActionConstructor: TypeAlias = Callable[[list[Any]], Any] | None - -if TYPE_CHECKING: - from azure.durable_functions import DurableOrchestrationContext - - class _TypedCompoundTask(CompoundTask): - _first_error: Any - - def __init__( - self, - tasks: list[TaskBase], - compound_action_constructor: CompoundActionConstructor = None, - ) -> None: ... - - AgentOrchestrationContextType: TypeAlias = DurableOrchestrationContext -else: - AgentOrchestrationContextType = Any - _TypedCompoundTask = CompoundTask - - -class PreCompletedTask(TaskBase): - """A simple task that is already completed with a result. - - Used for fire-and-forget mode where we want to return immediately - with an acceptance response without waiting for entity processing. - """ - - def __init__(self, result: Any): - """Initialize with a completed result. - - Args: - result: The result value for this completed task - """ - # Initialize with a NoOp action since we don't need actual orchestration actions - super().__init__(-1, NoOpAction()) - # Immediately mark as completed with the result - self.set_value(is_error=False, value=result) - - -class AgentTask(_TypedCompoundTask): - """A custom Task that wraps entity calls and provides typed AgentResponse results. - - This task wraps the underlying entity call task and intercepts its completion - to convert the raw result into a typed AgentResponse object. - """ - - def __init__( - self, - entity_task: TaskBase, - response_format: type[BaseModel] | None, - correlation_id: str, - ): - """Initialize the AgentTask. - - Args: - entity_task: The underlying entity call task - response_format: Optional Pydantic model for response parsing - correlation_id: Correlation ID for logging - """ - # Set instance variables BEFORE calling super().__init__ - # because super().__init__ may trigger try_set_value for pre-completed tasks - self._response_format = response_format - self._correlation_id = correlation_id - - super().__init__([entity_task]) - - # Override action_repr to expose the inner task's action directly - # This ensures compatibility with ReplaySchema V3 which expects Action objects. - self.action_repr = entity_task.action_repr - - # Also copy the task ID to match the entity task's identity - self.id = entity_task.id - - def try_set_value(self, child: TaskBase) -> None: - """Transition the AgentTask to a terminal state and set its value to `AgentResponse`. - - Parameters - ---------- - child : TaskBase - The entity call task that just completed - """ - if child.state is TaskState.SUCCEEDED: - # Delegate to parent class for standard completion logic - if len(self.pending_tasks) == 0: - # Transform the raw result before setting it - raw_result = child.result - logger.debug( - "[AgentTask] Converting raw result for correlation_id %s", - self._correlation_id, - ) - - try: - response = load_agent_response(raw_result) - - if self._response_format is not None: - ensure_response_format( - self._response_format, - self._correlation_id, - response, - ) - - # Set the typed AgentResponse as this task's result - self.set_value(is_error=False, value=response) - except Exception as e: - logger.exception( - "[AgentTask] Failed to convert result for correlation_id: %s", - self._correlation_id, - ) - self.set_value(is_error=True, value=e) - else: - # If error not handled by the parent, set it explicitly. - if self._first_error is None: - self._first_error = child.result - self.set_value(is_error=True, value=self._first_error) - - -class AzureFunctionsAgentExecutor(DurableAgentExecutor[AgentTask]): - """Executor that executes durable agents inside Azure Functions orchestrations.""" - - def __init__(self, context: AgentOrchestrationContextType): - self.context = context - - def generate_unique_id(self) -> str: - return str(self.context.new_uuid()) - - def get_run_request( - self, - message: str, - *, - options: dict[str, Any] | None = None, - ) -> RunRequest: - """Get the current run request from the orchestration context. - - Args: - message: The message to send to the agent - options: Optional options dictionary. Supported keys include - ``response_format``, ``enable_tool_calls``, and ``wait_for_response``. - Additional keys are forwarded to the agent execution. - - Returns: - RunRequest: The current run request - - Raises: - ValueError: If wait_for_response=False (not supported in orchestrations) - """ - # Create a copy to avoid modifying the caller's dict - - request = super().get_run_request(message, options=options) - request.orchestration_id = self.context.instance_id - return request - - def run_durable_agent( - self, - agent_name: str, - run_request: RunRequest, - session: AgentSession | None = None, - ) -> AgentTask: - - # Resolve session - session_id = self._create_session_id(agent_name, session) - - entity_id = df.EntityId( - name=session_id.entity_name, - key=session_id.key, - ) - - logger.debug( - "[AzureFunctionsAgentProvider] correlation_id: %s entity_id: %s session_id: %s", - run_request.correlation_id, - entity_id, - session_id, - ) - - # Branch based on wait_for_response - if not run_request.wait_for_response: - # Fire-and-forget mode: signal entity and return pre-completed task - logger.debug( - "[AzureFunctionsAgentExecutor] Fire-and-forget mode: signaling entity (correlation: %s)", - run_request.correlation_id, - ) - self.context.signal_entity(entity_id, "run", run_request.to_dict()) - - # Create acceptance response using base class helper - acceptance_response = self._create_acceptance_response(run_request.correlation_id) - - # Create a pre-completed task with the acceptance response - entity_task = PreCompletedTask(acceptance_response) - else: - # Blocking mode: call entity and wait for response - entity_task = self.context.call_entity(entity_id, "run", run_request.to_dict()) - - return AgentTask( - entity_task=entity_task, - response_format=run_request.response_format, - correlation_id=run_request.correlation_id, - ) diff --git a/python/packages/azurefunctions/agent_framework_azurefunctions/_routes.py b/python/packages/azurefunctions/agent_framework_azurefunctions/_routes.py deleted file mode 100644 index 6f6ec06b56c..00000000000 --- a/python/packages/azurefunctions/agent_framework_azurefunctions/_routes.py +++ /dev/null @@ -1,117 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Single source of truth for the AgentFunctionApp HTTP route prefix and HITL URLs. - -The server endpoints (:mod:`._app`) and the in-workflow addressing helper -(:mod:`._hitl_context`) build the same ``{prefix}/workflow/{name}/...`` URLs. Keeping the -shape and the prefix logic here stops the two sides from drifting -- previously they were -only kept in sync by an integration test asserting the two strings match -- and lets a -customized ``routePrefix`` be honored instead of a hardcoded ``api`` that would 404 on -resume. The server derives the prefix from the incoming request URL (the value the host -actually routed); the helper, which runs inside an executor with no request context, -reads it from ``host.json``. -""" - -from __future__ import annotations - -import functools -import json -import logging -import os -from typing import Any, cast -from urllib.parse import urlsplit - -logger = logging.getLogger(__name__) - -# Azure Functions' default HTTP route prefix, applied when host.json does not override -# ``extensions.http.routePrefix``. -DEFAULT_ROUTE_PREFIX = "api" - - -@functools.lru_cache(maxsize=1) -def route_prefix() -> str: - """Return the app's HTTP route prefix, honoring ``host.json``. - - Azure Functions prepends ``extensions.http.routePrefix`` (default ``api``) to every - HTTP route, and it can be customized or set to an empty string. That value is not - exposed through an environment variable, so it is read from ``host.json`` under the - script root (``AzureWebJobsScriptRoot``, falling back to the current working - directory) and cached for the process. Any failure to locate or parse the file falls - back to the ``api`` default. Tests that vary ``host.json`` call ``route_prefix.cache_clear()``. - """ - return _read_route_prefix() - - -def _read_route_prefix() -> str: - # AzureWebJobsScriptRoot is the host-set path to the app root (mixed case is the real - # variable name and is case-sensitive on Linux, so it must not be upper-cased). - script_root = os.environ.get("AzureWebJobsScriptRoot") or os.getcwd() # ruff:ignore[uncapitalized-environment-variables] - host_json_path = os.path.join(script_root, "host.json") - try: - with open(host_json_path, encoding="utf-8") as f: - loaded = json.load(f) - except (OSError, ValueError): - logger.debug("Could not read '%s'; defaulting route prefix to '%s'.", host_json_path, DEFAULT_ROUTE_PREFIX) - return DEFAULT_ROUTE_PREFIX - if not isinstance(loaded, dict): - return DEFAULT_ROUTE_PREFIX - extensions = cast("dict[str, Any]", loaded).get("extensions") - if not isinstance(extensions, dict): - return DEFAULT_ROUTE_PREFIX - http = cast("dict[str, Any]", extensions).get("http") - if not isinstance(http, dict): - return DEFAULT_ROUTE_PREFIX - prefix = cast("dict[str, Any]", http).get("routePrefix") - return prefix.strip("/") if isinstance(prefix, str) else DEFAULT_ROUTE_PREFIX - - -def _prefix_segment(prefix: str | None) -> str: - """Return the route-prefix path segment with a trailing slash, or ``""`` when empty.""" - resolved = route_prefix() if prefix is None else prefix.strip("/") - return f"{resolved}/" if resolved else "" - - -def build_workflow_respond_url( - base_url: str, - workflow_name: str, - instance_id: str, - request_id: str, - *, - prefix: str | None = None, -) -> str: - """Build the canonical HITL respond URL a reviewer POSTs to. - - ``{base}/{prefix}/workflow/{name}/respond/{instanceId}/{requestId}``. When ``prefix`` - is omitted it is resolved from ``host.json``. ``request_id`` may be a literal - ``{requestId}`` placeholder to produce the templated form the run endpoint returns. - """ - return f"{base_url}/{_prefix_segment(prefix)}workflow/{workflow_name}/respond/{instance_id}/{request_id}" - - -def build_workflow_status_url( - base_url: str, - workflow_name: str, - instance_id: str, - *, - prefix: str | None = None, -) -> str: - """Build the workflow status URL: ``{base}/{prefix}/workflow/{name}/status/{instanceId}``.""" - return f"{base_url}/{_prefix_segment(prefix)}workflow/{workflow_name}/status/{instance_id}" - - -def split_request_url(request_url: str) -> tuple[str, str]: - """Return ``(base_url, route_prefix)`` derived from an incoming request URL. - - On the server the request URL is the authoritative source for the prefix, since the - host served it through the configured ``routePrefix``. The scheme and host form the - base URL, and the path before the first ``/workflow/`` segment is the prefix (empty - when the routes sit directly under the host). Falls back to ``(request_url, "")`` when - the value is not an absolute URL. - """ - parts = urlsplit(request_url) - if not (parts.scheme and parts.netloc): - return request_url.rstrip("/"), "" - base_url = f"{parts.scheme}://{parts.netloc}" - index = parts.path.find("/workflow/") - prefix = parts.path[:index].strip("/") if index != -1 else "" - return base_url, prefix diff --git a/python/packages/azurefunctions/agent_framework_azurefunctions/_workflow.py b/python/packages/azurefunctions/agent_framework_azurefunctions/_workflow.py deleted file mode 100644 index e15ae94054e..00000000000 --- a/python/packages/azurefunctions/agent_framework_azurefunctions/_workflow.py +++ /dev/null @@ -1,83 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Workflow Execution for Durable Functions. - -This module provides the Azure Functions entry point for workflow orchestration. -The actual orchestration logic lives in the shared module -``agent_framework_durabletask._workflows.orchestrator`` and is host-agnostic. -This module re-exports the public API and provides the AF-specific -``run_workflow_orchestrator`` wrapper that creates an -:class:`AzureFunctionsWorkflowContext` before delegating. -""" - -from __future__ import annotations - -import logging -from collections.abc import Generator -from typing import Any - -from agent_framework import Workflow -from agent_framework_durabletask._workflows.orchestrator import ( - SOURCE_HITL_RESPONSE, - SOURCE_ORCHESTRATOR, - SOURCE_WORKFLOW_START, - ExecutorResult, - PendingHITLRequest, - TaskMetadata, - TaskType, - _extract_message_content, # pyright: ignore[reportPrivateUsage] - build_agent_executor_response, - execute_hitl_response_handler, - route_message_through_edge_groups, -) -from agent_framework_durabletask._workflows.orchestrator import ( - run_workflow_orchestrator as _run_workflow_orchestrator_shared, -) -from azure.durable_functions import DurableOrchestrationContext - -from ._workflow_af_context import AzureFunctionsWorkflowContext - -logger = logging.getLogger(__name__) - -# Re-export shared symbols for backward compatibility -__all__ = [ - "SOURCE_HITL_RESPONSE", - "SOURCE_ORCHESTRATOR", - "SOURCE_WORKFLOW_START", - "ExecutorResult", - "PendingHITLRequest", - "TaskMetadata", - "TaskType", - "_extract_message_content", - "build_agent_executor_response", - "execute_hitl_response_handler", - "route_message_through_edge_groups", - "run_workflow_orchestrator", -] - - -def run_workflow_orchestrator( - context: DurableOrchestrationContext, - workflow: Workflow, - initial_message: Any, - shared_state: dict[str, Any] | None = None, -) -> Generator[Any, Any, list[Any] | dict[str, Any]]: - """Azure Functions wrapper around the shared workflow orchestrator. - - Creates an :class:`AzureFunctionsWorkflowContext` and delegates to the - host-agnostic :func:`run_workflow_orchestrator` in the durabletask package. - - Args: - context: The Azure Functions ``DurableOrchestrationContext``. - workflow: The MAF Workflow instance to execute. - initial_message: Initial message to send to the start executor. - shared_state: Optional dict for cross-executor state sharing. - - Returns: - For a top-level run, the list of workflow outputs collected from executor - activities. For a sub-workflow run, a result envelope ``{"outputs": [...], - "events": [...]}`` so the parent can bubble nested progress (see the shared - ``run_workflow_orchestrator`` in the durabletask package). - """ - af_ctx = AzureFunctionsWorkflowContext(context) - return _run_workflow_orchestrator_shared(af_ctx, workflow, initial_message, shared_state) diff --git a/python/packages/azurefunctions/agent_framework_azurefunctions/_workflow_af_context.py b/python/packages/azurefunctions/agent_framework_azurefunctions/_workflow_af_context.py deleted file mode 100644 index eaf99a5a91f..00000000000 --- a/python/packages/azurefunctions/agent_framework_azurefunctions/_workflow_af_context.py +++ /dev/null @@ -1,105 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Azure Functions adapter for WorkflowOrchestrationContext. - -Wraps ``azure.durable_functions.DurableOrchestrationContext`` to satisfy the -:class:`~agent_framework_durabletask.WorkflowOrchestrationContext` protocol. -""" - -from __future__ import annotations - -import logging -from datetime import datetime -from typing import Any - -from agent_framework_durabletask import AgentSessionId, DurableAgentSession, DurableAIAgent -from azure.durable_functions import DurableOrchestrationContext - -from ._orchestration import AzureFunctionsAgentExecutor - -logger = logging.getLogger(__name__) - - -class AzureFunctionsWorkflowContext: - """Adapter that maps ``DurableOrchestrationContext`` to ``WorkflowOrchestrationContext``.""" - - def __init__(self, context: DurableOrchestrationContext) -> None: - self._context = context - - # -- Properties ----------------------------------------------------------- - - @property - def instance_id(self) -> str: - # Typed local (not cast): mypy sees the untyped context as Any, while - # pyright sees a concrete str - the annotation satisfies both. - instance_id: str = self._context.instance_id - return instance_id - - @property - def is_replaying(self) -> bool: - is_replaying: bool = self._context.is_replaying - return is_replaying - - @property - def supports_event_streaming(self) -> bool: - # The Azure Functions host has no workflow event-streaming endpoint, and its - # Durable Functions custom status is capped at 16 KB by the WebJobs extension. - # Publishing the accumulating event log would overflow that cap and fail the - # orchestrator, so events are omitted; state, pending HITL requests, and the - # final output remain available via the workflow status endpoint. - return False - - @property - def current_utc_datetime(self) -> datetime: - current: datetime = self._context.current_utc_datetime - return current - - # -- Agent / Activity dispatch -------------------------------------------- - - def prepare_agent_task(self, executor_id: str, message: str, orchestration_instance_id: str) -> Any: - session_id = AgentSessionId(name=executor_id, key=orchestration_instance_id) - session = DurableAgentSession(durable_session_id=session_id) - az_executor = AzureFunctionsAgentExecutor(self._context) - agent = DurableAIAgent(az_executor, executor_id) - return agent.run(message, session=session) - - def prepare_activity_task(self, activity_name: str, input_json: str) -> Any: - orchestration_context: Any = self._context - return orchestration_context.call_activity(activity_name, input_json) - - def call_sub_orchestrator(self, name: str, input: Any, instance_id: str | None = None) -> Any: - orchestration_context: Any = self._context - return orchestration_context.call_sub_orchestrator(name, input_=input, instance_id=instance_id) - - # -- Composite tasks ------------------------------------------------------ - - def task_all(self, tasks: list[Any]) -> Any: - return self._context.task_all(tasks) - - def task_any(self, tasks: list[Any]) -> Any: - return self._context.task_any(tasks) - - # -- External events / timers --------------------------------------------- - - def wait_for_external_event(self, name: str) -> Any: - return self._context.wait_for_external_event(name) - - def create_timer(self, fire_at: datetime) -> Any: - return self._context.create_timer(fire_at) - - # -- Status / utility ----------------------------------------------------- - - def set_custom_status(self, status: Any) -> None: - self._context.set_custom_status(status) - - def new_uuid(self) -> str: - new_uuid: str = self._context.new_uuid() - return new_uuid - - def cancel_task(self, task: Any) -> None: - cancel_fn = getattr(task, "cancel", None) - if callable(cancel_fn): - cancel_fn() - - def get_task_result(self, task: Any) -> Any: - return getattr(task, "result", None) diff --git a/python/packages/azurefunctions/agent_framework_azurefunctions/py.typed b/python/packages/azurefunctions/agent_framework_azurefunctions/py.typed deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/python/packages/azurefunctions/pyproject.toml b/python/packages/azurefunctions/pyproject.toml deleted file mode 100644 index 664938b9373..00000000000 --- a/python/packages/azurefunctions/pyproject.toml +++ /dev/null @@ -1,101 +0,0 @@ -[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.0b260730" -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>=1.13.0,<2", - "agent-framework-durabletask>=1.0.0b260730,<2", - "azure-functions>=1.24.0,<2", - "azure-functions-durable>=1.3.1,<2", -] - -[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' -pythonpath = ["tests/integration_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 = 300 -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" -include = ["agent_framework_azurefunctions"] - -[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] -help = "Run MyPy for this package." -cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_azurefunctions" - -[tool.poe.tasks.test] -help = "Run the default unit test suite for this package." -cmd = 'pytest -m "not integration" --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 deleted file mode 100644 index 75baa4aa279..00000000000 --- a/python/packages/azurefunctions/tests/integration_tests/.env.example +++ /dev/null @@ -1,10 +0,0 @@ -# Azure OpenAI Configuration -AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com/ -AZURE_OPENAI_MODEL=your-deployment-name -FUNCTIONS_WORKER_RUNTIME=python - -# 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 deleted file mode 100644 index 291d7347f3f..00000000000 --- a/python/packages/azurefunctions/tests/integration_tests/README.md +++ /dev/null @@ -1,81 +0,0 @@ -# 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_MODEL` -- `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 -e DTS_USE_DYNAMIC_TASK_HUBS=true 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/azurefunctions/tests/integration_tests/conftest.py b/python/packages/azurefunctions/tests/integration_tests/conftest.py deleted file mode 100644 index 79bc8a0ac36..00000000000 --- a/python/packages/azurefunctions/tests/integration_tests/conftest.py +++ /dev/null @@ -1,613 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. -""" -Pytest configuration for Azure Functions integration tests. - -This module provides fixtures, configuration, and test utilities for pytest. -""" - -import os -import shutil -import socket -import subprocess -import sys -import time -import uuid -from collections.abc import Iterator, Mapping -from contextlib import suppress -from pathlib import Path -from typing import Any - -import pytest -import requests - -# ============================================================================= -# Configuration Constants -# ============================================================================= - -TIMEOUT = 30 # seconds -ORCHESTRATION_TIMEOUT = 180 # seconds for orchestrations -_DEFAULT_HOST = "localhost" - -# Emulator ports (match CI workflow configuration) -_AZURITE_BLOB_PORT = 10000 -_DTS_EMULATOR_PORT = 8080 - - -# ============================================================================= -# Exceptions -# ============================================================================= - - -class FunctionAppStartupError(RuntimeError): - """Raised when the Azure Functions host fails to start reliably.""" - - pass - - -# ============================================================================= -# Environment and Service Checks -# ============================================================================= - - -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 _check_func_cli_available() -> bool: - """Check if Azure Functions Core Tools (func) is installed and available.""" - return shutil.which("func") is not None - - -def _check_port_listening(port: int, host: str = _DEFAULT_HOST) -> bool: - """Check if a service is listening on the given port.""" - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: - sock.settimeout(1) - return sock.connect_ex((host, port)) == 0 - - -def _check_azurite_available() -> bool: - """Check if Azurite (Azure Storage emulator) is available on the expected port.""" - return _check_port_listening(_AZURITE_BLOB_PORT) - - -def _check_dts_emulator_available() -> bool: - """Check if Durable Task Scheduler emulator is available on the expected port.""" - return _check_port_listening(_DTS_EMULATOR_PORT) - - -def _should_skip_azure_functions_integration_tests() -> tuple[bool, str]: - """Determine whether Azure Functions integration tests should be skipped.""" - _load_env_file_if_present() - - # Check for Azure Functions Core Tools - if not _check_func_cli_available(): - return ( - True, - "Azure Functions Core Tools (func) not installed. Install with: npm install -g azure-functions-core-tools@4", # noqa: E501 - ) - - # Check for Azurite (Azure Storage emulator) - if not _check_azurite_available(): - return ( - True, - f"Azurite not running on port {_AZURITE_BLOB_PORT}. Start with: docker run -d -p 10000:10000 -p 10001:10001 -p 10002:10002 mcr.microsoft.com/azure-storage/azurite", # noqa: E501 - ) - - # Check for Durable Task Scheduler emulator - if not _check_dts_emulator_available(): - return ( - True, - f"Durable Task Scheduler emulator not running on port {_DTS_EMULATOR_PORT}. Start with: docker run -d -p 8080:8080 -p 8082:8082 mcr.microsoft.com/dts/dts-emulator:latest", # noqa: E501 - ) - - has_foundry_config = bool(os.getenv("FOUNDRY_PROJECT_ENDPOINT", "").strip()) and bool( - os.getenv("FOUNDRY_MODEL", "").strip() - ) - has_azure_openai_config = bool(os.getenv("AZURE_OPENAI_ENDPOINT", "").strip()) and bool( - os.getenv("AZURE_OPENAI_MODEL", "").strip() - ) - if not has_foundry_config and not has_azure_openai_config: - return ( - True, - "No real FOUNDRY_* or AZURE_OPENAI_* configuration 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, -) - - -# ============================================================================= -# Test Helper Class -# ============================================================================= - - -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 -# ============================================================================= - - -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 conftest.py -> integration_tests -> tests -> azurefunctions -> packages -> python - return Path(__file__).resolve().parents[4] - - -def _get_sample_path_from_marker(request: pytest.FixtureRequest) -> 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" / "04-hosting" / "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(sample_path: Path) -> None: - """Load .env file from current directory if it exists, then validate required environment variables. - - Raises pytest.fail if required environment variables are missing. - """ - _load_env_file_if_present() - # Required environment variables for Azure Functions samples - required_env_vars = [ - "AzureWebJobsStorage", - "DURABLE_TASK_SCHEDULER_CONNECTION_STRING", - "FUNCTIONS_WORKER_RUNTIME", - ] - # Samples that host no AI agents need no model credentials (only the DTS emulator - # and Azurite). The suite-level gate still requires *some* LLM config to be present. - no_llm_samples = {"13_subworkflow_hitl"} - if sample_path.name in no_llm_samples: - pass - elif sample_path.name == "11_workflow_parallel": - required_env_vars.extend(["AZURE_OPENAI_ENDPOINT", "AZURE_OPENAI_MODEL"]) - else: - required_env_vars.extend(["FOUNDRY_PROJECT_ENDPOINT", "FOUNDRY_MODEL"]) - - # 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[Any]: - """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]}" - - # The Azure Functions Python worker's dependency isolation mechanism crashes - # on Python 3.13 with a SIGSEGV in the protobuf C extension (google._upb). - # Disabling isolation lets the worker load dependencies from the app's own - # environment, which avoids the crash. - # See: https://github.com/Azure/azure-functions-python-worker/issues/1797 - if sys.version_info >= (3, 13): - env.setdefault("PYTHON_ISOLATE_WORKER_DEPENDENCIES", "0") - - # 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, use start_new_session=True to isolate the process group from the - # pytest-xdist worker. Without this, signals (e.g. from test-timeout) can - # propagate to the func host and vice-versa, potentially killing the worker. - return subprocess.Popen( - ["func", "start", "--port", str(port)], - cwd=str(sample_path), - env=env, - start_new_session=True, - ) - - -def _wait_for_function_app_ready(func_process: subprocess.Popen[Any], 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[Any]) -> 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) - - -# ============================================================================= -# Pytest Configuration -# ============================================================================= - - -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'))", - ) - - -def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None: - """Skip integration tests in this directory if prerequisites are not met.""" - should_skip, reason = _should_skip_azure_functions_integration_tests() - if should_skip: - skip_marker = pytest.mark.skip(reason=reason) - for item in items: - # Only skip items that are in this integration_tests directory - if "integration_tests" in str(item.fspath): - item.add_marker(skip_marker) - - -# ============================================================================= -# Pytest Fixtures -# ============================================================================= - - -@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(sample_path) - - max_attempts = 3 - # The overall budget MUST be shorter than the pytest-timeout value - # (--timeout=120 by default) so that the fixture finishes cleanly instead - # of being killed by os._exit() which crashes the xdist worker. - overall_budget = 100 # seconds – leaves headroom below the 120 s test timeout - last_error: Exception | None = None - func_process: subprocess.Popen[Any] | None = None - base_url = "" - port = 0 - overall_start = time.monotonic() - attempts_made = 0 - - for _ in range(max_attempts): - remaining = overall_budget - (time.monotonic() - overall_start) - if remaining < 10: - # Not enough time for another attempt; bail out. - break - - attempts_made += 1 - port = _find_available_port() - base_url = _build_base_url(port) - func_process = _start_function_app(sample_path, port) - - try: - # Cap each attempt's wait to the remaining budget minus a small - # buffer for cleanup. - per_attempt_wait = min(60, int(remaining) - 5) - _wait_for_function_app_ready(func_process, port, max_wait=max(per_attempt_wait, 10)) - last_error = None - break - except FunctionAppStartupError as exc: - last_error = exc - _cleanup_function_app(func_process) - func_process = None - - if func_process is None: - elapsed = int(time.monotonic() - overall_start) - error_message = f"Function app failed to start after {attempts_made} attempt(s) ({elapsed}s elapsed)." - 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"]) - - -@pytest.fixture(scope="session") -def sample_helper() -> type[SampleTestHelper]: - """Provide the SampleTestHelper class for tests.""" - return SampleTestHelper 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 deleted file mode 100644 index 00dd096b568..00000000000 --- a/python/packages/azurefunctions/tests/integration_tests/test_01_single_agent.py +++ /dev/null @@ -1,115 +0,0 @@ -# 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 agent_framework_durabletask import THREAD_ID_HEADER - -# Module-level markers - applied to all tests in this file -pytestmark = [ - pytest.mark.flaky, - pytest.mark.integration, - pytest.mark.sample("01_single_agent"), - pytest.mark.usefixtures("function_app_for_test"), -] - - -class TestSampleSingleAgent: - """Tests for 01_single_agent sample.""" - - @pytest.fixture(autouse=True) - def _setup(self, base_url: str, sample_helper) -> None: - """Provide agent-specific base URL and helper for the tests.""" - self.base_url = f"{base_url}/api/agents/Joker" - self.helper = sample_helper - - def test_health_check(self, base_url: str, sample_helper) -> None: - """Test health check endpoint.""" - response = sample_helper.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 = self.helper.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 = self.helper.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(THREAD_ID_HEADER) is not None - - def test_thread_id_in_query(self) -> None: - """Test using thread_id in query parameter.""" - response = self.helper.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(THREAD_ID_HEADER) == "test-query-thread" - - def test_conversation_continuity(self) -> None: - """Test conversation context is maintained across requests.""" - thread_id = "test-continuity" - - # First message - response1 = self.helper.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"] == 2 # Initial + reply - - # Second message in same session - response2 = self.helper.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"] == 4 - else: - # In async mode, we can't easily test message count - # Just verify we can make multiple calls - response2 = self.helper.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 deleted file mode 100644 index e7ce7eabb26..00000000000 --- a/python/packages/azurefunctions/tests/integration_tests/test_02_multi_agent.py +++ /dev/null @@ -1,65 +0,0 @@ -# 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 - -# Module-level markers - applied to all tests in this file -pytestmark = [ - pytest.mark.flaky, - pytest.mark.integration, - pytest.mark.sample("02_multi_agent"), - pytest.mark.usefixtures("function_app_for_test"), -] - - -class TestSampleMultiAgent: - """Tests for 02_multi_agent sample.""" - - @pytest.fixture(autouse=True) - def _setup(self, base_url: str, sample_helper) -> 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" - self.helper = sample_helper - - @pytest.mark.skip(reason="Flaky in CI: times out / crashes the xdist runner; temporarily disabled.") - def test_weather_agent(self) -> None: - """Test WeatherAgent endpoint.""" - response = self.helper.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 = self.helper.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_reliable_streaming.py b/python/packages/azurefunctions/tests/integration_tests/test_03_reliable_streaming.py deleted file mode 100644 index b8c64263cb7..00000000000 --- a/python/packages/azurefunctions/tests/integration_tests/test_03_reliable_streaming.py +++ /dev/null @@ -1,121 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. -""" -Integration Tests for Reliable Streaming Sample - -Tests the reliable streaming sample using Redis Streams for persistent message delivery. - -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 -- Redis running (docker run -d --name redis -p 6379:6379 redis:latest) - -Usage: - uv run pytest packages/azurefunctions/tests/integration_tests/test_03_reliable_streaming.py -v -""" - -import time - -import pytest -import requests - -# Module-level markers - applied to all tests in this file -pytestmark = [ - pytest.mark.flaky, - pytest.mark.integration, - pytest.mark.sample("03_reliable_streaming"), - pytest.mark.usefixtures("function_app_for_test"), -] - - -class TestSampleReliableStreaming: - """Tests for 03_reliable_streaming sample.""" - - @pytest.fixture(autouse=True) - def _setup(self, base_url: str, sample_helper) -> None: - """Provide the base URL and helper for each test.""" - self.base_url = base_url - self.agent_url = f"{base_url}/api/agents/TravelPlanner" - self.stream_url = f"{base_url}/api/agent/stream" - self.helper = sample_helper - - def test_agent_run_and_stream(self) -> None: - """Test agent execution with Redis streaming.""" - # Start agent run - response = self.helper.post_json( - f"{self.agent_url}/run", - {"message": "Plan a 1-day trip to Seattle in 1 sentence", "wait_for_response": False}, - ) - assert response.status_code == 202 - data = response.json() - - thread_id = data.get("thread_id") - - # Wait a moment for the agent to start writing to Redis - time.sleep(2) - - # Stream response from Redis with longer timeout to account for LLM latency - stream_response = requests.get( - f"{self.stream_url}/{thread_id}", - headers={"Accept": "text/plain"}, - timeout=60, - ) - assert stream_response.status_code == 200 - - def test_stream_with_sse_format(self) -> None: - """Test streaming with Server-Sent Events format.""" - # Start agent run - response = self.helper.post_json( - f"{self.agent_url}/run", - {"message": "What's the weather like?", "wait_for_response": False}, - ) - assert response.status_code == 202 - data = response.json() - thread_id = data.get("thread_id") - - # Wait for agent to start writing - time.sleep(2) - - # Stream with SSE format - stream_response = requests.get( - f"{self.stream_url}/{thread_id}", - headers={"Accept": "text/event-stream"}, - timeout=60, - ) - assert stream_response.status_code == 200 - content_type = stream_response.headers.get("content-type", "") - assert "text/event-stream" in content_type - - # Check for SSE event markers if we got content - content = stream_response.text - if content: - assert "event:" in content or "data:" in content - - def test_stream_nonexistent_conversation(self) -> None: - """Test streaming from a non-existent conversation. - - The endpoint will wait for data in Redis, but since the conversation - doesn't exist, it will timeout. This is expected behavior. - """ - fake_id = "nonexistent-conversation-12345" - - # Should timeout since the conversation doesn't exist - with pytest.raises(requests.exceptions.ReadTimeout): - requests.get( - f"{self.stream_url}/{fake_id}", - headers={"Accept": "text/plain"}, - timeout=10, # Short timeout for non-existent ID - ) - - def test_health_endpoint(self) -> None: - """Test health check endpoint.""" - response = self.helper.get(f"{self.base_url}/api/health") - assert response.status_code == 200 - data = response.json() - assert data["status"] == "healthy" - assert "agents" in data - - -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 deleted file mode 100644 index 9c52c9a937b..00000000000 --- a/python/packages/azurefunctions/tests/integration_tests/test_04_single_agent_orchestration_chaining.py +++ /dev/null @@ -1,57 +0,0 @@ -# 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 - -# Module-level markers - applied to all tests in this file -pytestmark = [ - pytest.mark.flaky, - pytest.mark.integration, - pytest.mark.sample("04_single_agent_orchestration_chaining"), - pytest.mark.usefixtures("function_app_for_test"), -] - - -@pytest.mark.orchestration -class TestSampleOrchestrationChaining: - """Tests for 04_single_agent_orchestration_chaining sample.""" - - @pytest.fixture(autouse=True) - def _setup(self, sample_helper) -> None: - """Provide the helper for each test.""" - self.helper = sample_helper - - def test_orchestration_chaining(self, base_url: str) -> None: - """Test sequential agent calls in orchestration.""" - # Start orchestration - response = self.helper.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 = self.helper.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 deleted file mode 100644 index 7759b528cdf..00000000000 --- a/python/packages/azurefunctions/tests/integration_tests/test_05_multi_agent_orchestration_concurrency.py +++ /dev/null @@ -1,59 +0,0 @@ -# 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 - -# Module-level markers - applied to all tests in this file -pytestmark = [ - pytest.mark.flaky, - pytest.mark.integration, - pytest.mark.orchestration, - pytest.mark.sample("05_multi_agent_orchestration_concurrency"), - pytest.mark.usefixtures("function_app_for_test"), -] - - -class TestSampleMultiAgentConcurrency: - """Tests for 05_multi_agent_orchestration_concurrency sample.""" - - @pytest.fixture(autouse=True) - def _setup(self, sample_helper) -> None: - """Provide the helper for each test.""" - self.helper = sample_helper - - def test_concurrent_agents(self, base_url: str) -> None: - """Test multiple agents running concurrently.""" - # Start orchestration - response = self.helper.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 = self.helper.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 deleted file mode 100644 index c40e4ab4088..00000000000 --- a/python/packages/azurefunctions/tests/integration_tests/test_06_multi_agent_orchestration_conditionals.py +++ /dev/null @@ -1,77 +0,0 @@ -# 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 - -# Module-level markers - applied to all tests in this file -pytestmark = [ - pytest.mark.flaky, - pytest.mark.integration, - pytest.mark.orchestration, - pytest.mark.sample("06_multi_agent_orchestration_conditionals"), - pytest.mark.usefixtures("function_app_for_test"), -] - - -class TestSampleMultiAgentConditionals: - """Tests for 06_multi_agent_orchestration_conditionals sample.""" - - @pytest.fixture(autouse=True) - def _setup(self, sample_helper) -> None: - """Provide the helper for each test.""" - self.helper = sample_helper - - def test_legitimate_email(self, base_url: str) -> None: - """Test conditional logic with legitimate email.""" - response = self.helper.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 = self.helper.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 = self.helper.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 = self.helper.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 deleted file mode 100644 index 17356fac506..00000000000 --- a/python/packages/azurefunctions/tests/integration_tests/test_07_single_agent_orchestration_hitl.py +++ /dev/null @@ -1,185 +0,0 @@ -# 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 - -# Module-level markers - applied to all tests in this file -pytestmark = [ - pytest.mark.flaky, - pytest.mark.integration, - pytest.mark.sample("07_single_agent_orchestration_hitl"), - pytest.mark.usefixtures("function_app_for_test"), -] - - -@pytest.mark.orchestration -class TestSampleHITLOrchestration: - """Tests for 07_single_agent_orchestration_hitl sample.""" - - @pytest.fixture(autouse=True) - def _setup(self, base_url: str, sample_helper) -> None: - """Provide the helper and base URL for each test.""" - self.hitl_base_url = f"{base_url}/api/hitl" - self.helper = sample_helper - - def test_hitl_orchestration_approval(self) -> None: - """Test HITL orchestration with human approval.""" - # Start orchestration - response = self.helper.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 = self.helper.get(data["statusQueryGetUri"]) - assert status_response.status_code == 200 - status = status_response.json() - assert status["runtimeStatus"] in ["Running", "Pending"] - - # Send approval - approval_response = self.helper.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 = self.helper.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 = self.helper.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 = self.helper.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 = self.helper.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 = self.helper.post_json( - f"{self.hitl_base_url}/approve/{instance_id}", {"approved": True, "feedback": ""} - ) - assert approval_response.status_code == 200 - - # Wait for completion - status = self.helper.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 = self.helper.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 = self.helper.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 = self.helper.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) - self.helper.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 = self.helper.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 = self.helper.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 - self.helper.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 = self.helper.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/test_09_workflow_shared_state.py b/python/packages/azurefunctions/tests/integration_tests/test_09_workflow_shared_state.py deleted file mode 100644 index 27836d04f8d..00000000000 --- a/python/packages/azurefunctions/tests/integration_tests/test_09_workflow_shared_state.py +++ /dev/null @@ -1,100 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. -""" -Integration Tests for Workflow Shared State Sample - -Tests the workflow shared state sample for conditional email processing -with shared state 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 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_09_workflow_shared_state.py -v -""" - -import pytest - -# Must match the workflow name in samples/04-hosting/azure_functions/09_workflow_shared_state/function_app.py -WORKFLOW_NAME = "email_triage_shared_state" - -# Module-level markers - applied to all tests in this file -pytestmark = [ - pytest.mark.flaky, - pytest.mark.integration, - pytest.mark.sample("09_workflow_shared_state"), - pytest.mark.usefixtures("function_app_for_test"), -] - - -@pytest.mark.orchestration -class TestWorkflowSharedState: - """Tests for 09_workflow_shared_state sample.""" - - @pytest.fixture(autouse=True) - def _setup(self, base_url: str, sample_helper) -> None: - """Provide the helper and base URL for each test.""" - self.base_url = base_url - self.helper = sample_helper - - def test_workflow_with_spam_email(self) -> None: - """Test workflow with spam email content - should be detected and handled as spam.""" - spam_content = "URGENT! You have won $1,000,000! Click here to claim your prize now before it expires!" - - # Start orchestration with spam email - response = self.helper.post_text(f"{self.base_url}/api/workflow/{WORKFLOW_NAME}/run", spam_content) - assert response.status_code == 202 - data = response.json() - assert "instanceId" in data - assert "statusQueryGetUri" in data - - # Wait for completion - status = self.helper.wait_for_orchestration_with_output(data["statusQueryGetUri"]) - assert status["runtimeStatus"] == "Completed" - assert "output" in status - - def test_workflow_with_legitimate_email(self) -> None: - """Test workflow with legitimate email content - should generate response.""" - legitimate_content = ( - "Hi team, just a reminder about the sprint planning meeting tomorrow at 10 AM. " - "Please review the agenda items in Jira before the call." - ) - - # Start orchestration with legitimate email - response = self.helper.post_text(f"{self.base_url}/api/workflow/{WORKFLOW_NAME}/run", legitimate_content) - assert response.status_code == 202 - data = response.json() - assert "instanceId" in data - assert "statusQueryGetUri" in data - - # Wait for completion - status = self.helper.wait_for_orchestration_with_output(data["statusQueryGetUri"]) - assert status["runtimeStatus"] == "Completed" - assert "output" in status - - def test_workflow_with_phishing_email(self) -> None: - """Test workflow with phishing email - should be detected as spam.""" - phishing_content = ( - "Dear Customer, Your account has been compromised! " - "Click this link immediately to secure your account: http://totallylegit.suspicious.com/secure" - ) - - # Start orchestration with phishing email - response = self.helper.post_text(f"{self.base_url}/api/workflow/{WORKFLOW_NAME}/run", phishing_content) - assert response.status_code == 202 - data = response.json() - assert "instanceId" in data - - # Wait for completion - status = self.helper.wait_for_orchestration_with_output(data["statusQueryGetUri"]) - assert status["runtimeStatus"] == "Completed" - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) diff --git a/python/packages/azurefunctions/tests/integration_tests/test_10_workflow_no_shared_state.py b/python/packages/azurefunctions/tests/integration_tests/test_10_workflow_no_shared_state.py deleted file mode 100644 index 479b87cf0b7..00000000000 --- a/python/packages/azurefunctions/tests/integration_tests/test_10_workflow_no_shared_state.py +++ /dev/null @@ -1,116 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. -""" -Integration Tests for Workflow No Shared State Sample - -Tests the workflow sample that runs without shared state, -demonstrating conditional routing with spam detection and email response. - -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_10_workflow_no_shared_state.py -v -""" - -import pytest - -# Must match the workflow name in samples/04-hosting/azure_functions/10_workflow_no_shared_state/function_app.py -WORKFLOW_NAME = "email_triage" - -# Module-level markers - applied to all tests in this file -pytestmark = [ - pytest.mark.flaky, - pytest.mark.integration, - pytest.mark.sample("10_workflow_no_shared_state"), - pytest.mark.usefixtures("function_app_for_test"), -] - - -@pytest.mark.orchestration -class TestWorkflowNoSharedState: - """Tests for 10_workflow_no_shared_state sample.""" - - @pytest.fixture(autouse=True) - def _setup(self, base_url: str, sample_helper) -> None: - """Provide the helper and base URL for each test.""" - self.base_url = base_url - self.helper = sample_helper - - def test_workflow_with_spam_email(self) -> None: - """Test workflow with spam email - should detect and handle as spam.""" - payload = { - "email_id": "email-test-001", - "email_content": ( - "URGENT! You've won $1,000,000! Click here immediately to claim your prize! " - "Limited time offer - act now!" - ), - } - - # Start orchestration - response = self.helper.post_json(f"{self.base_url}/api/workflow/{WORKFLOW_NAME}/run", payload) - assert response.status_code == 202 - data = response.json() - assert "instanceId" in data - assert "statusQueryGetUri" in data - - # Wait for completion - status = self.helper.wait_for_orchestration_with_output(data["statusQueryGetUri"]) - assert status["runtimeStatus"] == "Completed" - assert "output" in status - - def test_workflow_with_legitimate_email(self) -> None: - """Test workflow with legitimate email - should draft a response.""" - payload = { - "email_id": "email-test-002", - "email_content": ( - "Hi team, just a reminder about our sprint planning meeting tomorrow at 10 AM. " - "Please review the agenda in Jira." - ), - } - - # Start orchestration - response = self.helper.post_json(f"{self.base_url}/api/workflow/{WORKFLOW_NAME}/run", payload) - assert response.status_code == 202 - data = response.json() - assert "instanceId" in data - assert "statusQueryGetUri" in data - - # Wait for completion - status = self.helper.wait_for_orchestration_with_output(data["statusQueryGetUri"]) - assert status["runtimeStatus"] == "Completed" - assert "output" in status - - def test_workflow_status_endpoint(self) -> None: - """Test that the status endpoint works correctly.""" - payload = { - "email_id": "email-test-003", - "email_content": "Quick question: When is the next team meeting scheduled?", - } - - # Start orchestration - response = self.helper.post_json(f"{self.base_url}/api/workflow/{WORKFLOW_NAME}/run", payload) - assert response.status_code == 202 - data = response.json() - instance_id = data["instanceId"] - - # Check status using the workflow status endpoint - status_response = self.helper.get(f"{self.base_url}/api/workflow/{WORKFLOW_NAME}/status/{instance_id}") - assert status_response.status_code == 200 - status = status_response.json() - assert "instanceId" in status - assert status["instanceId"] == instance_id - assert "runtimeStatus" in status - - # Wait for completion to clean up - self.helper.wait_for_orchestration(data["statusQueryGetUri"]) - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) diff --git a/python/packages/azurefunctions/tests/integration_tests/test_11_workflow_parallel.py b/python/packages/azurefunctions/tests/integration_tests/test_11_workflow_parallel.py deleted file mode 100644 index 2b51301abc9..00000000000 --- a/python/packages/azurefunctions/tests/integration_tests/test_11_workflow_parallel.py +++ /dev/null @@ -1,87 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. -""" -Integration Tests for Parallel Workflow Sample - -Tests the parallel workflow execution sample demonstrating: -- Two executors running concurrently (fan-out to activities) -- Two agents running concurrently (fan-out to entities) -- Mixed agent + executor running concurrently - -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_11_workflow_parallel.py -v -""" - -import pytest - -# Must match the workflow name in samples/04-hosting/azure_functions/11_workflow_parallel/function_app.py -WORKFLOW_NAME = "parallel_review" - -# Module-level markers - applied to all tests in this file -pytestmark = [ - pytest.mark.flaky, - pytest.mark.integration, - pytest.mark.sample("11_workflow_parallel"), - pytest.mark.usefixtures("function_app_for_test"), -] - - -@pytest.mark.orchestration -class TestWorkflowParallel: - """Tests for 11_workflow_parallel sample.""" - - @pytest.fixture(autouse=True) - def _setup(self, base_url: str, sample_helper) -> None: - """Provide the helper and base URL for each test.""" - self.base_url = base_url - self.helper = sample_helper - - @pytest.mark.skip(reason="Flaky in CI: times out / crashes the xdist runner; temporarily disabled.") - def test_parallel_workflow_end_to_end(self) -> None: - """Run the parallel workflow end-to-end: start, check status, verify completion. - - Consolidated into a single test on purpose: the work-stealing xdist scheduler - distributes tests (not modules) across workers, and the module-scoped - ``function_app_for_test`` fixture is created per worker -- so multiple tests in - this module would each spawn a separate ``func`` host for this resource-heavy - parallel sample. One test keeps it to a single host while still covering the - fan-out path end-to-end. - """ - payload = { - "document_id": "doc-test-001", - "content": ( - "The quarterly earnings report shows strong growth in our cloud services division. " - "Revenue increased by 25% compared to last year, driven by enterprise adoption. " - "Customer satisfaction remains high at 92%." - ), - } - - # Start the orchestration. - response = self.helper.post_json(f"{self.base_url}/api/workflow/{WORKFLOW_NAME}/run", payload) - assert response.status_code == 202 - data = response.json() - instance_id = data["instanceId"] - assert "statusQueryGetUri" in data - - # The status endpoint reflects the started instance. - status_response = self.helper.get(f"{self.base_url}/api/workflow/{WORKFLOW_NAME}/status/{instance_id}") - assert status_response.status_code == 200 - assert status_response.json()["instanceId"] == instance_id - - # Fan-out to parallel processors and agents completes with an aggregated output. - status = self.helper.wait_for_orchestration_with_output(data["statusQueryGetUri"], max_wait=300) - 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_12_workflow_hitl.py b/python/packages/azurefunctions/tests/integration_tests/test_12_workflow_hitl.py deleted file mode 100644 index f018f07e86f..00000000000 --- a/python/packages/azurefunctions/tests/integration_tests/test_12_workflow_hitl.py +++ /dev/null @@ -1,284 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. -""" -Integration Tests for Workflow Human-in-the-Loop (HITL) Sample - -Tests the workflow HITL sample demonstrating content moderation with human approval -using the MAF request_info / @response_handler pattern. - -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_12_workflow_hitl.py -v -""" - -import time -import uuid - -import pytest - -from agent_framework_azurefunctions import WorkflowHitlContext - -# Module-level markers - applied to all tests in this file -pytestmark = [ - pytest.mark.flaky, - pytest.mark.integration, - pytest.mark.sample("12_workflow_hitl"), - pytest.mark.usefixtures("function_app_for_test"), -] - -# Must match the workflow name in samples/04-hosting/azure_functions/12_workflow_hitl/function_app.py -WORKFLOW_NAME = "content_moderation" - - -@pytest.mark.orchestration -class TestWorkflowHITL: - """Tests for 12_workflow_hitl sample.""" - - @pytest.fixture(autouse=True) - def _setup(self, base_url: str, sample_helper) -> None: - """Provide the helper and base URL for each test.""" - self.base_url = base_url - self.helper = sample_helper - - def _wait_for_hitl_request(self, instance_id: str, timeout: int = 40) -> dict: - """Polls for a pending HITL request.""" - start_time = time.time() - while time.time() - start_time < timeout: - status_response = self.helper.get(f"{self.base_url}/api/workflow/{WORKFLOW_NAME}/status/{instance_id}") - if status_response.status_code == 200: - status = status_response.json() - pending_requests = status.get("pendingHumanInputRequests", []) - if pending_requests: - return status - time.sleep(2) - raise AssertionError(f"Timed out waiting for HITL request for instance {instance_id}") - - def test_hitl_workflow_approval(self) -> None: - """Test HITL workflow with human approval.""" - payload = { - "content_id": "article-test-001", - "title": "Introduction to AI in Healthcare", - "body": ( - "Artificial intelligence is revolutionizing healthcare by enabling faster diagnosis, " - "personalized treatment plans, and improved patient outcomes. Machine learning algorithms " - "can analyze medical images with remarkable accuracy." - ), - "author": "Dr. Jane Smith", - } - - # Start orchestration - response = self.helper.post_json(f"{self.base_url}/api/workflow/{WORKFLOW_NAME}/run", payload) - assert response.status_code == 202 - data = response.json() - assert "instanceId" in data - assert "statusQueryGetUri" in data - instance_id = data["instanceId"] - - # Wait for the workflow to reach the HITL pause point - status = self._wait_for_hitl_request(instance_id) - - # Confirm status is valid - assert status["runtimeStatus"] in ["Running", "Pending"] - - # Get the request ID from pending requests - pending_requests = status.get("pendingHumanInputRequests", []) - assert len(pending_requests) > 0, "Expected pending HITL request" - request_id = pending_requests[0]["requestId"] - - # Send approval - approval_response = self.helper.post_json( - f"{self.base_url}/api/workflow/{WORKFLOW_NAME}/respond/{instance_id}/{request_id}", - {"approved": True, "reviewer_notes": "Content is appropriate and well-written."}, - ) - assert approval_response.status_code == 200 - - # Wait for orchestration to complete - final_status = self.helper.wait_for_orchestration(data["statusQueryGetUri"]) - assert final_status["runtimeStatus"] == "Completed" - assert "output" in final_status - - def test_hitl_workflow_rejection(self) -> None: - """Test HITL workflow with human rejection.""" - payload = { - "content_id": "article-test-002", - "title": "Get Rich Quick Scheme", - "body": ( - "Click here NOW to make $10,000 overnight! This SECRET method is GUARANTEED to work! " - "Limited time offer - act NOW before it's too late!" - ), - "author": "Definitely Not Spam", - } - - # Start orchestration - response = self.helper.post_json(f"{self.base_url}/api/workflow/{WORKFLOW_NAME}/run", payload) - assert response.status_code == 202 - data = response.json() - instance_id = data["instanceId"] - - # Wait for the workflow to reach the HITL pause point - status = self._wait_for_hitl_request(instance_id) - - # Get the request ID from pending requests - pending_requests = status.get("pendingHumanInputRequests", []) - assert len(pending_requests) > 0, "Expected pending HITL request" - request_id = pending_requests[0]["requestId"] - - # Send rejection - rejection_response = self.helper.post_json( - f"{self.base_url}/api/workflow/{WORKFLOW_NAME}/respond/{instance_id}/{request_id}", - {"approved": False, "reviewer_notes": "Content appears to be spam/scam material."}, - ) - assert rejection_response.status_code == 200 - - # Wait for orchestration to complete - final_status = self.helper.wait_for_orchestration(data["statusQueryGetUri"]) - assert final_status["runtimeStatus"] == "Completed" - assert "output" in final_status - # The output should indicate rejection - output = final_status["output"] - assert "rejected" in str(output).lower() - - def test_hitl_workflow_status_endpoint(self) -> None: - """Test that the workflow status endpoint shows pending HITL requests.""" - payload = { - "content_id": "article-test-003", - "title": "Test Article", - "body": "This is a test article for checking status endpoint functionality.", - "author": "Test Author", - } - - # Start orchestration - response = self.helper.post_json(f"{self.base_url}/api/workflow/{WORKFLOW_NAME}/run", payload) - assert response.status_code == 202 - data = response.json() - instance_id = data["instanceId"] - - # Wait for HITL pause - status = self._wait_for_hitl_request(instance_id) - - # Check status - assert "instanceId" in status - assert status["instanceId"] == instance_id - assert "runtimeStatus" in status - assert "pendingHumanInputRequests" in status - - # Clean up: approve to complete - pending_requests = status.get("pendingHumanInputRequests", []) - if pending_requests: - request_id = pending_requests[0]["requestId"] - self.helper.post_json( - f"{self.base_url}/api/workflow/{WORKFLOW_NAME}/respond/{instance_id}/{request_id}", - {"approved": True, "reviewer_notes": ""}, - ) - - # Wait for completion - self.helper.wait_for_orchestration(data["statusQueryGetUri"]) - - def test_hitl_workflow_with_neutral_content(self) -> None: - """Test HITL workflow with neutral content that should get medium risk.""" - payload = { - "content_id": "article-test-004", - "title": "Product Review", - "body": ( - "This product works as advertised. The build quality is average and the price " - "is reasonable. I would recommend it for basic use cases but not for professional work." - ), - "author": "Regular User", - } - - # Start orchestration - response = self.helper.post_json(f"{self.base_url}/api/workflow/{WORKFLOW_NAME}/run", payload) - assert response.status_code == 202 - data = response.json() - instance_id = data["instanceId"] - - # Wait for HITL pause - status = self._wait_for_hitl_request(instance_id) - - pending_requests = status.get("pendingHumanInputRequests", []) - assert len(pending_requests) > 0 - request_id = pending_requests[0]["requestId"] - - # Approve - self.helper.post_json( - f"{self.base_url}/api/workflow/{WORKFLOW_NAME}/respond/{instance_id}/{request_id}", - {"approved": True, "reviewer_notes": "Approved after review."}, - ) - - # Wait for completion - final_status = self.helper.wait_for_orchestration(data["statusQueryGetUri"]) - assert final_status["runtimeStatus"] == "Completed" - - def test_hitl_notify_respond_url_matches_helper(self) -> None: - """The respond URL WorkflowHitlContext builds equals the one the server accepts. - - This is the core guarantee of the in-workflow notify pattern: the URL an - executor builds (via ``WorkflowHitlContext`` -- the same one ``NotifyExecutor`` - would email a reviewer) is byte-for-byte the canonical respond URL the status - endpoint exposes, and POSTing to it actually resumes the run. - """ - payload = { - "content_id": "article-test-005", - "title": "Sustainable Gardening Basics", - "body": ( - "Composting kitchen scraps enriches soil naturally and reduces waste. " - "Rotating crops each season helps prevent nutrient depletion." - ), - "author": "Green Thumb", - } - - # Start orchestration - response = self.helper.post_json(f"{self.base_url}/api/workflow/{WORKFLOW_NAME}/run", payload) - assert response.status_code == 202 - data = response.json() - instance_id = data["instanceId"] - - # Wait for the workflow to reach the HITL pause point - status = self._wait_for_hitl_request(instance_id) - pending_requests = status.get("pendingHumanInputRequests", []) - assert len(pending_requests) > 0, "Expected pending HITL request" - pending = pending_requests[0] - request_id = pending["requestId"] - - # request_info generates the request id internally as a uuid4 when the caller - # does not pass one, so the pending id round-trips as a valid UUID (not an - # opaque framework default). - uuid.UUID(request_id) # raises ValueError if not a valid UUID - - # The request originates in the executor that called request_info. - assert pending.get("sourceExecutor") == "human_review_executor" - - # Build the respond URL the same way an in-workflow executor would, via the - # public helper, pointing it at this app's base URL. It must equal the - # server-exposed respondUrl exactly -- i.e. the link NotifyExecutor emails is - # the one the /respond endpoint honors. - hitl = WorkflowHitlContext( - instance_id=instance_id, - workflow_name=WORKFLOW_NAME, - base_url_override=self.base_url, - ) - helper_url = hitl.build_respond_url(request_id) - assert helper_url == pending["respondUrl"] - - # Responding via the helper-built URL resumes the workflow to completion. - approval_response = self.helper.post_json( - helper_url, - {"approved": True, "reviewer_notes": "Looks good."}, - ) - assert approval_response.status_code == 200 - - final_status = self.helper.wait_for_orchestration(data["statusQueryGetUri"]) - assert final_status["runtimeStatus"] == "Completed" - assert "output" in final_status - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) diff --git a/python/packages/azurefunctions/tests/integration_tests/test_13_workflow_subworkflow_hitl.py b/python/packages/azurefunctions/tests/integration_tests/test_13_workflow_subworkflow_hitl.py deleted file mode 100644 index 70d0a856756..00000000000 --- a/python/packages/azurefunctions/tests/integration_tests/test_13_workflow_subworkflow_hitl.py +++ /dev/null @@ -1,199 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. -""" -Integration Tests for the Sub-workflow HITL Sample (13_subworkflow_hitl) - -Tests nested human-in-the-loop through the Azure Functions host: the HITL pause -lives inside an inner workflow embedded via ``WorkflowExecutor``, so the pending -request surfaces at the top-level instance with a **qualified** request id -(``review_sub~0~{requestId}``). The caller responds against the top-level instance -and the host routes it to the owning child orchestration. - -This sample hosts no AI agents, so it exercises the AF nested-HITL plumbing -deterministically (no model latency / variability). - -The function app is automatically started by the test fixture. - -Prerequisites: -- Azurite running for durable orchestrations -- Durable Task Scheduler emulator running on localhost:8080 - -Usage: - uv run pytest packages/azurefunctions/tests/integration_tests/test_13_workflow_subworkflow_hitl.py -v -""" - -import time -import uuid - -import pytest - -from agent_framework_azurefunctions import WorkflowHitlContext - -# Module-level markers - applied to all tests in this file -pytestmark = [ - pytest.mark.flaky, - pytest.mark.integration, - pytest.mark.sample("13_subworkflow_hitl"), - pytest.mark.usefixtures("function_app_for_test"), -] - -# Must match the outer workflow name in samples/.../13_subworkflow_hitl/function_app.py -WORKFLOW_NAME = "moderation_pipeline" -# The WorkflowExecutor node id that embeds the inner HITL workflow. -SUBWORKFLOW_NODE_ID = "review_sub" - - -@pytest.mark.orchestration -class TestSubworkflowHITL: - """Tests for the 13_subworkflow_hitl sample (nested HITL behind one surface).""" - - @pytest.fixture(autouse=True) - def _setup(self, base_url: str, sample_helper) -> None: - """Provide the helper and base URL for each test.""" - self.base_url = base_url - self.helper = sample_helper - - def _wait_for_hitl_request(self, instance_id: str, timeout: int = 40) -> dict: - """Poll the top-level status endpoint until a (nested) HITL request appears.""" - start_time = time.time() - while time.time() - start_time < timeout: - status_response = self.helper.get(f"{self.base_url}/api/workflow/{WORKFLOW_NAME}/status/{instance_id}") - if status_response.status_code == 200: - status = status_response.json() - if status.get("pendingHumanInputRequests"): - return status - time.sleep(2) - raise AssertionError(f"Timed out waiting for a nested HITL request for instance {instance_id}") - - def _start(self, payload: dict) -> dict: - """Start the outer workflow and return the run response JSON.""" - response = self.helper.post_json(f"{self.base_url}/api/workflow/{WORKFLOW_NAME}/run", payload) - assert response.status_code == 202 - return response.json() - - def test_nested_request_surfaces_with_qualified_id(self) -> None: - """The nested pending request is surfaced with a ``review_sub~0~{id}`` qualified id.""" - data = self._start({ - "content_id": "article-100", - "title": "Quarterly Roadmap", - "body": "A summary of the upcoming features planned for the next quarter.", - }) - instance_id = data["instanceId"] - - status = self._wait_for_hitl_request(instance_id) - pending = status.get("pendingHumanInputRequests", []) - assert len(pending) == 1 - request_id = pending[0]["requestId"] - - # The qualifier carries the node id and the child's ordinal (0 for the single - # dispatch), then the inner bare request id: ``review_sub~0~{requestId}``. - expected_prefix = f"{SUBWORKFLOW_NODE_ID}~0~" - assert request_id.startswith(expected_prefix), request_id - assert request_id[len(expected_prefix) :] # non-empty inner id - - # The respondUrl always targets the top-level instance. - assert f"/api/workflow/{WORKFLOW_NAME}/respond/{instance_id}/" in pending[0]["respondUrl"] - - # Drain the pause so the instance does not hang. - approve = self.helper.post_json( - f"{self.base_url}/api/workflow/{WORKFLOW_NAME}/respond/{instance_id}/{request_id}", - {"approved": True, "reviewer_notes": "ok"}, - ) - assert approve.status_code == 200 - self.helper.wait_for_orchestration(data["statusQueryGetUri"]) - - def test_nested_hitl_approval(self) -> None: - """Responding 'approved' to the nested request resumes the outer workflow to APPROVED.""" - data = self._start({ - "content_id": "article-001", - "title": "Introduction to AI in Healthcare", - "body": ( - "Artificial intelligence is improving healthcare by enabling faster diagnosis, " - "personalized treatment plans, and better patient outcomes." - ), - }) - instance_id = data["instanceId"] - - status = self._wait_for_hitl_request(instance_id) - request_id = status["pendingHumanInputRequests"][0]["requestId"] - - approval = self.helper.post_json( - f"{self.base_url}/api/workflow/{WORKFLOW_NAME}/respond/{instance_id}/{request_id}", - {"approved": True, "reviewer_notes": "Looks good."}, - ) - assert approval.status_code == 200 - - final_status = self.helper.wait_for_orchestration(data["statusQueryGetUri"]) - assert final_status["runtimeStatus"] == "Completed" - assert "APPROVED" in str(final_status.get("output")).upper() - - def test_nested_hitl_rejection(self) -> None: - """Responding 'rejected' to the nested request resumes the outer workflow to REJECTED.""" - data = self._start({ - "content_id": "article-002", - "title": "Get Rich Quick", - "body": "Click here NOW to make $10,000 overnight! GUARANTEED! Limited time offer!", - }) - instance_id = data["instanceId"] - - status = self._wait_for_hitl_request(instance_id) - request_id = status["pendingHumanInputRequests"][0]["requestId"] - - rejection = self.helper.post_json( - f"{self.base_url}/api/workflow/{WORKFLOW_NAME}/respond/{instance_id}/{request_id}", - {"approved": False, "reviewer_notes": "Violates content policy."}, - ) - assert rejection.status_code == 200 - - final_status = self.helper.wait_for_orchestration(data["statusQueryGetUri"]) - assert final_status["runtimeStatus"] == "Completed" - assert "REJECTED" in str(final_status.get("output")).upper() - - def test_nested_notify_respond_url_matches_helper(self) -> None: - """The URL the nested NotifyExecutor builds equals the server's qualified respondUrl. - - Proves the address-propagation path end-to-end: the inner ``notify`` executor - (running in the child orchestration) builds a respond URL from the propagated - root instance + ``review_sub~0~`` prefix, given only the inner bare request id, - and that URL is byte-for-byte the qualified ``respondUrl`` the top-level status - endpoint exposes -- and POSTing to it resumes the nested run. - """ - data = self._start({ - "content_id": "article-200", - "title": "Tide Pool Ecology", - "body": "Intertidal zones host a surprising diversity of resilient marine life.", - }) - instance_id = data["instanceId"] - - status = self._wait_for_hitl_request(instance_id) - pending = status.get("pendingHumanInputRequests", []) - assert len(pending) == 1 - qualified_id = pending[0]["requestId"] - - # The qualified id is ``review_sub~0~{bare}``; request_info generates the bare - # inner id internally as a uuid4 when the review gate does not pass one. - prefix = f"{SUBWORKFLOW_NODE_ID}~0~" - assert qualified_id.startswith(prefix), qualified_id - bare_id = qualified_id[len(prefix) :] - uuid.UUID(bare_id) # raises if the inner id is not a valid uuid4 - - # Rebuild the URL exactly as the nested NotifyExecutor does: the root instance - # and root workflow name, the propagated path prefix, and only the bare id. - hitl = WorkflowHitlContext( - instance_id=instance_id, - workflow_name=WORKFLOW_NAME, - base_url_override=self.base_url, - request_path_prefix=prefix, - ) - helper_url = hitl.build_respond_url(bare_id) - assert helper_url == pending[0]["respondUrl"] - - # Responding via the helper-built URL resumes the nested run to completion. - approve = self.helper.post_json(helper_url, {"approved": True, "reviewer_notes": "ok"}) - assert approve.status_code == 200 - final_status = self.helper.wait_for_orchestration(data["statusQueryGetUri"]) - assert final_status["runtimeStatus"] == "Completed" - assert "APPROVED" in str(final_status.get("output")).upper() - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) diff --git a/python/packages/azurefunctions/tests/test_app.py b/python/packages/azurefunctions/tests/test_app.py deleted file mode 100644 index 93ede3aeb5a..00000000000 --- a/python/packages/azurefunctions/tests/test_app.py +++ /dev/null @@ -1,1966 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Unit tests for AgentFunctionApp.""" - -# pyright: reportPrivateUsage=false - -import json -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 AgentResponse, Message -from agent_framework_durabletask import ( - MIMETYPE_APPLICATION_JSON, - MIMETYPE_TEXT_PLAIN, - THREAD_ID_HEADER, - WAIT_FOR_RESPONSE_FIELD, - WAIT_FOR_RESPONSE_HEADER, - AgentEntity, - AgentEntityStateProviderMixin, - DurableAgentState, - workflow_orchestrator_name, -) - -from agent_framework_azurefunctions import AgentFunctionApp -from agent_framework_azurefunctions._entities import create_agent_entity - -FuncT = TypeVar("FuncT", bound=Callable[..., Any]) - - -def _identity_decorator(func: FuncT) -> FuncT: - return func - - -class _InMemoryStateProvider(AgentEntityStateProviderMixin): - def __init__(self, *, thread_id: str = "test-thread", initial_state: dict[str, Any] | None = None) -> None: - self._thread_id = thread_id - self._state_dict: dict[str, Any] = initial_state or {} - - def _get_state_dict(self) -> dict[str, Any]: - return self._state_dict - - def _set_state_dict(self, state: dict[str, Any]) -> None: - self._state_dict = state - - def _get_thread_id_from_entity(self) -> str: - return self._thread_id - - -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, _enable_mcp_tool_trigger = 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, _enable_mcp_tool_trigger = 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, _enable_mcp_tool_trigger = 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[[FuncT], FuncT]: - def decorator(func: FuncT) -> FuncT: - 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[[FuncT], FuncT]: - def decorator(func: FuncT) -> FuncT: - captured_names.append(name) - return func - - return decorator - - def passthrough_decorator(*args: Any, **kwargs: Any) -> Callable[[FuncT], FuncT]: - def decorator(func: FuncT) -> FuncT: - 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[[FuncT], FuncT]: - def decorator(func: FuncT) -> FuncT: - 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[[FuncT], FuncT]: - def decorator(func: FuncT) -> FuncT: - 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_metadata["OverrideAgent"].http_endpoint_enabled 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_metadata["DisabledOverride"].http_endpoint_enabled 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=AgentResponse(messages=[Message(role="assistant", contents=["Test response"])]) - ) - - entity = AgentEntity(mock_agent, state_provider=_InMemoryStateProvider(thread_id="test-conv-123")) - - result = await entity.run({ - "message": "Test message", - "correlationId": "corr-app-entity-1", - }) - - assert isinstance(result, AgentResponse) - assert result.text == "Test response" - assert entity.state.message_count == 2 - - 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=AgentResponse(messages=[Message(role="assistant", contents=["Response 1"])]) - ) - - entity = AgentEntity(mock_agent, state_provider=_InMemoryStateProvider(thread_id="conv-1")) - - # Send first message - await entity.run({"message": "Message 1", "correlationId": "corr-app-entity-2"}) - - # Each conversation turn creates 2 entries: request and response - history = entity.state.data.conversation_history[0].messages # Request entry - assert len(history) == 1 # Just the user message - - # Send second message - await entity.run({"message": "Message 2", "correlationId": "corr-app-entity-2b"}) - - # Now we have 4 entries total (2 requests + 2 responses) - # Access the first request entry - history2 = entity.state.data.conversation_history[2].messages # Second request entry - assert len(history2) == 1 # Just the user message - - 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 = entity.state.data.conversation_history[1].messages[0] - 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=AgentResponse(messages=[Message(role="assistant", contents=["Response"])]) - ) - - entity = AgentEntity(mock_agent, state_provider=_InMemoryStateProvider(thread_id="conv-1")) - - assert len(entity.state.data.conversation_history) == 0 - - await entity.run({"message": "Message 1", "correlationId": "corr-app-entity-3a"}) - assert len(entity.state.data.conversation_history) == 2 - - await entity.run({"message": "Message 2", "correlationId": "corr-app-entity-3b"}) - assert len(entity.state.data.conversation_history) == 4 - - def test_entity_reset(self) -> None: - """Test that entity reset clears state.""" - mock_agent = Mock() - entity = AgentEntity(mock_agent, state_provider=_InMemoryStateProvider()) - - # Set some state - entity.state = DurableAgentState() - - # Reset - entity.reset() - - assert len(entity.state.data.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_operation(self) -> None: - """Test that the entity function handles the run operation.""" - mock_agent = Mock() - mock_agent.run = AsyncMock( - return_value=AgentResponse(messages=[Message(role="assistant", contents=["Response"])]) - ) - - entity_function = create_agent_entity(mock_agent) - - # Mock context - mock_context = Mock() - mock_context.operation_name = "run" - mock_context.get_input.return_value = { - "message": "Test message", - "correlationId": "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 - result_call = mock_context.set_result.call_args[0][0] - assert "error" not in result_call - - def test_entity_function_handles_run_agent_operation(self) -> None: - """Test that the entity function handles the deprecated run_agent operation for backward compatibility.""" - mock_agent = Mock() - mock_agent.run = AsyncMock( - return_value=AgentResponse(messages=[Message(role="assistant", contents=["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", - "correlationId": "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 - result_call = mock_context.set_result.call_args[0][0] - assert "error" not in result_call - - 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 = { - "schemaVersion": "1.0.0", - "data": { - "conversationHistory": [ - { - "$type": "request", - "correlationId": "corr-reset-test", - "createdAt": "2024-01-01T00:00:00Z", - "messages": [ - { - "role": "user", - "contents": [ - { - "$type": "text", - "text": "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 = { - "schemaVersion": "1.0.0", - "data": { - "conversationHistory": [ - { - "$type": "request", - "correlationId": "corr-existing-1", - "createdAt": "2024-01-01T00:00:00Z", - "messages": [ - { - "role": "user", - "contents": [ - { - "$type": "text", - "text": "msg1", - } - ], - } - ], - }, - { - "$type": "response", - "correlationId": "corr-existing-1", - "createdAt": "2024-01-01T00:05:00Z", - "messages": [ - { - "role": "assistant", - "contents": [ - { - "$type": "text", - "text": "resp1", - } - ], - } - ], - }, - ], - }, - } - - mock_context = Mock() - mock_context.operation_name = "run" - mock_context.get_input.return_value = { - "message": "Test message", - "correlationId": "corr-restore-1", - } - mock_context.get_state.return_value = existing_state - - with patch.object(DurableAgentState, "from_dict", wraps=DurableAgentState.from_dict) as from_dict_mock: - entity_function(mock_context) - - from_dict_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, state_provider=_InMemoryStateProvider(thread_id="conv-1")) - - result = await entity.run({ - "message": "Test message", - "correlationId": "corr-app-error-1", - }) - - assert isinstance(result, AgentResponse) - assert len(result.messages) == 1 - content = result.messages[0].contents[0] - assert content.type == "error" - assert "Agent error" in (content.message or "") - assert content.error_code == "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" - 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": MIMETYPE_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: dict[str, Any] = {} - - 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[[FuncT], FuncT]: - def decorator(func: FuncT) -> FuncT: - return func - - return decorator - - def capture_route(*args: Any, **kwargs: Any) -> Callable[[FuncT], FuncT]: - def decorator(func: FuncT) -> FuncT: - 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 == MIMETYPE_TEXT_PLAIN - assert response.headers.get(THREAD_ID_HEADER) 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" not 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": MIMETYPE_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 == MIMETYPE_APPLICATION_JSON - assert response.headers.get(THREAD_ID_HEADER) 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 == MIMETYPE_TEXT_PLAIN - assert response.headers.get(THREAD_ID_HEADER) is not None - assert response.get_body().decode("utf-8") == "Message is required" - client.signal_entity.assert_not_called() - - -class TestMCPToolEndpoint: - """Test suite for MCP tool endpoint functionality.""" - - def test_init_with_mcp_tool_endpoint_enabled(self) -> None: - """Test initialization with MCP tool endpoint enabled.""" - mock_agent = Mock() - mock_agent.name = "TestAgent" - - app = AgentFunctionApp(agents=[mock_agent], enable_mcp_tool_trigger=True) - - assert app.enable_mcp_tool_trigger is True - - def test_init_with_mcp_tool_endpoint_disabled(self) -> None: - """Test initialization with MCP tool endpoint disabled (default).""" - mock_agent = Mock() - mock_agent.name = "TestAgent" - - app = AgentFunctionApp(agents=[mock_agent]) - - assert app.enable_mcp_tool_trigger is False - - def test_add_agent_with_mcp_tool_trigger_enabled(self) -> None: - """Test adding an agent with MCP tool trigger explicitly enabled.""" - mock_agent = Mock() - mock_agent.name = "MCPAgent" - mock_agent.description = "Test MCP Agent" - - with patch.object(AgentFunctionApp, "_setup_agent_functions") as setup_mock: - app = AgentFunctionApp() - app.add_agent(mock_agent, enable_mcp_tool_trigger=True) - - setup_mock.assert_called_once() - _, _, _, _, enable_mcp = setup_mock.call_args[0] - assert enable_mcp is True - - def test_add_agent_with_mcp_tool_trigger_disabled(self) -> None: - """Test adding an agent with MCP tool trigger explicitly disabled.""" - mock_agent = Mock() - mock_agent.name = "NoMCPAgent" - - with patch.object(AgentFunctionApp, "_setup_agent_functions") as setup_mock: - app = AgentFunctionApp(enable_mcp_tool_trigger=True) - app.add_agent(mock_agent, enable_mcp_tool_trigger=False) - - setup_mock.assert_called_once() - _, _, _, _, enable_mcp = setup_mock.call_args[0] - assert enable_mcp is False - - def test_agent_override_enables_mcp_when_app_disabled(self) -> None: - """Test that per-agent override can enable MCP when app-level is disabled.""" - mock_agent = Mock() - mock_agent.name = "OverrideAgent" - - with patch.object(AgentFunctionApp, "_setup_mcp_tool_trigger") as mcp_setup_mock: - app = AgentFunctionApp(enable_mcp_tool_trigger=False) - app.add_agent(mock_agent, enable_mcp_tool_trigger=True) - - mcp_setup_mock.assert_called_once() - - def test_agent_override_disables_mcp_when_app_enabled(self) -> None: - """Test that per-agent override can disable MCP when app-level is enabled.""" - mock_agent = Mock() - mock_agent.name = "NoOverrideAgent" - - with patch.object(AgentFunctionApp, "_setup_mcp_tool_trigger") as mcp_setup_mock: - app = AgentFunctionApp(enable_mcp_tool_trigger=True) - app.add_agent(mock_agent, enable_mcp_tool_trigger=False) - - mcp_setup_mock.assert_not_called() - - def test_setup_mcp_tool_trigger_registers_decorators(self) -> None: - """Test that _setup_mcp_tool_trigger registers the correct decorators.""" - mock_agent = Mock() - mock_agent.name = "MCPToolAgent" - mock_agent.description = "Test MCP Tool" - - app = AgentFunctionApp() - - # Mock the decorators - with ( - patch.object(app, "function_name") as func_name_mock, - patch.object(app, "mcp_tool_trigger") as mcp_trigger_mock, - patch.object(app, "durable_client_input") as client_mock, - ): - # Setup mock decorator chain - func_name_mock.return_value = _identity_decorator - mcp_trigger_mock.return_value = _identity_decorator - client_mock.return_value = _identity_decorator - - app._setup_mcp_tool_trigger(mock_agent.name, mock_agent.description) - - # Verify decorators were called with correct parameters - func_name_mock.assert_called_once() - mcp_trigger_mock.assert_called_once_with( - arg_name="context", - tool_name=mock_agent.name, - description=mock_agent.description, - tool_properties=ANY, - data_type=func.DataType.UNDEFINED, - ) - client_mock.assert_called_once_with(client_name="client") - - def test_setup_mcp_tool_trigger_uses_default_description(self) -> None: - """Test that _setup_mcp_tool_trigger uses default description when none provided.""" - mock_agent = Mock() - mock_agent.name = "NoDescAgent" - - app = AgentFunctionApp() - - with ( - patch.object(app, "function_name", return_value=_identity_decorator), - patch.object(app, "mcp_tool_trigger") as mcp_trigger_mock, - patch.object(app, "durable_client_input", return_value=_identity_decorator), - ): - mcp_trigger_mock.return_value = _identity_decorator - - app._setup_mcp_tool_trigger(mock_agent.name, None) - - # Verify default description was used - call_args = mcp_trigger_mock.call_args - assert call_args[1]["description"] == f"Interact with {mock_agent.name} agent" - - async def test_handle_mcp_tool_invocation_with_json_string(self) -> None: - """Test _handle_mcp_tool_invocation with JSON string context.""" - mock_agent = Mock() - mock_agent.name = "TestAgent" - - app = AgentFunctionApp(agents=[mock_agent]) - client = AsyncMock() - - # Mock the entity response - mock_state = Mock() - mock_state.entity_state = { - "schemaVersion": "1.0.0", - "data": {"conversationHistory": []}, - } - client.read_entity_state.return_value = mock_state - - # Create JSON string context - context = '{"arguments": {"query": "test query", "threadId": "test-thread"}}' - - with patch.object(app, "_get_response_from_entity") as get_response_mock: - get_response_mock.return_value = {"status": "success", "response": "Test response"} - - result = await app._handle_mcp_tool_invocation("TestAgent", context, client) - - assert result == "Test response" - get_response_mock.assert_called_once() - - async def test_handle_mcp_tool_invocation_with_json_context(self) -> None: - """Test _handle_mcp_tool_invocation with JSON string context.""" - mock_agent = Mock() - mock_agent.name = "TestAgent" - - app = AgentFunctionApp(agents=[mock_agent]) - client = AsyncMock() - - # Mock the entity response - mock_state = Mock() - mock_state.entity_state = { - "schemaVersion": "1.0.0", - "data": {"conversationHistory": []}, - } - client.read_entity_state.return_value = mock_state - - # Create JSON string context - context = json.dumps({"arguments": {"query": "test query", "threadId": "test-thread"}}) - - with patch.object(app, "_get_response_from_entity") as get_response_mock: - get_response_mock.return_value = {"status": "success", "response": "Test response"} - - result = await app._handle_mcp_tool_invocation("TestAgent", context, client) - - assert result == "Test response" - get_response_mock.assert_called_once() - - async def test_handle_mcp_tool_invocation_missing_query(self) -> None: - """Test _handle_mcp_tool_invocation raises ValueError when query is missing.""" - mock_agent = Mock() - mock_agent.name = "TestAgent" - - app = AgentFunctionApp(agents=[mock_agent]) - client = AsyncMock() - - # Context missing query (as JSON string) - context = json.dumps({"arguments": {}}) - - with pytest.raises(ValueError, match="missing required 'query' argument"): - await app._handle_mcp_tool_invocation("TestAgent", context, client) - - async def test_handle_mcp_tool_invocation_invalid_json(self) -> None: - """Test _handle_mcp_tool_invocation raises ValueError for invalid JSON.""" - mock_agent = Mock() - mock_agent.name = "TestAgent" - - app = AgentFunctionApp(agents=[mock_agent]) - client = AsyncMock() - - # Invalid JSON string - context = "not valid json" - - with pytest.raises(ValueError, match="Invalid MCP context format"): - await app._handle_mcp_tool_invocation("TestAgent", context, client) - - async def test_handle_mcp_tool_invocation_runtime_error(self) -> None: - """Test _handle_mcp_tool_invocation raises RuntimeError when agent fails.""" - mock_agent = Mock() - mock_agent.name = "TestAgent" - - app = AgentFunctionApp(agents=[mock_agent]) - client = AsyncMock() - - # Mock the entity response - mock_state = Mock() - mock_state.entity_state = { - "schemaVersion": "1.0.0", - "data": {"conversationHistory": []}, - } - client.read_entity_state.return_value = mock_state - - context = '{"arguments": {"query": "test query"}}' - - with patch.object(app, "_get_response_from_entity") as get_response_mock: - get_response_mock.return_value = {"status": "failed", "error": "Agent error"} - - with pytest.raises(RuntimeError, match="Agent execution failed"): - await app._handle_mcp_tool_invocation("TestAgent", context, client) - - async def test_handle_mcp_tool_invocation_ignores_agent_name_in_thread_id(self) -> None: - """Test that MCP tool invocation uses the agent_name parameter, not the name from thread_id.""" - mock_agent = Mock() - mock_agent.name = "PlantAdvisor" - - app = AgentFunctionApp(agents=[mock_agent]) - client = AsyncMock() - - # Mock the entity response - mock_state = Mock() - mock_state.entity_state = { - "schemaVersion": "1.0.0", - "data": {"conversationHistory": []}, - } - client.read_entity_state.return_value = mock_state - - # Thread ID contains a different agent name (@StockAdvisor@poc123) - # but we're invoking PlantAdvisor - it should use PlantAdvisor's entity - context = json.dumps({"arguments": {"query": "test query", "threadId": "@StockAdvisor@test123"}}) - - with patch.object(app, "_get_response_from_entity") as get_response_mock: - get_response_mock.return_value = {"status": "success", "response": "Test response"} - - await app._handle_mcp_tool_invocation("PlantAdvisor", context, client) - - # Verify signal_entity was called with PlantAdvisor's entity, not StockAdvisor's - client.signal_entity.assert_called_once() - call_args = client.signal_entity.call_args - entity_id = call_args[0][0] - - # Entity name should be dafx-PlantAdvisor, not dafx-StockAdvisor - assert entity_id.name == "dafx-PlantAdvisor" - assert entity_id.key == "test123" - - async def test_handle_mcp_tool_invocation_uses_plain_thread_id_as_key(self) -> None: - """Test that a plain thread_id (not in @name@key format) is used as-is for the key.""" - mock_agent = Mock() - mock_agent.name = "TestAgent" - - app = AgentFunctionApp(agents=[mock_agent]) - client = AsyncMock() - - mock_state = Mock() - mock_state.entity_state = { - "schemaVersion": "1.0.0", - "data": {"conversationHistory": []}, - } - client.read_entity_state.return_value = mock_state - - # Plain thread_id without @name@key format - context = json.dumps({"arguments": {"query": "test query", "threadId": "simple-thread-123"}}) - - with patch.object(app, "_get_response_from_entity") as get_response_mock: - get_response_mock.return_value = {"status": "success", "response": "Test response"} - - await app._handle_mcp_tool_invocation("TestAgent", context, client) - - client.signal_entity.assert_called_once() - call_args = client.signal_entity.call_args - entity_id = call_args[0][0] - - assert entity_id.name == "dafx-TestAgent" - assert entity_id.key == "simple-thread-123" - - def test_health_check_includes_mcp_tool_enabled(self) -> None: - """Test that health check endpoint includes mcp_tool_enabled field.""" - mock_agent = Mock() - mock_agent.name = "HealthAgent" - - app = AgentFunctionApp(agents=[mock_agent], enable_mcp_tool_trigger=True) - - # Capture the health check handler function - captured_handler: Callable[[func.HttpRequest], func.HttpResponse] | None = None - - def capture_decorator(*args: Any, **kwargs: Any) -> Callable[[FuncT], FuncT]: - def decorator(func: FuncT) -> FuncT: - nonlocal captured_handler - captured_handler = func - return func - - return decorator - - with patch.object(app, "route", side_effect=capture_decorator): - app._setup_health_route() - - # Verify we captured the handler - assert captured_handler is not None - - # Call the health handler - request = Mock() - response = captured_handler(request) - - # Verify response includes mcp_tool_enabled - import json - - body = json.loads(response.get_body().decode("utf-8")) - assert "agents" in body - assert len(body["agents"]) == 1 - assert "mcp_tool_enabled" in body["agents"][0] - assert body["agents"][0]["mcp_tool_enabled"] is True - - -class TestAgentFunctionAppErrorPaths: - """Test suite for error handling paths.""" - - def test_init_with_invalid_max_poll_retries(self) -> None: - """Test initialization handles invalid max_poll_retries by falling back to default.""" - mock_agent = Mock() - mock_agent.name = "TestAgent" - - # Test with invalid type - app = AgentFunctionApp(agents=[mock_agent], max_poll_retries="invalid") # type: ignore[arg-type] # pyrefly: ignore[bad-argument-type] # ty: ignore[invalid-argument-type] - assert app.max_poll_retries >= 1 # Should use default - - # Test with None - app2 = AgentFunctionApp(agents=[mock_agent], max_poll_retries=None) # type: ignore[arg-type] # pyrefly: ignore[bad-argument-type] # ty: ignore[invalid-argument-type] - assert app2.max_poll_retries >= 1 # Should use default - - def test_init_with_invalid_poll_interval_seconds(self) -> None: - """Test initialization handles invalid poll_interval_seconds by falling back to default.""" - mock_agent = Mock() - mock_agent.name = "TestAgent" - - # Test with invalid type - app = AgentFunctionApp(agents=[mock_agent], poll_interval_seconds="invalid") # type: ignore[arg-type] # pyrefly: ignore[bad-argument-type] # ty: ignore[invalid-argument-type] - assert app.poll_interval_seconds > 0 # Should use default - - # Test with None - app2 = AgentFunctionApp(agents=[mock_agent], poll_interval_seconds=None) # type: ignore[arg-type] # pyrefly: ignore[bad-argument-type] # ty: ignore[invalid-argument-type] - assert app2.poll_interval_seconds > 0 # Should use default - - def test_get_agent_raises_for_unregistered_agent(self) -> None: - """Test get_agent raises ValueError for unregistered agent.""" - mock_agent = Mock() - mock_agent.name = "RegisteredAgent" - - app = AgentFunctionApp(agents=[mock_agent], enable_http_endpoints=False) - - # Create mock orchestration context - mock_context = Mock() - - # Should raise ValueError for unregistered agent - with pytest.raises(ValueError, match="Agent 'UnknownAgent' is not registered"): - app.get_agent(mock_context, "UnknownAgent") - - def test_convert_payload_to_text_with_response_key(self) -> None: - """Test _convert_payload_to_text returns response key value.""" - app = AgentFunctionApp(enable_http_endpoints=False, enable_health_check=False) - - # Test with response key - payload = {"response": "Test response"} - result = app._convert_payload_to_text(payload) - assert result == "Test response" - - # Test with error key - payload = {"error": "Error message"} - result = app._convert_payload_to_text(payload) - assert result == "Error message" - - # Test with message key - payload = {"message": "Message text"} - result = app._convert_payload_to_text(payload) - assert result == "Message text" - - # Test with no matching keys - should return JSON string - payload = {"other": "value"} - result = app._convert_payload_to_text(payload) - assert "other" in result - assert "value" in result - - def test_create_session_id_with_thread_id(self) -> None: - """Test _create_session_id with provided thread_id.""" - app = AgentFunctionApp(enable_http_endpoints=False, enable_health_check=False) - - # With thread_id provided - session_id = app._create_session_id("TestAgent", "my-thread-123") - assert session_id.key == "my-thread-123" - - # Without thread_id (None) - should generate random - session_id = app._create_session_id("TestAgent", None) - assert session_id.key is not None - assert len(session_id.key) > 0 - - def test_resolve_thread_id_from_body(self) -> None: - """Test _resolve_thread_id extracts from body.""" - app = AgentFunctionApp(enable_http_endpoints=False, enable_health_check=False) - - mock_req = Mock() - mock_req.params = {} - - # Thread ID in body - field name is "thread_id" - req_body = {"thread_id": "body-thread-123"} - result = app._resolve_thread_id(mock_req, req_body) - assert result == "body-thread-123" - - def test_select_body_parser_json_content_type(self) -> None: - """Test _select_body_parser for JSON content type.""" - app = AgentFunctionApp(enable_http_endpoints=False, enable_health_check=False) - - # Test with application/json - parser, format_str = app._select_body_parser("application/json") - assert parser == app._parse_json_body - assert format_str == "json" - - # Test with +json suffix - parser, format_str = app._select_body_parser("application/vnd.api+json") - assert parser == app._parse_json_body - assert format_str == "json" - - def test_accepts_json_response_with_accept_header(self) -> None: - """Test _accepts_json_response checks accept header.""" - app = AgentFunctionApp(enable_http_endpoints=False, enable_health_check=False) - - # With application/json in accept header - headers = {"accept": "application/json"} - result = app._accepts_json_response(headers) - assert result is True - - # Without accept header - headers = {} - result = app._accepts_json_response(headers) - assert result is False - - def test_parse_json_body_invalid_type(self) -> None: - """Test _parse_json_body raises error for invalid JSON.""" - from agent_framework_azurefunctions._errors import IncomingRequestError - - app = AgentFunctionApp(enable_http_endpoints=False, enable_health_check=False) - - # Mock request with non-dict JSON - mock_req = Mock() - mock_req.get_json.return_value = ["not", "a", "dict"] - - with pytest.raises(IncomingRequestError, match="Invalid JSON payload"): - app._parse_json_body(mock_req) - - def test_coerce_to_bool_with_none(self) -> None: - """Test _coerce_to_bool handles None and various value types.""" - app = AgentFunctionApp(enable_http_endpoints=False, enable_health_check=False) - - # None returns False - assert app._coerce_to_bool(None) is False - - # Integer - assert app._coerce_to_bool(1) is True - assert app._coerce_to_bool(0) is False - - # String - assert app._coerce_to_bool("true") is True - assert app._coerce_to_bool("false") is False - - # Other type returns False - assert app._coerce_to_bool([]) is False - - -class TestAgentFunctionAppWorkflow: - """Test suite for AgentFunctionApp workflow support.""" - - def test_init_with_workflow_stores_workflow(self) -> None: - """Test that workflow is stored when provided.""" - mock_workflow = Mock() - mock_workflow.name = "test_workflow" - mock_workflow.executors = {} - - with ( - patch.object(AgentFunctionApp, "_setup_executor_activity"), - patch.object(AgentFunctionApp, "_setup_workflow_orchestration"), - ): - app = AgentFunctionApp(workflow=mock_workflow) - - assert app.workflow is mock_workflow - - def test_init_with_workflow_registers_agent_entity_by_executor_id(self) -> None: - """Workflow agent executors are registered as entities keyed by executor id.""" - from agent_framework import AgentExecutor - - mock_agent = Mock() - mock_agent.name = "WorkflowAgent" - - mock_executor = Mock(spec=AgentExecutor) - mock_executor.agent = mock_agent - # Executor id intentionally differs from the agent name to exercise the - # identity fix: dispatch uses the executor id, so registration must too. - mock_executor.id = "custom-executor-id" - - mock_workflow = Mock() - mock_workflow.name = "orders" - mock_workflow.executors = {"custom-executor-id": mock_executor} - - with ( - patch.object(AgentFunctionApp, "_setup_executor_activity"), - patch.object(AgentFunctionApp, "_setup_workflow_orchestration"), - patch.object(AgentFunctionApp, "_setup_agent_entity") as setup_entity, - ): - app = AgentFunctionApp(workflow=mock_workflow) - - # The entity is registered under the workflow-scoped dispatch identity. - setup_entity.assert_called_once() - call_args = setup_entity.call_args.args - assert call_args[0] is mock_agent - assert call_args[1] == "orders-custom-executor-id" - - # Regression guard: the workflow agent must also be tracked on the app's - # normal registration surface, keyed by the scoped id, so it appears in - # ``agents`` and is retrievable via ``get_agent``. - assert "orders-custom-executor-id" in app.agents - assert app.agents["orders-custom-executor-id"] is mock_agent - - def test_init_with_workflow_calls_setup_methods(self) -> None: - """Test that workflow setup methods are called.""" - mock_executor = Mock() - mock_executor.id = "TestExecutor" - - mock_workflow = Mock() - mock_workflow.name = "test_workflow" - # Include a non-AgentExecutor so _setup_executor_activity is called - mock_workflow.executors = {"TestExecutor": mock_executor} - - with ( - patch.object(AgentFunctionApp, "_setup_executor_activity") as setup_exec, - patch.object(AgentFunctionApp, "_setup_workflow_orchestration") as setup_orch, - ): - AgentFunctionApp(workflow=mock_workflow) - - setup_exec.assert_called_once() - setup_orch.assert_called_once() - - def test_init_without_workflow_does_not_call_workflow_setup(self) -> None: - """Test that workflow setup is not called when no workflow provided.""" - mock_agent = Mock() - mock_agent.name = "TestAgent" - - with ( - patch.object(AgentFunctionApp, "_setup_executor_activity") as setup_exec, - patch.object(AgentFunctionApp, "_setup_workflow_orchestration") as setup_orch, - ): - AgentFunctionApp(agents=[mock_agent]) - - setup_exec.assert_not_called() - setup_orch.assert_not_called() - - def test_init_with_workflow_and_explicit_agent_does_not_raise(self) -> None: - """An agent passed explicitly and present in the workflow registers without error.""" - from agent_framework import AgentExecutor - - mock_agent = Mock() - mock_agent.name = "SharedAgent" - - mock_executor = Mock(spec=AgentExecutor) - mock_executor.agent = mock_agent - mock_executor.id = "SharedAgent" - - mock_workflow = Mock() - mock_workflow.name = "shared_flow" - mock_workflow.executors = {"SharedAgent": mock_executor} - - with ( - patch.object(AgentFunctionApp, "_setup_executor_activity"), - patch.object(AgentFunctionApp, "_setup_workflow_orchestration"), - patch.object(AgentFunctionApp, "_setup_agent_functions"), - patch.object(AgentFunctionApp, "_setup_agent_entity"), - ): - # Same agent passed explicitly AND present in workflow — should not raise - app = AgentFunctionApp(agents=[mock_agent], workflow=mock_workflow) - - assert "SharedAgent" in app.agents - - def test_init_with_multiple_workflows_registers_each(self) -> None: - """The workflows= list registers each workflow keyed by name.""" - from agent_framework import Executor - - def _wf(name: str, executor_id: str) -> Mock: - ex = Mock(spec=Executor) - ex.id = executor_id - wf = Mock() - wf.name = name - wf.executors = {executor_id: ex} - return wf - - with ( - patch.object(AgentFunctionApp, "_setup_executor_activity") as setup_exec, - patch.object(AgentFunctionApp, "_setup_workflow_orchestration") as setup_orch, - ): - app = AgentFunctionApp(workflows=[_wf("orders", "router"), _wf("billing", "router")]) - - assert set(app.workflows) == {"orders", "billing"} - assert app.workflow is None # ambiguous with >1 workflow - assert setup_exec.call_count == 2 - assert setup_orch.call_count == 2 - - def test_init_rejects_duplicate_workflow_name(self) -> None: - """Two workflows with the same name are rejected.""" - from agent_framework import Executor - - def _wf(executor_id: str) -> Mock: - ex = Mock(spec=Executor) - ex.id = executor_id - wf = Mock() - wf.name = "orders" - wf.executors = {executor_id: ex} - return wf - - with ( - patch.object(AgentFunctionApp, "_setup_executor_activity"), - patch.object(AgentFunctionApp, "_setup_workflow_orchestration"), - pytest.raises(ValueError, match="already registered"), - ): - AgentFunctionApp(workflows=[_wf("a"), _wf("b")]) - - def test_init_rejects_case_insensitive_duplicate_workflow_name(self) -> None: - """Workflow names that differ only by case collide and are rejected. - - The route ownership guard folds case, so hosting both ``orders`` and - ``Orders`` would let one workflow's routes reach the other's instances. - """ - from agent_framework import Executor - - def _wf(name: str, executor_id: str) -> Mock: - ex = Mock(spec=Executor) - ex.id = executor_id - wf = Mock() - wf.name = name - wf.executors = {executor_id: ex} - return wf - - with ( - patch.object(AgentFunctionApp, "_setup_executor_activity"), - patch.object(AgentFunctionApp, "_setup_workflow_orchestration"), - pytest.raises(ValueError, match="case-insensitively"), - ): - AgentFunctionApp(workflows=[_wf("orders", "a"), _wf("Orders", "b")]) - - def test_init_rejects_mapping_key_mismatch(self) -> None: - """A workflows mapping whose key disagrees with Workflow.name is rejected.""" - mock_workflow = Mock() - mock_workflow.name = "orders" - mock_workflow.executors = {} - - with ( - patch.object(AgentFunctionApp, "_setup_executor_activity"), - patch.object(AgentFunctionApp, "_setup_workflow_orchestration"), - pytest.raises(ValueError, match="does not match"), - ): - AgentFunctionApp(workflows={"wrong_key": mock_workflow}) - - def test_init_rejects_auto_generated_workflow_name(self) -> None: - """An auto-generated WorkflowBuilder name is rejected.""" - import uuid - - mock_workflow = Mock() - mock_workflow.name = f"WorkflowBuilder-{uuid.uuid4()}" - mock_workflow.executors = {} - - with ( - patch.object(AgentFunctionApp, "_setup_executor_activity"), - patch.object(AgentFunctionApp, "_setup_workflow_orchestration"), - pytest.raises(ValueError, match="auto-generated"), - ): - AgentFunctionApp(workflow=mock_workflow) - - -class TestAgentFunctionAppSubworkflow: - """Test recursive registration of nested sub-workflows on the Functions app.""" - - @staticmethod - def _inner_agent_wf(name: str, executor_id: str) -> tuple[Mock, Mock]: - from agent_framework import AgentExecutor - - agent = Mock() - agent.name = "InnerAssistant" - ex = Mock(spec=AgentExecutor) - ex.agent = agent - ex.id = executor_id - wf = Mock() - wf.name = name - wf.executors = {executor_id: ex} - return wf, agent - - @staticmethod - def _outer_wf(name: str, inner: Mock, *, sub_ids: tuple[str, ...] = ("sub",)) -> Mock: - from agent_framework import Executor, WorkflowExecutor - - executors: dict[str, Mock] = {} - for sid in sub_ids: - sub = Mock(spec=WorkflowExecutor) - sub.id = sid - sub.workflow = inner - sub.allow_direct_output = False - executors[sid] = sub - router = Mock(spec=Executor) - router.id = "router" - executors["router"] = router - wf = Mock() - wf.name = name - wf.executors = executors - return wf - - def test_nested_workflow_registers_both_orchestrations(self) -> None: - """An outer workflow registers an orchestration for itself and the inner workflow.""" - inner, _ = self._inner_agent_wf("inner", "agent_node") - outer = self._outer_wf("outer", inner) - - with ( - patch.object(AgentFunctionApp, "_setup_executor_activity"), - patch.object(AgentFunctionApp, "_setup_workflow_orchestration") as setup_orch, - ): - app = AgentFunctionApp(workflow=outer) - - assert setup_orch.call_count == 2 - registered = {call.args[0].name for call in setup_orch.call_args_list} - assert registered == {"outer", "inner"} - # Only the top-level workflow is tracked as an addressable workflow. - assert set(app.workflows) == {"outer"} - - def test_nested_workflow_registers_inner_agent_scoped(self) -> None: - """The inner workflow's agent entity is registered under the inner-scoped id.""" - inner, inner_agent = self._inner_agent_wf("inner", "agent_node") - outer = self._outer_wf("outer", inner) - - with ( - patch.object(AgentFunctionApp, "_setup_executor_activity"), - patch.object(AgentFunctionApp, "_setup_workflow_orchestration"), - patch.object(AgentFunctionApp, "_setup_agent_entity") as setup_entity, - ): - app = AgentFunctionApp(workflow=outer) - - setup_entity.assert_called_once() - call_args = setup_entity.call_args.args - assert call_args[0] is inner_agent - assert call_args[1] == "inner-agent_node" - assert "inner-agent_node" in app.agents - - def test_nested_workflow_routes_only_top_level(self) -> None: - """HTTP routes are registered only for the top-level workflow.""" - inner, _ = self._inner_agent_wf("inner", "agent_node") - outer = self._outer_wf("outer", inner) - - with ( - patch.object(AgentFunctionApp, "_setup_executor_activity"), - patch.object(AgentFunctionApp, "_setup_workflow_orchestration"), - patch.object(AgentFunctionApp, "_register_workflow_routes") as routes, - ): - AgentFunctionApp(workflow=outer) - - routes.assert_called_once() - assert routes.call_args.args[0] is outer - - def test_shared_subworkflow_registered_once(self) -> None: - """A sub-workflow reused by two nodes registers its orchestration only once.""" - inner, _ = self._inner_agent_wf("inner", "agent_node") - outer = self._outer_wf("outer", inner, sub_ids=("sub_a", "sub_b")) - - with ( - patch.object(AgentFunctionApp, "_setup_executor_activity"), - patch.object(AgentFunctionApp, "_setup_workflow_orchestration") as setup_orch, - ): - AgentFunctionApp(workflow=outer) - - registered = sorted(call.args[0].name for call in setup_orch.call_args_list) - assert registered == ["inner", "outer"] - - def test_nested_workflow_with_invalid_name_is_rejected(self) -> None: - """A nested sub-workflow must also have a valid, stable name.""" - inner, _ = self._inner_agent_wf("has space", "agent_node") - outer = self._outer_wf("outer", inner) - - with ( - patch.object(AgentFunctionApp, "_setup_executor_activity"), - patch.object(AgentFunctionApp, "_setup_workflow_orchestration"), - pytest.raises(ValueError, match="invalid"), - ): - AgentFunctionApp(workflow=outer) - - def test_different_subworkflow_sharing_a_name_is_rejected(self) -> None: - """Two different sub-workflow instances that share a name collide and are rejected.""" - inner_a, _ = self._inner_agent_wf("shared", "agent_node") - inner_b, _ = self._inner_agent_wf("shared", "other_node") # different instance, same name - from agent_framework import WorkflowExecutor - - sub_a = Mock(spec=WorkflowExecutor) - sub_a.id = "a" - sub_a.workflow = inner_a - sub_b = Mock(spec=WorkflowExecutor) - sub_b.id = "b" - sub_b.workflow = inner_b - outer = Mock() - outer.name = "outer" - outer.executors = {"a": sub_a, "b": sub_b} - - with ( - patch.object(AgentFunctionApp, "_setup_executor_activity"), - patch.object(AgentFunctionApp, "_setup_workflow_orchestration"), - pytest.raises(ValueError, match="different workflow"), - ): - AgentFunctionApp(workflow=outer) - - def test_cross_registration_nested_collision_is_atomic(self) -> None: - """A later top-level workflow whose nested child collides aborts before committing it. - - Hosting ``[first, second]`` where ``second``'s nested sub-workflow reuses - ``first``'s child name must raise *before* ``second`` registers any primitives, - so the app is never left with ``second`` half-configured. - """ - shared_a, _ = self._inner_agent_wf("shared", "agent_node") - shared_b, _ = self._inner_agent_wf("shared", "other_node") # different instance, same name - first = self._outer_wf("first", shared_a) - second = self._outer_wf("second", shared_b) - - with ( - patch.object(AgentFunctionApp, "_setup_executor_activity"), - patch.object(AgentFunctionApp, "_setup_workflow_orchestration") as setup_orch, - pytest.raises(ValueError, match="collides"), - ): - AgentFunctionApp(workflows=[first, second]) - - # Only 'first' and its child 'shared' committed primitives; the collision aborted - # before 'second' (or its colliding child) registered anything. - registered = {call.args[0].name for call in setup_orch.call_args_list} - assert registered == {"first", "shared"} - assert "second" not in registered - - def test_executor_id_with_reserved_separator_is_rejected(self) -> None: - """An executor id containing the nested-HITL separator is rejected at registration.""" - from agent_framework import Executor - - ex = Mock(spec=Executor) - ex.id = "bad~id" - wf = Mock() - wf.name = "orders" - wf.executors = {"bad~id": ex} - - with ( - patch.object(AgentFunctionApp, "_setup_executor_activity"), - patch.object(AgentFunctionApp, "_setup_workflow_orchestration"), - pytest.raises(ValueError, match="reserved sub-workflow request separator"), - ): - AgentFunctionApp(workflow=wf) - - -# NOTE: State snapshot/diff tests were moved to durabletask once the activity -# execution body was extracted into the host-agnostic execute_workflow_activity. -# See packages/durabletask/tests/test_workflow_activity.py. - - -class TestWorkflowStatusOutputEncoding: - """The workflow status endpoint emits clean domain JSON for reconstructed outputs. - - Reconstructed outputs (see ``deserialize_workflow_output``) may be framework - models or dataclasses; ``_json_default`` converts them via their own - serialization so the HTTP body is clean domain JSON rather than opaque - checkpoint-marker dicts or repr strings. - """ - - def test_encodes_framework_model_via_to_dict(self) -> None: - from agent_framework_azurefunctions._app import _json_default - - response = AgentResponse(messages=[Message(role="assistant", contents=["hello"])]) - - encoded = _json_default(response) - - assert encoded == response.to_dict() - # The result must be JSON-serializable (no marker dicts, no objects). - assert "hello" in json.dumps(encoded) - - def test_encodes_dataclass_via_asdict(self) -> None: - from dataclasses import dataclass - - from agent_framework_azurefunctions._app import _json_default - - @dataclass - class Decision: - approved: bool - note: str - - encoded = _json_default(Decision(approved=True, note="ok")) - - assert encoded == {"approved": True, "note": "ok"} - - def test_falls_back_to_str_for_plain_objects(self) -> None: - from agent_framework_azurefunctions._app import _json_default - - class Opaque: - def __str__(self) -> str: - return "opaque-value" - - assert _json_default(Opaque()) == "opaque-value" - - -class TestWorkflowOrchestrationScoping: - """Scoping of the workflow status/respond endpoints to the workflow orchestrator. - - Both endpoints address durable instances by ID only, but the durable client resolves - IDs across every orchestration in the task hub (agent entities, user-registered - orchestrations, other apps on the same hub). ``_is_owned_orchestration`` gates the - endpoints so a leaked instance ID for a different orchestration is treated as - "not found" instead of leaking its status/HITL details or accepting injected events. - """ - - def _app_for(self, workflow_name: str) -> AgentFunctionApp: - mock_workflow = Mock() - mock_workflow.name = workflow_name - mock_workflow.executors = {} - with ( - patch.object(AgentFunctionApp, "_setup_executor_activity"), - patch.object(AgentFunctionApp, "_setup_workflow_orchestration"), - ): - return AgentFunctionApp(workflow=mock_workflow) - - @pytest.mark.parametrize( - "name", - [ - workflow_orchestrator_name("orders"), # exact dafx-orders - workflow_orchestrator_name("orders").upper(), # case-insensitive: must match - "DAFX-orders", # mixed case prefix - ], - ) - def test_accepts_matching_workflow_orchestration(self, name: str) -> None: - app = self._app_for("orders") - status = Mock() - status.name = name - assert app._is_owned_orchestration(status, "orders") is True - - def test_rejects_none_status(self) -> None: - # client.get_status returns None when no instance resolves for the ID. - app = self._app_for("orders") - assert app._is_owned_orchestration(None, "orders") is False - - def test_rejects_status_without_name(self) -> None: - app = self._app_for("orders") - status = Mock() - status.name = None - assert app._is_owned_orchestration(status, "orders") is False - - @pytest.mark.parametrize( - "other_name", - [ - "SomeUserOrchestration", - "dafx-WeatherAgent", # an agent entity, not this workflow's orchestration - "dafx-billing", # a *different* workflow's orchestration - "workflow_orchestrator", # the deprecated fixed name - ], - ) - def test_rejects_other_orchestration_name(self, other_name: str) -> None: - app = self._app_for("orders") - status = Mock() - status.name = other_name - assert app._is_owned_orchestration(status, "orders") is False - - -class TestAgentFunctionAppSubworkflowHitl: - """Sub-workflow HITL plumbing: gather nested pending requests and route responses. - - These exercise the host-side helpers the ``workflow/{name}/status`` and - ``.../respond`` routes use to support nested sub-workflows behind a single - top-level addressing surface (B2 qualified request ids). - """ - - @staticmethod - def _app() -> AgentFunctionApp: - mock_workflow = Mock() - mock_workflow.name = "orders" - mock_workflow.executors = {} - with ( - patch.object(AgentFunctionApp, "_setup_executor_activity"), - patch.object(AgentFunctionApp, "_setup_workflow_orchestration"), - ): - return AgentFunctionApp(workflow=mock_workflow) - - @staticmethod - def _client(by_instance: dict[str, dict | None]) -> AsyncMock: - """An AsyncMock durable client whose get_status returns a per-instance custom status.""" - - async def _get_status(instance_id: str) -> Mock | None: - if instance_id not in by_instance: - return None - status = Mock() - status.custom_status = by_instance[instance_id] - return status - - client = AsyncMock() - client.get_status.side_effect = _get_status - return client - - async def test_gather_returns_top_level_requests_unqualified(self) -> None: - app = self._app() - client = self._client({}) - custom_status = {"pending_requests": {"top-1": {"source_executor_id": "outer"}}} - - gathered = await app._gather_pending_hitl_requests(client, custom_status) - - assert gathered == [("top-1", {"source_executor_id": "outer"})] - - async def test_gather_qualifies_nested_requests(self) -> None: - app = self._app() - client = self._client({"child-1": {"pending_requests": {"inner-1": {"source_executor_id": "inner"}}}}) - parent_status = { - "pending_requests": {"top-1": {"source_executor_id": "outer"}}, - "subworkflows": {"sub": ["child-1"]}, - } - - gathered = await app._gather_pending_hitl_requests(client, parent_status) - - ids = {qid for qid, _ in gathered} - assert ids == {"top-1", "sub~0~inner-1"} - - async def test_gather_accumulates_deep_path(self) -> None: - app = self._app() - client = self._client({ - "child-1": {"subworkflows": {"leaf": ["child-2"]}}, - "child-2": {"pending_requests": {"deep": {"source_executor_id": "leaf_node"}}}, - }) - parent_status = {"subworkflows": {"mid": ["child-1"]}} - - gathered = await app._gather_pending_hitl_requests(client, parent_status) - - assert [qid for qid, _ in gathered] == ["mid~0~leaf~0~deep"] - - async def test_resolve_unqualified_targets_same_instance(self) -> None: - app = self._app() - client = self._client({}) - - resolved = await app._resolve_hitl_target(client, "parent", "req-1") - - assert resolved == ("parent", "req-1") - - async def test_resolve_qualified_targets_child_instance(self) -> None: - app = self._app() - client = self._client({"parent": {"subworkflows": {"sub": ["child-1"]}}}) - - resolved = await app._resolve_hitl_target(client, "parent", "sub~0~req-9") - - assert resolved == ("child-1", "req-9") - - async def test_resolve_deeply_qualified_targets_leaf(self) -> None: - app = self._app() - client = self._client({ - "parent": {"subworkflows": {"mid": ["child-1"]}}, - "child-1": {"subworkflows": {"leaf": ["child-2"]}}, - }) - - resolved = await app._resolve_hitl_target(client, "parent", "mid~0~leaf~0~deep") - - assert resolved == ("child-2", "deep") - - async def test_resolve_unknown_subworkflow_returns_none(self) -> None: - app = self._app() - client = self._client({"parent": {"state": "running"}}) # no subworkflows map - - resolved = await app._resolve_hitl_target(client, "parent", "sub~0~req-9") - - assert resolved is None - - async def test_multiple_children_of_one_executor_stay_addressable(self) -> None: - app = self._app() - client = self._client({ - "parent": {"subworkflows": {"sub": ["child-1", "child-2"]}}, - "child-1": {"pending_requests": {"r1": {"source_executor_id": "a"}}}, - "child-2": {"pending_requests": {"r2": {"source_executor_id": "b"}}}, - }) - parent_status = {"subworkflows": {"sub": ["child-1", "child-2"]}} - - gathered = await app._gather_pending_hitl_requests(client, parent_status) - assert {qid for qid, _ in gathered} == {"sub~0~r1", "sub~1~r2"} - - # The second child (ordinal 1) resolves distinctly, not shadowed by the first. - resolved = await app._resolve_hitl_target(client, "parent", "sub~1~r2") - assert resolved == ("child-2", "r2") - - async def test_nested_double_colon_leaf_round_trips(self) -> None: - app = self._app() - client = self._client({ - "parent": {"subworkflows": {"sub": ["child-1"]}}, - "child-1": {"pending_requests": {"auto::0": {"request_id": "auto::0", "source_executor_id": "fn"}}}, - }) - parent_status = {"subworkflows": {"sub": ["child-1"]}} - - gathered = await app._gather_pending_hitl_requests(client, parent_status) - assert [qid for qid, _ in gathered] == ["sub~0~auto::0"] - - resolved = await app._resolve_hitl_target(client, "parent", "sub~0~auto::0") - assert resolved == ("child-1", "auto::0") - - async def test_top_level_double_colon_leaf_is_not_nested(self) -> None: - app = self._app() - client = self._client({}) - - resolved = await app._resolve_hitl_target(client, "parent", "auto::0") - - assert resolved == ("parent", "auto::0") - - -if __name__ == "__main__": - pytest.main([__file__, "-v", "--tb=short"]) diff --git a/python/packages/azurefunctions/tests/test_azurefunctions_workflow_initial_input.py b/python/packages/azurefunctions/tests/test_azurefunctions_workflow_initial_input.py deleted file mode 100644 index 7ec3a9f492b..00000000000 --- a/python/packages/azurefunctions/tests/test_azurefunctions_workflow_initial_input.py +++ /dev/null @@ -1,72 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Behavioral tests for Azure Functions workflow HTTP initial input.""" - -from __future__ import annotations - -from collections.abc import Callable -from typing import Any, TypeVar -from unittest.mock import AsyncMock, Mock, patch - -from agent_framework import Executor, Workflow, WorkflowBuilder, WorkflowContext, handler - -from agent_framework_azurefunctions import AgentFunctionApp - -FuncT = TypeVar("FuncT", bound=Callable[..., Any]) - - -def _identity_decorator(*args: Any, **kwargs: Any) -> Callable[[FuncT], FuncT]: - def decorator(func: FuncT) -> FuncT: - return func - - return decorator - - -class _Start(Executor): - def __init__(self) -> None: - super().__init__(id="start") - - @handler(input=str | dict, workflow_output=str) - async def run(self, message: str | dict, ctx: WorkflowContext) -> None: - pass - - -def _capture_run_handler(workflow: Workflow) -> Callable[..., Any]: - captured_routes: dict[str, Callable[..., Any]] = {} - - def capture_route(*args: Any, **kwargs: Any) -> Callable[[FuncT], FuncT]: - def decorator(func: FuncT) -> FuncT: - captured_routes[kwargs["route"]] = func - return func - - return decorator - - with ( - patch.object(AgentFunctionApp, "function_name", new=_identity_decorator), - patch.object(AgentFunctionApp, "route", new=capture_route), - patch.object(AgentFunctionApp, "durable_client_input", new=_identity_decorator), - patch.object(AgentFunctionApp, "activity_trigger", new=_identity_decorator), - patch.object(AgentFunctionApp, "orchestration_trigger", new=_identity_decorator), - ): - AgentFunctionApp(workflow=workflow, enable_health_check=False) - - return captured_routes[f"workflow/{workflow.name}/run"] - - -async def test_workflow_run_route_neutralizes_reserved_marker_shaped_input() -> None: - """The workflow run route schedules neutralized framework-reserved metadata.""" - executor = _Start() - workflow = WorkflowBuilder(name="input_boundary", start_executor=executor, output_from=[executor]).build() - handler = _capture_run_handler(workflow) - request = Mock() - request.get_json.return_value = { - "__pickled__": "not-checkpoint-data", - "__type__": "builtins:int", - } - request.url = "https://example.test/api/workflow/input_boundary/run" - client = AsyncMock() - client.start_new.return_value = "instance-1" - - await handler(request, client) - - assert client.start_new.await_args.kwargs["client_input"] is None diff --git a/python/packages/azurefunctions/tests/test_entities.py b/python/packages/azurefunctions/tests/test_entities.py deleted file mode 100644 index cc2fc75320e..00000000000 --- a/python/packages/azurefunctions/tests/test_entities.py +++ /dev/null @@ -1,293 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Unit tests for create_agent_entity factory function. - -Run with: pytest tests/test_entities.py -v -""" - -from collections.abc import Callable -from typing import Any, TypeVar -from unittest.mock import AsyncMock, Mock - -import pytest -from agent_framework import AgentResponse, Message - -from agent_framework_azurefunctions._entities import create_agent_entity - -FuncT = TypeVar("FuncT", bound=Callable[..., Any]) - - -def _agent_response(text: str | None) -> AgentResponse: - """Create an AgentResponse with a single assistant message.""" - message = ( - Message(role="assistant", contents=[text]) if text is not None else Message(role="assistant", contents=[""]) - ) - return AgentResponse(messages=[message]) - - -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" - mock_context.entity_key = "conv-123" - mock_context.get_input.return_value = { - "message": "Test message", - "correlationId": "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 = { - "schemaVersion": "1.0.0", - "data": { - "conversationHistory": [ - { - "$type": "request", - "correlationId": "test-correlation-id", - "createdAt": "2024-01-01T00:00:00Z", - "messages": [ - { - "role": "user", - "contents": [{"$type": "text", "text": "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["data"]["conversationHistory"] == [] - - 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["data"] == {"conversationHistory": []} - - def test_entity_function_restores_existing_state(self) -> None: - """Test that the entity function can operate when existing state is present.""" - mock_agent = Mock() - - entity_function = create_agent_entity(mock_agent) - - existing_state = { - "schemaVersion": "1.0.0", - "data": { - "conversationHistory": [ - { - "$type": "request", - "correlationId": "corr-existing-1", - "createdAt": "2024-01-01T00:00:00Z", - "messages": [ - { - "role": "user", - "contents": [ - { - "$type": "text", - "text": "msg1", - } - ], - } - ], - }, - { - "$type": "response", - "correlationId": "corr-existing-1", - "createdAt": "2024-01-01T00:05:00Z", - "messages": [ - { - "role": "assistant", - "contents": [ - { - "$type": "text", - "text": "resp1", - } - ], - } - ], - }, - ], - }, - } - - mock_context = Mock() - mock_context.operation_name = "reset" - mock_context.get_state.return_value = existing_state - - entity_function(mock_context) - - assert mock_context.set_result.called - - # Reset should clear history and persist via set_state - assert mock_context.set_state.called - persisted_state = mock_context.set_state.call_args[0][0] - assert persisted_state["data"]["conversationHistory"] == [] - - def test_entity_function_handles_string_input(self) -> None: - """Test that the entity function handles non-dict input by converting to string.""" - mock_agent = Mock() - mock_agent.run = AsyncMock(return_value=_agent_response("String response")) - - entity_function = create_agent_entity(mock_agent) - - # Mock context with non-dict input (like a number) - mock_context = Mock() - mock_context.operation_name = "run" - mock_context.entity_key = "conv-456" - # Use a number to test the str() conversion path - mock_context.get_input.return_value = 12345 - mock_context.get_state.return_value = None - - # Execute - entity will convert non-dict input to string - entity_function(mock_context) - - # Verify the result was set - assert mock_context.set_result.called - - def test_entity_function_handles_none_input(self) -> None: - """Test that the entity function handles None input by converting to empty string.""" - mock_agent = Mock() - mock_agent.run = AsyncMock(return_value=_agent_response("Empty response")) - - entity_function = create_agent_entity(mock_agent) - - # Mock context with None input - mock_context = Mock() - mock_context.operation_name = "run" - mock_context.entity_key = "conv-789" - mock_context.get_input.return_value = None - mock_context.get_state.return_value = None - - # Execute - should hit error path since entity expects dict or valid JSON string - entity_function(mock_context) - - # Verify the result was set (likely error result) - assert mock_context.set_result.called - - def test_entity_function_runs_on_persistent_loop(self) -> None: - """Entity coroutines run on the shared persistent loop and set a result.""" - 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" - mock_context.entity_key = "conv-loop-test" - mock_context.get_input.return_value = {"message": "Test", "correlationId": "corr-loop-test"} - mock_context.get_state.return_value = None - - # Execute - the persistent loop should run the coroutine and set a result. - entity_function(mock_context) - - assert mock_context.set_result.called - mock_agent.run.assert_awaited() - - def test_entity_function_runs_across_threads_without_hang(self) -> None: - """Successive entity invocations from different threads must not hang. - - This reproduces the cross-loop scenario that previously deadlocked: a - shared async resource bound to one loop being awaited from a different - worker thread. The persistent loop keeps every invocation on one loop. - """ - from concurrent.futures import ThreadPoolExecutor - - mock_agent = Mock() - mock_agent.run = AsyncMock(return_value=_agent_response("Response")) - - entity_function = create_agent_entity(mock_agent) - - def invoke(i: int) -> bool: - ctx = Mock() - ctx.operation_name = "run" - ctx.entity_key = f"conv-{i}" - ctx.get_input.return_value = {"message": f"Test {i}", "correlationId": f"corr-{i}"} - ctx.get_state.return_value = None - entity_function(ctx) - return ctx.set_result.called - - with ThreadPoolExecutor(max_workers=8) as ex: - results = list(ex.map(invoke, range(16))) - - assert all(results) - - -if __name__ == "__main__": - pytest.main([__file__, "-v", "--tb=short"]) diff --git a/python/packages/azurefunctions/tests/test_errors.py b/python/packages/azurefunctions/tests/test_errors.py deleted file mode 100644 index 09bf8797c63..00000000000 --- a/python/packages/azurefunctions/tests/test_errors.py +++ /dev/null @@ -1,38 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Unit tests for custom exception types.""" - -import pytest - -from agent_framework_azurefunctions._errors import IncomingRequestError - - -class TestIncomingRequestError: - """Test suite for IncomingRequestError exception.""" - - def test_incoming_request_error_default_status_code(self) -> None: - """Test that IncomingRequestError has a default status code of 400.""" - error = IncomingRequestError("Invalid request") - - assert str(error) == "Invalid request" - assert error.status_code == 400 - - def test_incoming_request_error_custom_status_code(self) -> None: - """Test that IncomingRequestError can have a custom status code.""" - error = IncomingRequestError("Unauthorized", status_code=401) - - assert str(error) == "Unauthorized" - assert error.status_code == 401 - - def test_incoming_request_error_is_value_error(self) -> None: - """Test that IncomingRequestError inherits from ValueError.""" - error = IncomingRequestError("Test error") - - assert isinstance(error, ValueError) - - def test_incoming_request_error_can_be_raised_and_caught(self) -> None: - """Test that IncomingRequestError can be raised and caught.""" - with pytest.raises(IncomingRequestError) as exc_info: - raise IncomingRequestError("Bad request", status_code=400) - - assert exc_info.value.status_code == 400 diff --git a/python/packages/azurefunctions/tests/test_func_utils.py b/python/packages/azurefunctions/tests/test_func_utils.py deleted file mode 100644 index 902a216379d..00000000000 --- a/python/packages/azurefunctions/tests/test_func_utils.py +++ /dev/null @@ -1,195 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Unit tests for workflow utility functions.""" - -from unittest.mock import Mock - -import pytest -from agent_framework import WorkflowEvent, WorkflowMessage - -from agent_framework_azurefunctions._context import CapturingRunnerContext - - -class TestCapturingRunnerContext: - """Test suite for CapturingRunnerContext.""" - - @pytest.fixture - def context(self) -> CapturingRunnerContext: - """Create a fresh CapturingRunnerContext for each test.""" - return CapturingRunnerContext() - - @pytest.mark.asyncio - async def test_send_message_captures_message(self, context: CapturingRunnerContext) -> None: - """Test that send_message captures messages correctly.""" - message = WorkflowMessage(data="test data", target_id="target_1", source_id="source_1") - - await context.send_message(message) - - messages = await context.drain_messages() - assert "source_1" in messages - assert len(messages["source_1"]) == 1 - assert messages["source_1"][0].data == "test data" - - @pytest.mark.asyncio - async def test_send_multiple_messages_groups_by_source(self, context: CapturingRunnerContext) -> None: - """Test that messages are grouped by source_id.""" - msg1 = WorkflowMessage(data="msg1", target_id="target", source_id="source_a") - msg2 = WorkflowMessage(data="msg2", target_id="target", source_id="source_a") - msg3 = WorkflowMessage(data="msg3", target_id="target", source_id="source_b") - - await context.send_message(msg1) - await context.send_message(msg2) - await context.send_message(msg3) - - messages = await context.drain_messages() - assert len(messages["source_a"]) == 2 - assert len(messages["source_b"]) == 1 - - @pytest.mark.asyncio - async def test_drain_messages_clears_messages(self, context: CapturingRunnerContext) -> None: - """Test that drain_messages clears the message store.""" - message = WorkflowMessage(data="test", target_id="t", source_id="s") - await context.send_message(message) - - await context.drain_messages() # First drain - messages = await context.drain_messages() # Second drain - - assert messages == {} - - @pytest.mark.asyncio - async def test_has_messages_returns_correct_status(self, context: CapturingRunnerContext) -> None: - """Test has_messages returns correct boolean.""" - assert await context.has_messages() is False - - await context.send_message(WorkflowMessage(data="test", target_id="t", source_id="s")) - - assert await context.has_messages() is True - - @pytest.mark.asyncio - async def test_add_event_queues_event(self, context: CapturingRunnerContext) -> None: - """Test that add_event queues events correctly.""" - event = WorkflowEvent("output", executor_id="exec_1", data="output") - - await context.add_event(event) - - events = await context.drain_events() - assert len(events) == 1 - assert isinstance(events[0], WorkflowEvent) - assert events[0].type == "output" - assert events[0].data == "output" - - @pytest.mark.asyncio - async def test_drain_events_clears_queue(self, context: CapturingRunnerContext) -> None: - """Test that drain_events clears the event queue.""" - await context.add_event(WorkflowEvent("output", executor_id="e", data="test")) - - await context.drain_events() # First drain - events = await context.drain_events() # Second drain - - assert events == [] - - @pytest.mark.asyncio - async def test_has_events_returns_correct_status(self, context: CapturingRunnerContext) -> None: - """Test has_events returns correct boolean.""" - assert await context.has_events() is False - - await context.add_event(WorkflowEvent("output", executor_id="e", data="test")) - - assert await context.has_events() is True - - @pytest.mark.asyncio - async def test_next_event_waits_for_event(self, context: CapturingRunnerContext) -> None: - """Test that next_event returns queued events.""" - event = WorkflowEvent("output", executor_id="e", data="waited") - await context.add_event(event) - - result = await context.next_event() - - assert result.data == "waited" - - def test_has_checkpointing_returns_false(self, context: CapturingRunnerContext) -> None: - """Test that checkpointing is not supported.""" - assert context.has_checkpointing() is False - - def test_is_streaming_returns_false_by_default(self, context: CapturingRunnerContext) -> None: - """Test streaming is disabled by default.""" - assert context.is_streaming() is False - - def test_set_streaming(self, context: CapturingRunnerContext) -> None: - """Test setting streaming mode.""" - context.set_streaming(True) - assert context.is_streaming() is True - - context.set_streaming(False) - assert context.is_streaming() is False - - def test_set_workflow_id(self, context: CapturingRunnerContext) -> None: - """Test setting workflow ID.""" - context.set_workflow_id("workflow-123") - assert context._workflow_id == "workflow-123" - - @pytest.mark.asyncio - async def test_reset_for_new_run_clears_state(self, context: CapturingRunnerContext) -> None: - """Test that reset_for_new_run clears all state.""" - await context.send_message(WorkflowMessage(data="test", target_id="t", source_id="s")) - await context.add_event(WorkflowEvent("output", executor_id="e", data="event")) - context.set_streaming(True) - - context.reset_for_new_run() - - assert await context.has_messages() is False - assert await context.has_events() is False - assert context.is_streaming() is False - - @pytest.mark.asyncio - async def test_create_checkpoint_raises_not_implemented(self, context: CapturingRunnerContext) -> None: - """Test that checkpointing methods raise NotImplementedError.""" - from agent_framework._workflows._state import State - - with pytest.raises(NotImplementedError): - await context.create_checkpoint("test_workflow", "abc123", State(), None, 1) - - @pytest.mark.asyncio - async def test_load_checkpoint_raises_not_implemented(self, context: CapturingRunnerContext) -> None: - """Test that load_checkpoint raises NotImplementedError.""" - with pytest.raises(NotImplementedError): - await context.load_checkpoint("some-id") - - @pytest.mark.asyncio - async def test_apply_checkpoint_raises_not_implemented(self, context: CapturingRunnerContext) -> None: - """Test that apply_checkpoint raises NotImplementedError.""" - with pytest.raises(NotImplementedError): - await context.apply_checkpoint(Mock()) - - def test_checkpoint_storage_noops_are_safe(self, context: CapturingRunnerContext) -> None: - """Unsupported checkpoint-storage hooks remain harmless no-ops.""" - storage = Mock() - - context.set_runtime_checkpoint_storage(storage) - context.clear_runtime_checkpoint_storage() - - assert context.has_checkpointing() is False - - def test_yield_output_classifier_can_be_overridden(self, context: CapturingRunnerContext) -> None: - """Custom yield-output classification is delegated to the configured classifier.""" - context.set_yield_output_classifier(lambda executor_id: None if executor_id == "secret" else "output") - - assert context.classify_yielded_output("secret") is None - assert context.classify_yielded_output("visible") == "output" - - async def test_add_request_info_event_tracks_pending_requests(self, context: CapturingRunnerContext) -> None: - """Request-info events are both queued and retained for later correlation.""" - event = WorkflowEvent("request_info", executor_id="reviewer", data={"question": "approve?"}, request_id="req-1") - - await context.add_request_info_event(event) - - pending = await context.get_pending_request_info_events() - queued = await context.drain_events() - - assert pending == {"req-1": event} - assert queued == [event] - - async def test_send_request_info_response_raises_not_implemented(self, context: CapturingRunnerContext) -> None: - """Activity contexts cannot resolve HITL responses directly.""" - with pytest.raises(NotImplementedError, match="orchestrator level"): - await context.send_request_info_response("req-1", {"approved": True}) diff --git a/python/packages/azurefunctions/tests/test_hitl_context.py b/python/packages/azurefunctions/tests/test_hitl_context.py deleted file mode 100644 index 0ca4466e5e2..00000000000 --- a/python/packages/azurefunctions/tests/test_hitl_context.py +++ /dev/null @@ -1,231 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Unit tests for WorkflowHitlContext (HITL respond-URL helper).""" - -# pyright: reportPrivateUsage=false - -from types import SimpleNamespace -from typing import Any - -import pytest - -from agent_framework_azurefunctions import WorkflowHitlContext -from agent_framework_azurefunctions._hitl_context import WEBSITE_HOSTNAME_ENV, _is_loopback - - -def _ctx(metadata: Any) -> SimpleNamespace: - """Build a stand-in WorkflowContext exposing ``_runner_context.host_metadata``.""" - return SimpleNamespace(_runner_context=SimpleNamespace(host_metadata=metadata)) - - -class TestFromContext: - """Construction from a workflow executor's context.""" - - def test_returns_context_when_metadata_present(self) -> None: - hitl = WorkflowHitlContext.from_context(_ctx({"instance_id": "inst-1", "workflow_name": "content_moderation"})) - assert hitl is not None - assert hitl.instance_id == "inst-1" - assert hitl.workflow_name == "content_moderation" - - def test_returns_none_when_no_runner_context(self) -> None: - # A bare object without _runner_context (e.g. an unexpected ctx) yields None. - assert WorkflowHitlContext.from_context(SimpleNamespace()) is None - - def test_returns_none_when_metadata_absent(self) -> None: - # In-process RunnerContext has no host_metadata -> getattr default None. - assert WorkflowHitlContext.from_context(_ctx(None)) is None - - def test_returns_none_when_metadata_not_a_dict(self) -> None: - assert WorkflowHitlContext.from_context(_ctx("not-a-dict")) is None - - def test_returns_none_when_instance_id_missing(self) -> None: - assert WorkflowHitlContext.from_context(_ctx({"workflow_name": "wf"})) is None - - def test_returns_none_when_workflow_name_missing(self) -> None: - assert WorkflowHitlContext.from_context(_ctx({"instance_id": "inst-1"})) is None - - -class TestBaseUrl: - """base_url resolution from override and WEBSITE_HOSTNAME.""" - - def test_explicit_override_wins(self, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv(WEBSITE_HOSTNAME_ENV, "ignored.azurewebsites.net") - hitl = WorkflowHitlContext.from_context( - _ctx({"instance_id": "i", "workflow_name": "wf"}), - base_url="https://contoso.example.com/", - ) - assert hitl is not None - # Trailing slash trimmed; override used verbatim over the env host. - assert hitl.base_url == "https://contoso.example.com" - - def test_website_hostname_gets_https(self, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv(WEBSITE_HOSTNAME_ENV, "myapp.azurewebsites.net") - hitl = WorkflowHitlContext.from_context(_ctx({"instance_id": "i", "workflow_name": "wf"})) - assert hitl is not None - assert hitl.base_url == "https://myapp.azurewebsites.net" - - def test_localhost_gets_http(self, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv(WEBSITE_HOSTNAME_ENV, "localhost:7071") - hitl = WorkflowHitlContext.from_context(_ctx({"instance_id": "i", "workflow_name": "wf"})) - assert hitl is not None - assert hitl.base_url == "http://localhost:7071" - - def test_override_with_scheme_preserved(self) -> None: - hitl = WorkflowHitlContext.from_context( - _ctx({"instance_id": "i", "workflow_name": "wf"}), - base_url="http://127.0.0.1:7071", - ) - assert hitl is not None - assert hitl.base_url == "http://127.0.0.1:7071" - - def test_raises_when_no_base_url_available(self, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv(WEBSITE_HOSTNAME_ENV, raising=False) - hitl = WorkflowHitlContext.from_context(_ctx({"instance_id": "i", "workflow_name": "wf"})) - assert hitl is not None - with pytest.raises(RuntimeError, match=WEBSITE_HOSTNAME_ENV): - _ = hitl.base_url - - -class TestUrlBuilders: - """respond/status URL shapes match the AgentFunctionApp routes.""" - - def test_build_respond_url(self) -> None: - hitl = WorkflowHitlContext.from_context( - _ctx({"instance_id": "inst-1", "workflow_name": "content_moderation"}), - base_url="https://app.example.com", - ) - assert hitl is not None - assert hitl.build_respond_url("req-9") == ( - "https://app.example.com/api/workflow/content_moderation/respond/inst-1/req-9" - ) - - def test_build_respond_url_accepts_qualified_id(self) -> None: - # A nested sub-workflow request id (executor~ordinal~rid) flows through unchanged. - hitl = WorkflowHitlContext.from_context( - _ctx({"instance_id": "inst-1", "workflow_name": "wf"}), - base_url="https://app.example.com", - ) - assert hitl is not None - assert hitl.build_respond_url("reviewer~0~req-9") == ( - "https://app.example.com/api/workflow/wf/respond/inst-1/reviewer~0~req-9" - ) - - def test_build_status_url(self) -> None: - hitl = WorkflowHitlContext.from_context( - _ctx({"instance_id": "inst-1", "workflow_name": "wf"}), - base_url="https://app.example.com", - ) - assert hitl is not None - assert hitl.build_status_url() == "https://app.example.com/api/workflow/wf/status/inst-1" - - -class TestNestedPrefix: - """request_path_prefix qualifies a bare request id back to the root instance.""" - - def test_prefix_read_from_metadata(self) -> None: - # host_metadata for a nested executor carries the root instance/workflow and the - # accumulated path prefix; instance_id/workflow_name are the *root* values. - hitl = WorkflowHitlContext.from_context( - _ctx({ - "instance_id": "root-inst", - "workflow_name": "moderation_pipeline", - "request_path_prefix": "review_sub~0~", - }), - base_url="https://app.example.com", - ) - assert hitl is not None - assert hitl.request_path_prefix == "review_sub~0~" - # A bare request id is qualified back to the top-level instance automatically. - assert hitl.build_respond_url("req-9") == ( - "https://app.example.com/api/workflow/moderation_pipeline/respond/root-inst/review_sub~0~req-9" - ) - - def test_deep_prefix(self) -> None: - hitl = WorkflowHitlContext.from_context( - _ctx({ - "instance_id": "root-inst", - "workflow_name": "wf", - "request_path_prefix": "outer~2~inner~1~", - }), - base_url="https://app.example.com", - ) - assert hitl is not None - assert hitl.build_respond_url("rid") == ( - "https://app.example.com/api/workflow/wf/respond/root-inst/outer~2~inner~1~rid" - ) - - def test_absent_prefix_defaults_empty(self) -> None: - # Top-level metadata may omit the key; the bare id is used unqualified. - hitl = WorkflowHitlContext.from_context( - _ctx({"instance_id": "inst-1", "workflow_name": "wf"}), - base_url="https://app.example.com", - ) - assert hitl is not None - assert hitl.request_path_prefix == "" - assert hitl.build_respond_url("rid") == ("https://app.example.com/api/workflow/wf/respond/inst-1/rid") - - -def _ctx_with_pending(pending: dict[str, Any] | None, *, has_getter: bool = True) -> SimpleNamespace: - """Build a ctx whose runner context returns the given pending request-info events.""" - if not has_getter: - return SimpleNamespace(_runner_context=SimpleNamespace()) - - async def _get() -> dict[str, Any]: - return pending or {} - - return SimpleNamespace(_runner_context=SimpleNamespace(get_pending_request_info_events=_get)) - - -class TestPendingRequestId: - """Reading back the framework-generated request id after request_info.""" - - async def test_returns_latest_request_id(self) -> None: - # Dicts preserve insertion order; the most recently emitted request wins. - ctx = _ctx_with_pending({"r1": object(), "r2": object()}) - assert await WorkflowHitlContext.pending_request_id(ctx) == "r2" - - async def test_returns_single_request_id(self) -> None: - ctx = _ctx_with_pending({"only-one": object()}) - assert await WorkflowHitlContext.pending_request_id(ctx) == "only-one" - - async def test_returns_none_when_no_pending(self) -> None: - ctx = _ctx_with_pending({}) - assert await WorkflowHitlContext.pending_request_id(ctx) is None - - async def test_returns_none_when_no_runner_context(self) -> None: - assert await WorkflowHitlContext.pending_request_id(SimpleNamespace()) is None - - async def test_returns_none_when_getter_absent(self) -> None: - # A runner context that doesn't track request-info events degrades to None. - ctx = _ctx_with_pending(None, has_getter=False) - assert await WorkflowHitlContext.pending_request_id(ctx) is None - - -class TestLoopback: - """Loopback detection covers the addresses ``func start`` can bind, not just localhost.""" - - @pytest.mark.parametrize( - ("host", "expected"), - [ - ("localhost", True), - ("localhost:7071", True), - ("127.0.0.1", True), - ("127.0.0.1:7071", True), - ("127.5.9.9", True), - ("0.0.0.0", True), - ("0.0.0.0:7071", True), - ("::1", True), - ("[::1]:7071", True), - ("myapp.azurewebsites.net", False), - ("contoso.example.com:443", False), - ], - ) - def test_is_loopback(self, host: str, expected: bool) -> None: - assert _is_loopback(host) is expected - - def test_ipv6_loopback_base_url_gets_http(self, monkeypatch: pytest.MonkeyPatch) -> None: - # WEBSITE_HOSTNAME may report a bracketed IPv6 loopback locally; it must resolve to http. - monkeypatch.setenv(WEBSITE_HOSTNAME_ENV, "[::1]:7071") - hitl = WorkflowHitlContext.from_context(_ctx({"instance_id": "i", "workflow_name": "wf"})) - assert hitl is not None - assert hitl.base_url == "http://[::1]:7071" diff --git a/python/packages/azurefunctions/tests/test_multi_agent.py b/python/packages/azurefunctions/tests/test_multi_agent.py deleted file mode 100644 index c03e00dd3ef..00000000000 --- a/python/packages/azurefunctions/tests/test_multi_agent.py +++ /dev/null @@ -1,155 +0,0 @@ -# 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 duplicate agent names deduplicates with warning.""" - agent1 = Mock() - agent1.name = "TestAgent" - agent2 = Mock() - agent2.name = "TestAgent" - - app = AgentFunctionApp(agents=[agent1, agent2]) - - # Duplicate is skipped, only the first agent is registered - assert len(app.agents) == 1 - assert "TestAgent" in app.agents - - 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_skips(self) -> None: - """Test that adding agent with duplicate name logs warning and skips.""" - agent1 = Mock() - agent1.name = "MyAgent" - agent2 = Mock() - agent2.name = "MyAgent" - - app = AgentFunctionApp(agents=[agent1]) - - # Duplicate is silently skipped with a warning - app.add_agent(agent2) - - # Only the original agent remains - assert len(app.agents) == 1 - - 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 deleted file mode 100644 index ec387f60f14..00000000000 --- a/python/packages/azurefunctions/tests/test_orchestration.py +++ /dev/null @@ -1,399 +0,0 @@ -# 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 AgentResponse, Message -from agent_framework_durabletask import DurableAIAgent -from azure.durable_functions.models.Task import TaskBase, TaskState - -from agent_framework_azurefunctions import AgentFunctionApp -from agent_framework_azurefunctions._orchestration import AgentTask - - -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 _FakeTask(TaskBase): - """Concrete TaskBase for testing AgentTask wiring.""" - - def __init__(self, task_id: int = 1): - super().__init__(task_id, []) - self._set_is_scheduled(False) - self.action_repr: list[Any] = [] # pyrefly: ignore[bad-override-mutable-attribute] - self.state = TaskState.RUNNING - - -def _create_entity_task(task_id: int = 1) -> TaskBase: - """Create a minimal TaskBase instance for AgentTask tests.""" - return _FakeTask(task_id) - - -@pytest.fixture -def mock_context(): - """Create a mock orchestration context with UUID support.""" - context = Mock() - context.instance_id = "test-instance" - context.current_utc_datetime = Mock() - return context - - -@pytest.fixture -def mock_context_with_uuid() -> tuple[Mock, str]: - """Create a mock context with a single UUID.""" - from uuid import UUID - - context = Mock() - context.instance_id = "test-instance" - context.current_utc_datetime = Mock() - test_uuid = UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") - context.new_uuid = Mock(return_value=test_uuid) - return context, test_uuid.hex - - -@pytest.fixture -def mock_context_with_multiple_uuids() -> tuple[Mock, list[str]]: - """Create a mock context with multiple UUIDs via side_effect.""" - from uuid import UUID - - context = Mock() - context.instance_id = "test-instance" - context.current_utc_datetime = Mock() - uuids = [ - UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), - UUID("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), - UUID("cccccccc-cccc-cccc-cccc-cccccccccccc"), - ] - context.new_uuid = Mock(side_effect=uuids) - # Return the hex versions for assertion checking - hex_uuids = [uuid.hex for uuid in uuids] - return context, hex_uuids - - -@pytest.fixture -def executor_with_uuid() -> tuple[Any, Mock, str]: - """Create an executor with a mocked generate_unique_id method.""" - from agent_framework_azurefunctions._orchestration import AzureFunctionsAgentExecutor - - context = Mock() - context.instance_id = "test-instance" - context.current_utc_datetime = Mock() - - executor = AzureFunctionsAgentExecutor(context) - test_uuid_hex = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" - executor.generate_unique_id = Mock(return_value=test_uuid_hex) # type: ignore[method-assign] - - return executor, context, test_uuid_hex - - -@pytest.fixture -def executor_with_multiple_uuids() -> tuple[Any, Mock, list[str]]: - """Create an executor with multiple mocked UUIDs.""" - from agent_framework_azurefunctions._orchestration import AzureFunctionsAgentExecutor - - context = Mock() - context.instance_id = "test-instance" - context.current_utc_datetime = Mock() - - executor = AzureFunctionsAgentExecutor(context) - uuid_hexes = [ - "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", - "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", - "cccccccc-cccc-cccc-cccc-cccccccccccc", - "dddddddd-dddd-dddd-dddd-dddddddddddd", - "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee", - ] - executor.generate_unique_id = Mock(side_effect=uuid_hexes) # type: ignore[method-assign] - - return executor, context, uuid_hexes - - -@pytest.fixture -def executor_with_context(mock_context_with_uuid: tuple[Mock, str]) -> tuple[Any, Mock]: - """Create an executor with a mocked context.""" - from agent_framework_azurefunctions._orchestration import AzureFunctionsAgentExecutor - - context, _ = mock_context_with_uuid - return AzureFunctionsAgentExecutor(context), context - - -class TestAgentResponseHelpers: - """Tests for response handling through public AgentTask API.""" - - def test_try_set_value_exception_handling(self) -> None: - """Test try_set_value handles exceptions raised when converting a successful task result to AgentResponse.""" - entity_task = _create_entity_task() - task = AgentTask(entity_task, None, "correlation-id") - - # Simulate successful entity task with invalid result that causes exception - entity_task.state = TaskState.SUCCEEDED - entity_task.result = {"invalid": "format"} # Missing required fields for AgentResponse - - # Clear pending_tasks to simulate that parent has processed the child - task.pending_tasks.clear() - - # Call try_set_value - should catch exception and set error - task.try_set_value(entity_task) - - # Verify task failed due to conversion exception - assert task.state == TaskState.FAILED - assert isinstance(task.result, Exception) - - def test_try_set_value_success(self) -> None: - """Test try_set_value correctly processes successful task completion.""" - entity_task = _create_entity_task() - task = AgentTask(entity_task, None, "correlation-id") - - # Simulate successful entity task completion - entity_task.state = TaskState.SUCCEEDED - entity_task.result = AgentResponse(messages=[Message(role="assistant", contents=["Test response"])]).to_dict() - - # Clear pending_tasks to simulate that parent has processed the child - task.pending_tasks.clear() - - # Call try_set_value - task.try_set_value(entity_task) - - # Verify task completed successfully with AgentResponse - assert task.state == TaskState.SUCCEEDED - assert isinstance(task.result, AgentResponse) - assert task.result.text == "Test response" - - def test_try_set_value_failure(self) -> None: - """Test try_set_value correctly handles failed task completion.""" - entity_task = _create_entity_task() - task = AgentTask(entity_task, None, "correlation-id") - - # Simulate failed entity task - entity_task.state = TaskState.FAILED - entity_task.result = Exception("Entity call failed") - - # Call try_set_value - task.try_set_value(entity_task) - - # Verify task failed with the error - assert task.state == TaskState.FAILED - assert isinstance(task.result, Exception) - assert str(task.result) == "Entity call failed" - - def test_try_set_value_with_response_format(self) -> None: - """Test try_set_value parses structured output when response_format is provided.""" - from pydantic import BaseModel - - class TestSchema(BaseModel): - answer: str - - entity_task = _create_entity_task() - task = AgentTask(entity_task, TestSchema, "correlation-id") - - # Simulate successful entity task with JSON response - entity_task.state = TaskState.SUCCEEDED - entity_task.result = AgentResponse( - messages=[Message(role="assistant", contents=['{"answer": "42"}'])] - ).to_dict() - - # Clear pending_tasks to simulate that parent has processed the child - task.pending_tasks.clear() - - # Call try_set_value - task.try_set_value(entity_task) - - # Verify task completed and value was parsed - assert task.state == TaskState.SUCCEEDED - assert isinstance(task.result, AgentResponse) - assert isinstance(task.result.value, TestSchema) - assert task.result.value.answer == "42" - - -class TestAgentFunctionAppGetAgent: - """Test suite for AgentFunctionApp.get_agent.""" - - 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 TestAzureFunctionsFireAndForget: - """Test fire-and-forget mode for AzureFunctionsAgentExecutor.""" - - def test_fire_and_forget_calls_signal_entity(self, executor_with_uuid: tuple[Any, Mock, str]) -> None: - """Verify wait_for_response=False calls signal_entity instead of call_entity.""" - executor, context, _ = executor_with_uuid - context.signal_entity = Mock() - context.call_entity = Mock(return_value=_create_entity_task()) - - agent = DurableAIAgent(executor, "TestAgent") - session = agent.create_session() - - # Run with wait_for_response=False - result = agent.run("Test message", session=session, options={"wait_for_response": False}) - - # Verify signal_entity was called and call_entity was not - assert context.signal_entity.call_count == 1 - assert context.call_entity.call_count == 0 - - # Should still return an AgentTask - assert isinstance(result, AgentTask) - - def test_fire_and_forget_returns_completed_task(self, executor_with_uuid: tuple[Any, Mock, str]) -> None: - """Verify wait_for_response=False returns pre-completed AgentTask.""" - executor, context, _ = executor_with_uuid - context.signal_entity = Mock() - - agent = DurableAIAgent(executor, "TestAgent") - session = agent.create_session() - - result = agent.run("Test message", session=session, options={"wait_for_response": False}) - - # Task should be immediately complete - assert isinstance(result, AgentTask) - assert result.is_completed - - def test_fire_and_forget_returns_acceptance_response(self, executor_with_uuid: tuple[Any, Mock, str]) -> None: - """Verify wait_for_response=False returns acceptance response.""" - executor, context, _ = executor_with_uuid - context.signal_entity = Mock() - - agent = DurableAIAgent(executor, "TestAgent") - session = agent.create_session() - - result = agent.run("Test message", session=session, options={"wait_for_response": False}) - - # Get the result - response = result.result - assert isinstance(response, AgentResponse) - assert len(response.messages) == 1 - assert response.messages[0].role == "system" - # Check message contains key information - message_text = response.messages[0].text - assert "accepted" in message_text.lower() - assert "background" in message_text.lower() - - def test_blocking_mode_still_works(self, executor_with_uuid: tuple[Any, Mock, str]) -> None: - """Verify wait_for_response=True uses call_entity as before.""" - executor, context, _ = executor_with_uuid - context.signal_entity = Mock() - context.call_entity = Mock(return_value=_create_entity_task()) - - agent = DurableAIAgent(executor, "TestAgent") - session = agent.create_session() - - result = agent.run("Test message", session=session, options={"wait_for_response": True}) - - # Verify call_entity was called and signal_entity was not - assert context.call_entity.call_count == 1 - assert context.signal_entity.call_count == 0 - - # Should return an AgentTask - assert isinstance(result, AgentTask) - - -class TestAzureFunctionsAgentExecutor: - """Tests for AzureFunctionsAgentExecutor.""" - - def test_generate_unique_id(self, mock_context_with_uuid: tuple[Mock, str]) -> None: - """Test generate_unique_id method returns UUID from orchestration context.""" - from agent_framework_azurefunctions._orchestration import AzureFunctionsAgentExecutor - - context, _ = mock_context_with_uuid - executor = AzureFunctionsAgentExecutor(context) - - # Call generate_unique_id - unique_id = executor.generate_unique_id() - - # Verify it returns the UUID from context (as string with dashes) - # The UUID is returned in standard format with dashes - context.new_uuid.assert_called_once() - # Just verify it's a string representation of UUID - assert isinstance(unique_id, str) - assert len(unique_id) > 0 - - -class TestOrchestrationIntegration: - """Integration tests for orchestration scenarios.""" - - def test_sequential_agent_calls_simulation(self, executor_with_multiple_uuids: tuple[Any, Mock, list[str]]) -> None: - """Simulate sequential agent calls in an orchestration.""" - executor, context, uuid_hexes = executor_with_multiple_uuids - - # 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]) -> TaskBase: - entity_calls.append({"entity_id": str(entity_id), "operation": operation, "input": input_data}) - return _create_entity_task() - - context.call_entity = Mock(side_effect=mock_call_entity_side_effect) - - # Create agent directly with executor (not via app.get_agent) - agent = DurableAIAgent(executor, "WriterAgent") - - # Create session - session = agent.create_session() - - # First call - returns AgentTask - task1 = agent.run("Write something", session=session) - assert isinstance(task1, AgentTask) - - # Second call - returns AgentTask - task2 = agent.run("Improve: something", session=session) - assert isinstance(task2, AgentTask) - - # 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@ - expected_entity_id = f"@dafx-writeragent@{uuid_hexes[0]}" - assert entity_calls[0]["entity_id"] == expected_entity_id - # generate_unique_id called 3 times: session + 2 correlation IDs - assert executor.generate_unique_id.call_count == 3 - - def test_multiple_agents_in_orchestration(self, executor_with_multiple_uuids: tuple[Any, Mock, list[str]]) -> None: - """Test using multiple different agents in one orchestration.""" - executor, context, uuid_hexes = executor_with_multiple_uuids - - entity_calls: list[str] = [] - - def mock_call_entity_side_effect(entity_id: Any, operation: str, input_data: dict[str, Any]) -> TaskBase: - entity_calls.append(str(entity_id)) - return _create_entity_task() - - context.call_entity = Mock(side_effect=mock_call_entity_side_effect) - - # Create agents directly with executor (not via app.get_agent) - writer = DurableAIAgent(executor, "WriterAgent") - editor = DurableAIAgent(executor, "EditorAgent") - - writer_session = writer.create_session() - editor_session = editor.create_session() - - # Call both agents - returns AgentTasks - writer_task = writer.run("Write", session=writer_session) - editor_task = editor.run("Edit", session=editor_session) - - assert isinstance(writer_task, AgentTask) - assert isinstance(editor_task, AgentTask) - - # Verify different entity IDs were used - assert len(entity_calls) == 2 - # EntityId format is @dafx-agentname@uuid_hex (lowercased agent name with dafx- prefix) - expected_writer_id = f"@dafx-writeragent@{uuid_hexes[0]}" - expected_editor_id = f"@dafx-editoragent@{uuid_hexes[1]}" - assert entity_calls[0] == expected_writer_id - assert entity_calls[1] == expected_editor_id - - -if __name__ == "__main__": - pytest.main([__file__, "-v", "--tb=short"]) diff --git a/python/packages/azurefunctions/tests/test_routes.py b/python/packages/azurefunctions/tests/test_routes.py deleted file mode 100644 index f00e9893266..00000000000 --- a/python/packages/azurefunctions/tests/test_routes.py +++ /dev/null @@ -1,128 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Unit tests for the shared route-prefix resolution and HITL URL builders.""" - -# pyright: reportPrivateUsage=false - -import json -from collections.abc import Iterator -from pathlib import Path -from typing import Any - -import pytest - -from agent_framework_azurefunctions import _routes - -SCRIPT_ROOT_ENV = "AzureWebJobsScriptRoot" - - -@pytest.fixture(autouse=True) -def _reset_prefix_cache() -> Iterator[None]: - """Keep the route-prefix cache from leaking across tests.""" - _routes.route_prefix.cache_clear() - yield - _routes.route_prefix.cache_clear() - - -def _write_host_json(directory: Path, config: dict[str, Any]) -> None: - (directory / "host.json").write_text(json.dumps(config), encoding="utf-8") - - -class TestRoutePrefix: - """Resolving ``extensions.http.routePrefix`` from host.json, with an ``api`` default.""" - - def test_defaults_to_api_without_host_json(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv(SCRIPT_ROOT_ENV, str(tmp_path)) - assert _routes.route_prefix() == "api" - - def test_defaults_to_api_when_prefix_absent(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - _write_host_json(tmp_path, {"version": "2.0", "extensions": {"http": {}}}) - monkeypatch.setenv(SCRIPT_ROOT_ENV, str(tmp_path)) - assert _routes.route_prefix() == "api" - - def test_reads_custom_prefix(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - _write_host_json(tmp_path, {"extensions": {"http": {"routePrefix": "gateway"}}}) - monkeypatch.setenv(SCRIPT_ROOT_ENV, str(tmp_path)) - assert _routes.route_prefix() == "gateway" - - def test_reads_empty_prefix(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - _write_host_json(tmp_path, {"extensions": {"http": {"routePrefix": ""}}}) - monkeypatch.setenv(SCRIPT_ROOT_ENV, str(tmp_path)) - assert _routes.route_prefix() == "" - - def test_strips_surrounding_slashes(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - _write_host_json(tmp_path, {"extensions": {"http": {"routePrefix": "/custom/"}}}) - monkeypatch.setenv(SCRIPT_ROOT_ENV, str(tmp_path)) - assert _routes.route_prefix() == "custom" - - def test_defaults_to_api_on_malformed_json(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - (tmp_path / "host.json").write_text("{ not json", encoding="utf-8") - monkeypatch.setenv(SCRIPT_ROOT_ENV, str(tmp_path)) - assert _routes.route_prefix() == "api" - - def test_caches_first_read(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - _write_host_json(tmp_path, {"extensions": {"http": {"routePrefix": "one"}}}) - monkeypatch.setenv(SCRIPT_ROOT_ENV, str(tmp_path)) - assert _routes.route_prefix() == "one" - # A later host.json change is not observed within a running host. - _write_host_json(tmp_path, {"extensions": {"http": {"routePrefix": "two"}}}) - assert _routes.route_prefix() == "one" - - -class TestUrlBuilders: - """Respond/status URL shapes, parameterized by an explicit prefix.""" - - def test_respond_url_default_prefix(self) -> None: - assert ( - _routes.build_workflow_respond_url("https://h", "wf", "i", "r", prefix="api") - == "https://h/api/workflow/wf/respond/i/r" - ) - - def test_respond_url_custom_prefix(self) -> None: - assert ( - _routes.build_workflow_respond_url("https://h", "wf", "i", "r", prefix="gw") - == "https://h/gw/workflow/wf/respond/i/r" - ) - - def test_respond_url_empty_prefix(self) -> None: - assert ( - _routes.build_workflow_respond_url("https://h", "wf", "i", "r", prefix="") - == "https://h/workflow/wf/respond/i/r" - ) - - def test_respond_url_template_placeholder(self) -> None: - assert ( - _routes.build_workflow_respond_url("https://h", "wf", "i", "{requestId}", prefix="api") - == "https://h/api/workflow/wf/respond/i/{requestId}" - ) - - def test_status_url_custom_prefix(self) -> None: - assert ( - _routes.build_workflow_status_url("https://h", "wf", "i", prefix="gw") - == "https://h/gw/workflow/wf/status/i" - ) - - def test_status_url_empty_prefix(self) -> None: - assert _routes.build_workflow_status_url("https://h", "wf", "i", prefix="") == "https://h/workflow/wf/status/i" - - -class TestSplitRequestUrl: - """Deriving base URL and route prefix from an incoming request URL.""" - - def test_default_prefix(self) -> None: - assert _routes.split_request_url("https://h:7071/api/workflow/wf/run") == ("https://h:7071", "api") - - def test_custom_prefix(self) -> None: - assert _routes.split_request_url("https://h/gw/workflow/wf/status/i") == ("https://h", "gw") - - def test_empty_prefix(self) -> None: - assert _routes.split_request_url("https://h/workflow/wf/run") == ("https://h", "") - - def test_multi_segment_prefix(self) -> None: - assert _routes.split_request_url("https://h/a/b/workflow/wf/run") == ("https://h", "a/b") - - def test_no_workflow_segment(self) -> None: - assert _routes.split_request_url("https://h/api/health") == ("https://h", "") - - def test_non_absolute_falls_back(self) -> None: - assert _routes.split_request_url("/api/workflow/wf/run") == ("/api/workflow/wf/run", "") diff --git a/python/packages/azurefunctions/tests/test_workflow.py b/python/packages/azurefunctions/tests/test_workflow.py deleted file mode 100644 index f68af169023..00000000000 --- a/python/packages/azurefunctions/tests/test_workflow.py +++ /dev/null @@ -1,328 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Unit tests for workflow orchestration functions.""" - -import json -from dataclasses import dataclass -from typing import Any - -from agent_framework import ( - AgentExecutorRequest, - AgentExecutorResponse, - AgentResponse, - Message, -) -from agent_framework._workflows._edge import ( - FanInEdgeGroup, - FanOutEdgeGroup, - SingleEdgeGroup, - SwitchCaseEdgeGroup, - SwitchCaseEdgeGroupCase, - SwitchCaseEdgeGroupDefault, -) - -from agent_framework_azurefunctions._workflow import ( - _extract_message_content, - build_agent_executor_response, - route_message_through_edge_groups, -) - - -class TestRouteMessageThroughEdgeGroups: - """Test suite for route_message_through_edge_groups function.""" - - def test_single_edge_group_routes_when_condition_matches(self) -> None: - """Test SingleEdgeGroup routes when condition is satisfied.""" - group = SingleEdgeGroup(source_id="src", target_id="tgt", condition=lambda m: True) - - targets = route_message_through_edge_groups([group], "src", "any message") - - assert targets == ["tgt"] - - def test_single_edge_group_does_not_route_when_condition_fails(self) -> None: - """Test SingleEdgeGroup does not route when condition fails.""" - group = SingleEdgeGroup(source_id="src", target_id="tgt", condition=lambda m: False) - - targets = route_message_through_edge_groups([group], "src", "any message") - - assert targets == [] - - def test_single_edge_group_ignores_different_source(self) -> None: - """Test SingleEdgeGroup ignores messages from different sources.""" - group = SingleEdgeGroup(source_id="src", target_id="tgt", condition=lambda m: True) - - targets = route_message_through_edge_groups([group], "other_src", "any message") - - assert targets == [] - - def test_switch_case_with_selection_func(self) -> None: - """Test SwitchCaseEdgeGroup uses selection_func.""" - - def select_first_target(msg: Any, targets: list[str]) -> list[str]: - return [targets[0]] - - group = SwitchCaseEdgeGroup( - source_id="src", - cases=[ - SwitchCaseEdgeGroupCase(condition=lambda m: True, target_id="target_a"), - SwitchCaseEdgeGroupDefault(target_id="target_b"), - ], - ) - # Manually set the selection function - group._selection_func = select_first_target - - targets = route_message_through_edge_groups([group], "src", "test") - - assert targets == ["target_a"] - - def test_switch_case_without_selection_func_broadcasts(self) -> None: - """Test SwitchCaseEdgeGroup without selection_func broadcasts to all.""" - group = SwitchCaseEdgeGroup( - source_id="src", - cases=[ - SwitchCaseEdgeGroupCase(condition=lambda m: True, target_id="target_a"), - SwitchCaseEdgeGroupDefault(target_id="target_b"), - ], - ) - group._selection_func = None - - targets = route_message_through_edge_groups([group], "src", "test") - - assert set(targets) == {"target_a", "target_b"} - - def test_fan_out_with_selection_func(self) -> None: - """Test FanOutEdgeGroup uses selection_func.""" - - def select_all(msg: Any, targets: list[str]) -> list[str]: - return targets - - group = FanOutEdgeGroup( - source_id="src", - target_ids=["fan_a", "fan_b", "fan_c"], - selection_func=select_all, - ) - - targets = route_message_through_edge_groups([group], "src", "broadcast") - - assert set(targets) == {"fan_a", "fan_b", "fan_c"} - - def test_fan_in_is_not_routed_directly(self) -> None: - """Test FanInEdgeGroup is handled separately (not routed here).""" - group = FanInEdgeGroup( - source_ids=["src_a", "src_b"], - target_id="aggregator", - ) - - # Fan-in should not add targets through this function - targets = route_message_through_edge_groups([group], "src_a", "message") - - assert targets == [] - - def test_multiple_edge_groups_aggregated(self) -> None: - """Test that targets from multiple edge groups are aggregated.""" - group1 = SingleEdgeGroup(source_id="src", target_id="t1", condition=lambda m: True) - group2 = SingleEdgeGroup(source_id="src", target_id="t2", condition=lambda m: True) - - targets = route_message_through_edge_groups([group1, group2], "src", "msg") - - assert set(targets) == {"t1", "t2"} - - -class TestBuildAgentExecutorResponse: - """Test suite for build_agent_executor_response function.""" - - def test_builds_response_with_text(self) -> None: - """Test building response with plain text.""" - response = build_agent_executor_response( - executor_id="my_executor", - response_text="Hello, world!", - structured_response=None, - previous_message="User input", - ) - - assert response.executor_id == "my_executor" - assert response.agent_response.text == "Hello, world!" - assert len(response.full_conversation) == 2 # User + Assistant - - def test_builds_response_with_structured_response(self) -> None: - """Test building response with structured JSON response.""" - structured = {"answer": 42, "reason": "because"} - - response = build_agent_executor_response( - executor_id="calc", - response_text="Original text", - structured_response=structured, - previous_message="Calculate", - ) - - # Structured response overrides text - assert response.agent_response.text == json.dumps(structured) - - def test_conversation_includes_previous_string_message(self) -> None: - """Test that string previous_message is included in conversation.""" - response = build_agent_executor_response( - executor_id="exec", - response_text="Response", - structured_response=None, - previous_message="User said this", - ) - - assert len(response.full_conversation) == 2 - assert response.full_conversation[0].role == "user" - assert response.full_conversation[0].text == "User said this" - assert response.full_conversation[1].role == "assistant" - - def test_conversation_extends_previous_agent_executor_response(self) -> None: - """Test that previous AgentExecutorResponse's conversation is extended.""" - # Create a previous response with conversation history - previous = AgentExecutorResponse( - executor_id="prev", - agent_response=AgentResponse(messages=[Message(role="assistant", contents=["Previous"])]), - full_conversation=[ - Message(role="user", contents=["First"]), - Message(role="assistant", contents=["Previous"]), - ], - ) - - response = build_agent_executor_response( - executor_id="current", - response_text="Current response", - structured_response=None, - previous_message=previous, - ) - - # Should have 3 messages: First + Previous + Current - assert len(response.full_conversation) == 3 - assert response.full_conversation[0].text == "First" - assert response.full_conversation[1].text == "Previous" - assert response.full_conversation[2].text == "Current response" - - -class TestExtractMessageContent: - """Test suite for _extract_message_content function.""" - - def test_extract_from_string(self) -> None: - """Test extracting content from plain string.""" - result = _extract_message_content("Hello, world!") - - assert result == "Hello, world!" - - def test_extract_from_agent_executor_response_with_text(self) -> None: - """Test extracting from AgentExecutorResponse with text.""" - response = AgentExecutorResponse( - executor_id="exec", - agent_response=AgentResponse(messages=[Message(role="assistant", contents=["Response text"])]), - full_conversation=[Message(role="assistant", contents=["Response text"])], - ) - - result = _extract_message_content(response) - - assert result == "Response text" - - def test_extract_from_agent_executor_response_with_messages(self) -> None: - """Test extracting from AgentExecutorResponse with messages.""" - response = AgentExecutorResponse( - executor_id="exec", - agent_response=AgentResponse( - messages=[ - Message(role="user", contents=["First"]), - Message(role="assistant", contents=["Last message"]), - ] - ), - full_conversation=[ - Message(role="user", contents=["First"]), - Message(role="assistant", contents=["Last message"]), - ], - ) - - result = _extract_message_content(response) - - # AgentResponse.text concatenates all message texts - assert result == "FirstLast message" - - def test_extract_from_agent_executor_request(self) -> None: - """Test extracting from AgentExecutorRequest.""" - request = AgentExecutorRequest( - messages=[ - Message(role="user", contents=["First"]), - Message(role="user", contents=["Last request"]), - ] - ) - - result = _extract_message_content(request) - - assert result == "Last request" - - def test_extract_from_dict_returns_empty(self) -> None: - """Test that dict messages return empty string (unexpected input).""" - msg_dict = {"messages": [{"text": "Hello"}]} - - result = _extract_message_content(msg_dict) - - assert result == "" - - def test_extract_returns_empty_for_unknown_type(self) -> None: - """Test that unknown types return empty string.""" - result = _extract_message_content(12345) - - assert result == "" - - -class TestEdgeGroupIntegration: - """Integration tests for edge group routing with realistic scenarios.""" - - def test_conditional_routing_by_message_type(self) -> None: - """Test routing based on message content/type.""" - - @dataclass - class SpamResult: - is_spam: bool - reason: str - - def is_spam_condition(msg: Any) -> bool: - if isinstance(msg, SpamResult): - return msg.is_spam - return False - - def is_not_spam_condition(msg: Any) -> bool: - if isinstance(msg, SpamResult): - return not msg.is_spam - return False - - spam_group = SingleEdgeGroup( - source_id="detector", - target_id="spam_handler", - condition=is_spam_condition, - ) - legit_group = SingleEdgeGroup( - source_id="detector", - target_id="email_handler", - condition=is_not_spam_condition, - ) - - # Test spam message - spam_msg = SpamResult(is_spam=True, reason="Suspicious content") - targets = route_message_through_edge_groups([spam_group, legit_group], "detector", spam_msg) - assert targets == ["spam_handler"] - - # Test legitimate message - legit_msg = SpamResult(is_spam=False, reason="Clean") - targets = route_message_through_edge_groups([spam_group, legit_group], "detector", legit_msg) - assert targets == ["email_handler"] - - def test_fan_out_to_multiple_workers(self) -> None: - """Test fan-out to multiple parallel workers.""" - - def select_all_workers(msg: Any, targets: list[str]) -> list[str]: - return targets - - group = FanOutEdgeGroup( - source_id="coordinator", - target_ids=["worker_1", "worker_2", "worker_3"], - selection_func=select_all_workers, - ) - - targets = route_message_through_edge_groups([group], "coordinator", {"task": "process"}) - - assert len(targets) == 3 - assert set(targets) == {"worker_1", "worker_2", "worker_3"} diff --git a/python/packages/azurefunctions/tests/test_workflow_af_context.py b/python/packages/azurefunctions/tests/test_workflow_af_context.py deleted file mode 100644 index 4947d4782d9..00000000000 --- a/python/packages/azurefunctions/tests/test_workflow_af_context.py +++ /dev/null @@ -1,142 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Unit tests for the Azure Functions workflow-context adapter.""" - -# pyright: reportPrivateUsage=false - -from __future__ import annotations - -from datetime import datetime, timezone -from typing import Any -from unittest.mock import Mock - -import pytest - -from agent_framework_azurefunctions._workflow import run_workflow_orchestrator -from agent_framework_azurefunctions._workflow_af_context import AzureFunctionsWorkflowContext - - -class _FakeDurableAIAgent: - def __init__(self, executor: Any, name: str) -> None: - self.executor = executor - self.name = name - self.calls: list[tuple[str, Any]] = [] - - def run(self, message: str, *, session: Any) -> dict[str, Any]: - self.calls.append((message, session)) - return {"message": message, "session": session, "executor": self.executor, "name": self.name} - - -class TestAzureFunctionsWorkflowContext: - """Behavior of the Azure Functions orchestration-context adapter.""" - - @pytest.fixture - def orchestration_context(self) -> Mock: - context = Mock() - context.instance_id = "instance-123" - context.is_replaying = True - context.current_utc_datetime = datetime(2025, 1, 2, 3, 4, 5, tzinfo=timezone.utc) - context.call_activity.return_value = "activity-task" - context.call_sub_orchestrator.return_value = "sub-task" - context.task_all.return_value = "all-task" - context.task_any.return_value = "any-task" - context.wait_for_external_event.return_value = "event-task" - context.create_timer.return_value = "timer-task" - context.new_uuid.return_value = "uuid-123" - return context - - def test_exposes_basic_context_properties(self, orchestration_context: Mock) -> None: - workflow_context = AzureFunctionsWorkflowContext(orchestration_context) - - assert workflow_context.instance_id == "instance-123" - assert workflow_context.is_replaying is True - assert workflow_context.supports_event_streaming is False - assert workflow_context.current_utc_datetime == orchestration_context.current_utc_datetime - - def test_prepare_agent_task_wraps_session_and_executor( - self, - monkeypatch: pytest.MonkeyPatch, - orchestration_context: Mock, - ) -> None: - executor_sentinel = object() - monkeypatch.setattr( - "agent_framework_azurefunctions._workflow_af_context.AzureFunctionsAgentExecutor", - lambda context: executor_sentinel if context is orchestration_context else None, - ) - monkeypatch.setattr("agent_framework_azurefunctions._workflow_af_context.DurableAIAgent", _FakeDurableAIAgent) - - workflow_context = AzureFunctionsWorkflowContext(orchestration_context) - result = workflow_context.prepare_agent_task("reviewer", "please approve", "orch-9") - - assert result["message"] == "please approve" - assert result["executor"] is executor_sentinel - assert result["name"] == "reviewer" - assert result["session"].durable_session_id.name == "reviewer" - assert result["session"].durable_session_id.key == "orch-9" - - def test_delegates_activity_and_orchestrator_primitives(self, orchestration_context: Mock) -> None: - workflow_context = AzureFunctionsWorkflowContext(orchestration_context) - - assert workflow_context.prepare_activity_task("activity-name", '{"payload": 1}') == "activity-task" - orchestration_context.call_activity.assert_called_once_with("activity-name", '{"payload": 1}') - - assert workflow_context.call_sub_orchestrator("child", {"x": 1}, instance_id="child-1") == "sub-task" - orchestration_context.call_sub_orchestrator.assert_called_once_with( - "child", input_={"x": 1}, instance_id="child-1" - ) - - assert workflow_context.task_all(["a", "b"]) == "all-task" - orchestration_context.task_all.assert_called_once_with(["a", "b"]) - - assert workflow_context.task_any(["a", "b"]) == "any-task" - orchestration_context.task_any.assert_called_once_with(["a", "b"]) - - assert workflow_context.wait_for_external_event("approval") == "event-task" - orchestration_context.wait_for_external_event.assert_called_once_with("approval") - - assert workflow_context.create_timer(orchestration_context.current_utc_datetime) == "timer-task" - orchestration_context.create_timer.assert_called_once_with(orchestration_context.current_utc_datetime) - - def test_status_uuid_and_task_helpers_delegate(self, orchestration_context: Mock) -> None: - workflow_context = AzureFunctionsWorkflowContext(orchestration_context) - - workflow_context.set_custom_status({"state": "running"}) - orchestration_context.set_custom_status.assert_called_once_with({"state": "running"}) - assert workflow_context.new_uuid() == "uuid-123" - - cancellable = Mock() - workflow_context.cancel_task(cancellable) - cancellable.cancel.assert_called_once_with() - - non_cancellable = object() - workflow_context.cancel_task(non_cancellable) - - done_task = Mock() - done_task.result = {"answer": 42} - assert workflow_context.get_task_result(done_task) == {"answer": 42} - assert workflow_context.get_task_result(object()) is None - - -def test_run_workflow_orchestrator_wraps_context(monkeypatch: pytest.MonkeyPatch) -> None: - """The Azure Functions wrapper delegates to the shared durabletask orchestrator.""" - - def _shared_runner(context: Any, workflow: Any, initial_message: Any, shared_state: dict[str, Any] | None) -> Any: - return context, workflow, initial_message, shared_state - - monkeypatch.setattr("agent_framework_azurefunctions._workflow._run_workflow_orchestrator_shared", _shared_runner) - - df_context = Mock() - workflow = Mock() - - wrapped_context, passed_workflow, passed_message, passed_state = run_workflow_orchestrator( - df_context, - workflow, - "hello", - {"x": 1}, - ) - - assert isinstance(wrapped_context, AzureFunctionsWorkflowContext) - assert wrapped_context.instance_id == df_context.instance_id - assert passed_workflow is workflow - assert passed_message == "hello" - assert passed_state == {"x": 1} diff --git a/python/packages/core/agent_framework/_workflows/_checkpoint_encoding.py b/python/packages/core/agent_framework/_workflows/_checkpoint_encoding.py index 2c601a48d05..0bec7a970d1 100644 --- a/python/packages/core/agent_framework/_workflows/_checkpoint_encoding.py +++ b/python/packages/core/agent_framework/_workflows/_checkpoint_encoding.py @@ -35,8 +35,8 @@ 3. The ``allowed_types`` parameter is specified whenever possible to restrict the set of reconstructible types to the minimum required by the application. 4. Never pass untrusted external input to ``decode_checkpoint_value``. If you - must accept external JSON that might contain checkpoint markers, sanitize it - first (for example, :func:`agent_framework_durabletask._workflows.serialization.strip_pickle_markers`). + must accept external JSON that might contain checkpoint markers, reject or + sanitize the reserved marker keys first. The allowlist is a mitigation that reduces attack surface but does not eliminate the inherent risks of deserializing untrusted pickle data. Treat diff --git a/python/packages/core/tests/core/test_telemetry.py b/python/packages/core/tests/core/test_telemetry.py index 80bb029769e..7d7fc77256b 100644 --- a/python/packages/core/tests/core/test_telemetry.py +++ b/python/packages/core/tests/core/test_telemetry.py @@ -3,6 +3,7 @@ import ast import concurrent.futures import importlib.metadata +import importlib.util import os import re from pathlib import Path @@ -203,6 +204,19 @@ def test_declared_feature_indexes_match_registry() -> None: repository_root / "python" / "packages" / "core" / "agent_framework" / "_telemetry.py", *repository_root.glob("python/packages/**/_feature_usage.py"), ] + external_package_roots: dict[Path, Path] = {} + # Their stable registry rows stay in this repository, but their declarations moved to the durable extension. + for package_name in ("agent_framework_durabletask", "agent_framework_azurefunctions"): + package_spec = importlib.util.find_spec(package_name) + assert package_spec is not None and package_spec.submodule_search_locations is not None, ( + f"{package_name} must be installed to validate its feature indexes." + ) + package_root = Path(next(iter(package_spec.submodule_search_locations))) + declaration_file = package_root / "_feature_usage.py" + assert declaration_file.exists(), f"{package_name} does not declare feature indexes." + declaration_files.append(declaration_file) + external_package_roots[declaration_file] = package_root + declarations_by_index: dict[int, str] = {} declaration_pairs: set[tuple[int, str]] = set() declaration_owners: dict[tuple[int, str], tuple[Path, Path]] = {} @@ -220,7 +234,14 @@ def test_declared_feature_indexes_match_registry() -> None: index = member.value.value if not isinstance(index, int): continue - declaration = f"{declaration_file.relative_to(repository_root)}:{target.id}" + external_package_root = external_package_roots.get(declaration_file) + if external_package_root is not None: + package_root = external_package_root + declaration_path = declaration_file.relative_to(external_package_root.parent) + else: + declaration_path = declaration_file.relative_to(repository_root) + package_root = repository_root.joinpath(*declaration_path.parts[:3]) + declaration = f"{declaration_path}:{target.id}" assert 0 <= index < 128, f"Feature index {index} is out of range in {declaration}." assert index not in declarations_by_index, ( f"Feature index {index} overlaps between {declarations_by_index[index]} and {declaration}." @@ -228,7 +249,6 @@ def test_declared_feature_indexes_match_registry() -> None: declarations_by_index[index] = declaration pair = (index, target.id) declaration_pairs.add(pair) - package_root = repository_root.joinpath(*declaration_file.relative_to(repository_root).parts[:3]) declaration_owners[pair] = (package_root, declaration_file) assert declaration_pairs == registry_pairs diff --git a/python/packages/durabletask/AGENTS.md b/python/packages/durabletask/AGENTS.md deleted file mode 100644 index 1da199f5f31..00000000000 --- a/python/packages/durabletask/AGENTS.md +++ /dev/null @@ -1,57 +0,0 @@ -# Durable Task Package (agent-framework-durabletask) - -Durable execution support for long-running agent workflows using Azure Durable Functions. - -## Main Classes - -### Client Side - -- **`DurableAIAgentClient`** - Client for invoking durable agents -- **`DurableAIAgent`** - Shim for creating durable agents - -### Worker Side - -- **`DurableAIAgentWorker`** - Worker that executes durable agent tasks -- **`DurableAgentExecutor`** - Executes agent logic within durable context -- **`AgentEntity`** - Durable entity for agent state management - -### State Management - -- **`DurableAgentState`** - State container for durable agents -- **`DurableAgentSession`** - Session management for durable agents -- **`DurableAIAgentOrchestrationContext`** - Orchestration context - -### Callbacks - -- **`AgentCallbackContext`** - Context for agent callbacks -- **`AgentResponseCallbackProtocol`** - Protocol for response callbacks - -## Usage - -```python -from agent_framework import Agent -from agent_framework.openai import OpenAIChatCompletionClient -from agent_framework_durabletask import DurableAIAgentClient, DurableAIAgentWorker -from durabletask.client import TaskHubGrpcClient -from durabletask.worker import TaskHubGrpcWorker - -# Client side -dt_client = TaskHubGrpcClient(host_address="localhost:4001") -agent_client = DurableAIAgentClient(dt_client) -durable_agent = agent_client.get_agent("assistant") - -# Worker side -dt_worker = TaskHubGrpcWorker(host_address="localhost:4001") -agent_worker = DurableAIAgentWorker(dt_worker) - -# Create a chat client for the agent -chat_client = OpenAIChatCompletionClient() -my_agent = Agent(client=chat_client, name="assistant") -agent_worker.add_agent(my_agent) -``` - -## Import Path - -```python -from agent_framework_durabletask import DurableAIAgentClient, DurableAIAgentWorker -``` diff --git a/python/packages/durabletask/LICENSE b/python/packages/durabletask/LICENSE deleted file mode 100644 index 9e841e7a26e..00000000000 --- a/python/packages/durabletask/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ - 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/durabletask/README.md b/python/packages/durabletask/README.md deleted file mode 100644 index 7758c741bfc..00000000000 --- a/python/packages/durabletask/README.md +++ /dev/null @@ -1,32 +0,0 @@ -# Get Started with Microsoft Agent Framework Durable Task - -[![PyPI](https://img.shields.io/pypi/v/agent-framework-durabletask)](https://pypi.org/project/agent-framework-durabletask/) - -Please install this package via pip: - -```bash -pip install agent-framework-durabletask --pre -``` - -## Durable Task Integration - -The durable task integration lets you host Microsoft Agent Framework agents using the [Durable Task](https://github.com/microsoft/durabletask-python) framework so they can persist state, replay conversation history, and recover from failures automatically. - -### Basic Usage Example - -```python -from agent_framework import Agent -from agent_framework.openai import OpenAIChatCompletionClient -from agent_framework_durabletask import DurableAIAgentWorker -from durabletask.worker import TaskHubGrpcWorker - -# Create the worker -worker = TaskHubGrpcWorker(host_address="localhost:4001") -agent_worker = DurableAIAgentWorker(worker) - -chat_client = OpenAIChatCompletionClient() -my_agent = Agent(client=chat_client, name="assistant") -agent_worker.add_agent(my_agent) -``` - -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/durabletask/agent_framework_durabletask/__init__.py b/python/packages/durabletask/agent_framework_durabletask/__init__.py deleted file mode 100644 index bcf0abdf110..00000000000 --- a/python/packages/durabletask/agent_framework_durabletask/__init__.py +++ /dev/null @@ -1,142 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Durable Task integration for Microsoft Agent Framework.""" - -import importlib.metadata - -from ._async_bridge import run_agent_coroutine -from ._callbacks import AgentCallbackContext, AgentResponseCallbackProtocol -from ._client import DurableAIAgentClient -from ._constants import ( - DEFAULT_MAX_POLL_RETRIES, - DEFAULT_POLL_INTERVAL_SECONDS, - MIMETYPE_APPLICATION_JSON, - MIMETYPE_TEXT_PLAIN, - REQUEST_RESPONSE_FORMAT_JSON, - REQUEST_RESPONSE_FORMAT_TEXT, - THREAD_ID_FIELD, - THREAD_ID_HEADER, - WAIT_FOR_RESPONSE_FIELD, - WAIT_FOR_RESPONSE_HEADER, - ApiResponseFields, - ContentTypes, - DurableStateFields, -) -from ._durable_agent_state import ( - DurableAgentState, - DurableAgentStateContent, - DurableAgentStateData, - DurableAgentStateDataContent, - DurableAgentStateEntry, - DurableAgentStateEntryJsonType, - DurableAgentStateErrorContent, - DurableAgentStateFunctionCallContent, - DurableAgentStateFunctionResultContent, - DurableAgentStateHostedFileContent, - DurableAgentStateHostedVectorStoreContent, - DurableAgentStateMessage, - DurableAgentStateRequest, - DurableAgentStateResponse, - DurableAgentStateTextContent, - DurableAgentStateTextReasoningContent, - DurableAgentStateUnknownContent, - DurableAgentStateUriContent, - DurableAgentStateUsage, - DurableAgentStateUsageContent, -) -from ._entities import AgentEntity, AgentEntityStateProviderMixin -from ._executors import DurableAgentExecutor -from ._models import AgentSessionId, DurableAgentSession, RunRequest -from ._orchestration_context import DurableAIAgentOrchestrationContext -from ._response_utils import ensure_response_format, load_agent_response -from ._shim import DurableAIAgent -from ._worker import DurableAIAgentWorker -from ._workflows.activity import execute_workflow_activity -from ._workflows.client import DurableWorkflowClient -from ._workflows.context import WorkflowOrchestrationContext -from ._workflows.dt_context import DurableTaskWorkflowContext -from ._workflows.naming import ( - DURABLE_NAME_PREFIX, - is_auto_generated_workflow_name, - validate_executor_id, - validate_workflow_name, - workflow_name_from_orchestrator, - workflow_orchestrator_name, -) -from ._workflows.orchestrator import run_workflow_orchestrator -from ._workflows.registration import WorkflowRegistrationPlan, collect_hosted_workflows, plan_workflow_registration -from ._workflows.runner_context import CapturingRunnerContext -from ._workflows.serialization import deserialize_workflow_output - -try: - __version__ = importlib.metadata.version(__name__) -except importlib.metadata.PackageNotFoundError: - __version__ = "0.0.0" # Fallback for development mode - -__all__ = [ - "DEFAULT_MAX_POLL_RETRIES", - "DEFAULT_POLL_INTERVAL_SECONDS", - "DURABLE_NAME_PREFIX", - "MIMETYPE_APPLICATION_JSON", - "MIMETYPE_TEXT_PLAIN", - "REQUEST_RESPONSE_FORMAT_JSON", - "REQUEST_RESPONSE_FORMAT_TEXT", - "THREAD_ID_FIELD", - "THREAD_ID_HEADER", - "WAIT_FOR_RESPONSE_FIELD", - "WAIT_FOR_RESPONSE_HEADER", - "AgentCallbackContext", - "AgentEntity", - "AgentEntityStateProviderMixin", - "AgentResponseCallbackProtocol", - "AgentSessionId", - "ApiResponseFields", - "CapturingRunnerContext", - "ContentTypes", - "DurableAIAgent", - "DurableAIAgentClient", - "DurableAIAgentOrchestrationContext", - "DurableAIAgentWorker", - "DurableAgentExecutor", - "DurableAgentSession", - "DurableAgentState", - "DurableAgentStateContent", - "DurableAgentStateData", - "DurableAgentStateDataContent", - "DurableAgentStateEntry", - "DurableAgentStateEntryJsonType", - "DurableAgentStateErrorContent", - "DurableAgentStateFunctionCallContent", - "DurableAgentStateFunctionResultContent", - "DurableAgentStateHostedFileContent", - "DurableAgentStateHostedVectorStoreContent", - "DurableAgentStateMessage", - "DurableAgentStateRequest", - "DurableAgentStateResponse", - "DurableAgentStateTextContent", - "DurableAgentStateTextReasoningContent", - "DurableAgentStateUnknownContent", - "DurableAgentStateUriContent", - "DurableAgentStateUsage", - "DurableAgentStateUsageContent", - "DurableStateFields", - "DurableTaskWorkflowContext", - "DurableWorkflowClient", - "RunRequest", - "WorkflowOrchestrationContext", - "WorkflowRegistrationPlan", - "__version__", - "collect_hosted_workflows", - "deserialize_workflow_output", - "ensure_response_format", - "execute_workflow_activity", - "is_auto_generated_workflow_name", - "load_agent_response", - "plan_workflow_registration", - "run_agent_coroutine", - "run_workflow_orchestrator", - "validate_executor_id", - "validate_workflow_name", - "workflow_name_from_orchestrator", - "workflow_orchestrator_name", -] diff --git a/python/packages/durabletask/agent_framework_durabletask/_async_bridge.py b/python/packages/durabletask/agent_framework_durabletask/_async_bridge.py deleted file mode 100644 index 2ca52c6c94e..00000000000 --- a/python/packages/durabletask/agent_framework_durabletask/_async_bridge.py +++ /dev/null @@ -1,89 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Persistent background event loop for running agent coroutines. - -Durable entity (and agent) handlers are invoked synchronously by the host on -arbitrary worker threads. Agent clients and their async credentials create -asyncio primitives (locks, connection pools, futures) that are bound to the -event loop on which they are *first* used. Running a later invocation on a -*different* event loop causes those primitives to await futures attached to a -now-idle loop, which results in a silent, permanent hang. - -This module provides a single, process-wide persistent event loop running on a -dedicated daemon thread. All agent coroutines are submitted to this loop via -``run_coroutine_threadsafe`` so shared async resources remain valid across -invocations regardless of which worker thread the host happens to use. -""" - -from __future__ import annotations - -import asyncio -import contextlib -import threading -from collections.abc import Coroutine -from typing import Any, TypeVar - -_T = TypeVar("_T") - -_loop: asyncio.AbstractEventLoop | None = None -_thread: threading.Thread | None = None -_lock = threading.Lock() - - -def _ensure_loop() -> asyncio.AbstractEventLoop: - """Return the shared persistent event loop, starting it on first use. - - The loop is only reusable when it is open *and* its backing thread is still - alive. A loop whose thread has died (e.g. during interpreter shutdown) is not - reusable: ``run_coroutine_threadsafe`` would schedule onto a loop that will - never run again and ``future.result()`` would block forever. Such a loop is - replaced with a fresh loop + thread. - """ - global _loop, _thread - - loop, thread = _loop, _thread - if loop is not None and not loop.is_closed() and thread is not None and thread.is_alive(): - return loop - - with _lock: - loop, thread = _loop, _thread - if loop is not None and not loop.is_closed() and thread is not None and thread.is_alive(): - return loop - - # An existing loop whose thread has died is orphaned; close it best-effort - # before replacing it so it does not leak. - if loop is not None and not loop.is_closed(): - with contextlib.suppress(Exception): - loop.close() - - new_loop = asyncio.new_event_loop() - - def _run() -> None: - asyncio.set_event_loop(new_loop) - new_loop.run_forever() - - new_thread = threading.Thread(target=_run, name="dafx-agent-loop", daemon=True) - new_thread.start() - - _loop = new_loop - _thread = new_thread - return new_loop - - -def run_agent_coroutine(coro: Coroutine[Any, Any, _T]) -> _T: - """Run a coroutine on the shared persistent event loop and return its result. - - The calling (worker) thread blocks until the coroutine completes. Because - every agent coroutine runs on the same loop, async resources created by - shared agent clients/credentials (locks, connection pools) remain bound to a - live loop across all invocations, preventing cross-loop hangs. - - Args: - coro: The coroutine to execute. - - Returns: - The coroutine's result. - """ - loop = _ensure_loop() - future = asyncio.run_coroutine_threadsafe(coro, loop) - return future.result() diff --git a/python/packages/durabletask/agent_framework_durabletask/_callbacks.py b/python/packages/durabletask/agent_framework_durabletask/_callbacks.py deleted file mode 100644 index 53c4c2d71a7..00000000000 --- a/python/packages/durabletask/agent_framework_durabletask/_callbacks.py +++ /dev/null @@ -1,40 +0,0 @@ -# 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 dataclasses import dataclass -from typing import Protocol - -from agent_framework import AgentResponse, AgentResponseUpdate - - -@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: AgentResponseUpdate, - context: AgentCallbackContext, - ) -> None: - """Handle a streaming response update emitted by the agent.""" - - async def on_agent_response( - self, - response: AgentResponse, - context: AgentCallbackContext, - ) -> None: - """Handle the final agent response.""" diff --git a/python/packages/durabletask/agent_framework_durabletask/_client.py b/python/packages/durabletask/agent_framework_durabletask/_client.py deleted file mode 100644 index b7eef35252d..00000000000 --- a/python/packages/durabletask/agent_framework_durabletask/_client.py +++ /dev/null @@ -1,92 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Client wrapper for Durable Task Agent Framework. - -This module provides the DurableAIAgentClient class for external clients to interact -with durable agents via gRPC. -""" - -from __future__ import annotations - -import logging - -from agent_framework import AgentResponse -from durabletask.client import TaskHubGrpcClient - -from ._constants import DEFAULT_MAX_POLL_RETRIES, DEFAULT_POLL_INTERVAL_SECONDS -from ._executors import ClientAgentExecutor -from ._shim import DurableAgentProvider, DurableAIAgent - -logger = logging.getLogger("agent_framework.durabletask") - - -class DurableAIAgentClient(DurableAgentProvider[AgentResponse]): - """Client wrapper for interacting with durable agents externally. - - This class wraps a durabletask TaskHubGrpcClient and provides a convenient - interface for retrieving and executing durable agents from external contexts. - - Example: - ```python - from durabletask import TaskHubGrpcClient - from agent_framework.azure import DurableAIAgentClient - - # Create the underlying client - client = TaskHubGrpcClient(host_address="localhost:4001") - - # Wrap it with the agent client - agent_client = DurableAIAgentClient(client) - - # Get an agent reference - agent = agent_client.get_agent("assistant") - - # Run the agent (synchronous call that waits for completion) - response = agent.run("Hello, how are you?") - print(response.text) - ``` - """ - - def __init__( - self, - client: TaskHubGrpcClient, - max_poll_retries: int = DEFAULT_MAX_POLL_RETRIES, - poll_interval_seconds: float = DEFAULT_POLL_INTERVAL_SECONDS, - ): - """Initialize the client wrapper. - - Args: - client: The durabletask client instance to wrap - max_poll_retries: Maximum polling attempts when waiting for responses - poll_interval_seconds: Delay in seconds between polling attempts - """ - self._client = client - - # Validate and set polling parameters - self.max_poll_retries = max(1, max_poll_retries) - self.poll_interval_seconds = ( - poll_interval_seconds if poll_interval_seconds > 0 else DEFAULT_POLL_INTERVAL_SECONDS - ) - - self._executor = ClientAgentExecutor(self._client, self.max_poll_retries, self.poll_interval_seconds) - logger.debug("[DurableAIAgentClient] Initialized with client type: %s", type(client).__name__) - - def get_agent(self, agent_name: str) -> DurableAIAgent[AgentResponse]: - """Retrieve a DurableAIAgent shim for the specified agent. - - This method returns a proxy object that can be used to execute the agent. - The actual agent must be registered on a worker with the same name. - - Args: - agent_name: Name of the agent to retrieve (without the dafx- prefix) - - Returns: - DurableAIAgent instance that can be used to run the agent - - Note: - This method does not validate that the agent exists. Validation - will occur when the agent is executed. If the entity doesn't exist, - the execution will fail with an appropriate error. - """ - logger.debug("[DurableAIAgentClient] Creating agent proxy for: %s", agent_name) - - return DurableAIAgent(self._executor, agent_name) diff --git a/python/packages/durabletask/agent_framework_durabletask/_constants.py b/python/packages/durabletask/agent_framework_durabletask/_constants.py deleted file mode 100644 index a821590bd4f..00000000000 --- a/python/packages/durabletask/agent_framework_durabletask/_constants.py +++ /dev/null @@ -1,130 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Constants for Azure Functions Agent Framework integration. - -This module contains: -- Runtime configuration constants (polling, MIME types, headers) -- JSON field name mappings for camelCase (JSON) ↔ snake_case (Python) serialization - -For serialization constants, use the DurableStateFields, ContentTypes, and EntryTypes classes -to ensure consistent field naming between to_dict() and from_dict() methods. -""" - -from typing import Final - -# Supported request/response formats and MIME types -REQUEST_RESPONSE_FORMAT_JSON: str = "json" -REQUEST_RESPONSE_FORMAT_TEXT: str = "text" -MIMETYPE_APPLICATION_JSON: str = "application/json" -MIMETYPE_TEXT_PLAIN: str = "text/plain" - -# Field and header names -THREAD_ID_FIELD: str = "thread_id" -THREAD_ID_HEADER: str = "x-ms-thread-id" -WAIT_FOR_RESPONSE_FIELD: str = "wait_for_response" -WAIT_FOR_RESPONSE_HEADER: str = "x-ms-wait-for-response" - -# Polling configuration -DEFAULT_MAX_POLL_RETRIES: int = 30 -DEFAULT_POLL_INTERVAL_SECONDS: float = 1.0 - - -# ============================================================================= -# JSON Field Name Constants for Durable Agent State Serialization -# ============================================================================= -# These constants ensure consistent camelCase field names in JSON serialization. -# Use these in both to_dict() and from_dict() methods to prevent mismatches. - -# NOTE: Changing these constants is a breaking change and might require a schema version bump. - - -class DurableStateFields: - """JSON field name constants for durable agent state serialization. - - All field names are in camelCase to match the JSON schema. - Use these constants in both to_dict() and from_dict() methods. - """ - - # Schema-level fields - SCHEMA_VERSION: Final[str] = "schemaVersion" - DATA: Final[str] = "data" - - # Entry discriminator - TYPE_DISCRIMINATOR: Final[str] = "$type" - - # Internal field names - JSON_TYPE: Final[str] = "json_type" - TYPE_INTERNAL: Final[str] = "type" - - # Common entry fields - CORRELATION_ID: Final[str] = "correlationId" - CREATED_AT: Final[str] = "createdAt" - MESSAGES: Final[str] = "messages" - EXTENSION_DATA: Final[str] = "extensionData" - - # Request-specific fields - RESPONSE_TYPE: Final[str] = "responseType" - RESPONSE_SCHEMA: Final[str] = "responseSchema" - ORCHESTRATION_ID: Final[str] = "orchestrationId" - - # Response-specific fields - USAGE: Final[str] = "usage" - - # Message fields - ROLE: Final[str] = "role" - CONTENTS: Final[str] = "contents" - AUTHOR_NAME: Final[str] = "authorName" - - # Content fields - TEXT: Final[str] = "text" - URI: Final[str] = "uri" - MEDIA_TYPE: Final[str] = "mediaType" - MESSAGE: Final[str] = "message" - ERROR_CODE: Final[str] = "errorCode" - DETAILS: Final[str] = "details" - CALL_ID: Final[str] = "callId" - NAME: Final[str] = "name" - ARGUMENTS: Final[str] = "arguments" - RESULT: Final[str] = "result" - FILE_ID: Final[str] = "fileId" - VECTOR_STORE_ID: Final[str] = "vectorStoreId" - CONTENT: Final[str] = "content" - - # Usage fields (noqa: S105 - these are JSON field names, not passwords) - INPUT_TOKEN_COUNT: Final[str] = "inputTokenCount" # ruff:ignore[hardcoded-password-string] - OUTPUT_TOKEN_COUNT: Final[str] = "outputTokenCount" # ruff:ignore[hardcoded-password-string] - TOTAL_TOKEN_COUNT: Final[str] = "totalTokenCount" # ruff:ignore[hardcoded-password-string] - - # History field - CONVERSATION_HISTORY: Final[str] = "conversationHistory" - - -class ContentTypes: - """Content type discriminator values for the $type field. - - These values are used in the JSON $type field to identify content types. - """ - - TEXT: Final[str] = "text" - DATA: Final[str] = "data" - ERROR: Final[str] = "error" - FUNCTION_CALL: Final[str] = "functionCall" - FUNCTION_RESULT: Final[str] = "functionResult" - HOSTED_FILE: Final[str] = "hostedFile" - HOSTED_VECTOR_STORE: Final[str] = "hostedVectorStore" - REASONING: Final[str] = "reasoning" - URI: Final[str] = "uri" - USAGE: Final[str] = "usage" - UNKNOWN: Final[str] = "unknown" - - -class ApiResponseFields: - """Field names for HTTP API responses (not part of persisted schema). - - These are used in try_get_agent_response() for backward compatibility - with the HTTP API response format. - """ - - CONTENT: Final[str] = "content" - MESSAGE_COUNT: Final[str] = "message_count" - CORRELATION_ID: Final[str] = "correlationId" diff --git a/python/packages/durabletask/agent_framework_durabletask/_durable_agent_state.py b/python/packages/durabletask/agent_framework_durabletask/_durable_agent_state.py deleted file mode 100644 index 209ff17ff7b..00000000000 --- a/python/packages/durabletask/agent_framework_durabletask/_durable_agent_state.py +++ /dev/null @@ -1,1334 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Durable agent state management conforming to the durable-agent-entity-state.json schema. - -This module provides classes for managing conversation state in Azure Durable Functions agents. -It implements the versioned schema that defines how agent conversations are persisted and restored -across invocations, enabling stateful, long-running agent sessions. - -The module includes: -- DurableAgentState: Root state container with schema version and conversation history -- DurableAgentStateEntry and subclasses: Request and response entries in conversation history -- DurableAgentStateMessage: Individual messages with role, content items, and metadata -- Content type classes: Specialized types for text, function calls, errors, and other content -- Serialization/deserialization: Conversion between Python objects and JSON schema format - -The state structure follows this hierarchy: - DurableAgentState - └── DurableAgentStateData - └── conversationHistory: List[DurableAgentStateEntry] - ├── DurableAgentStateRequest (user/system messages) - └── DurableAgentStateResponse (assistant messages with usage stats) - └── messages: List[DurableAgentStateMessage] - └── contents: List[DurableAgentStateContent subclasses] - -All classes support bidirectional conversion between: -- Durable state format (JSON with camelCase, $type discriminators) -- Agent framework objects (Python objects with snake_case) -""" - -from __future__ import annotations - -import json -import logging -from collections.abc import MutableMapping -from datetime import datetime, timezone -from enum import Enum -from typing import Any, ClassVar, cast - -from agent_framework import ( - AgentResponse, - Content, - Message, - UsageDetails, -) -from dateutil import parser as date_parser - -from ._constants import ContentTypes, DurableStateFields -from ._models import RunRequest, serialize_response_format - -logger = logging.getLogger("agent_framework.durabletask") - - -class DurableAgentStateEntryJsonType(str, Enum): - """Enum for conversation history entry types. - - Discriminator values for the $type field in DurableAgentStateEntry objects. - """ - - REQUEST = "request" - RESPONSE = "response" - - -def _parse_created_at(value: Any) -> datetime: - """Normalize created_at values coming from persisted durable state.""" - if isinstance(value, datetime): - return value - - if isinstance(value, str): - try: - parsed = date_parser.parse(value) - if isinstance(parsed, datetime): - return parsed - except (ValueError, TypeError): - pass - - logger.warning( - f"Invalid or missing created_at value in durable agent state; defaulting to current UTC time, {value}", - stack_info=True, - ) - return datetime.now(tz=timezone.utc) - - -def _parse_messages(data: dict[str, Any]) -> list[DurableAgentStateMessage]: - """Parse messages from a dictionary, converting dicts to DurableAgentStateMessage objects. - - Args: - data: Dictionary containing a 'messages' key with a list of message data - - Returns: - List of DurableAgentStateMessage objects - """ - messages: list[DurableAgentStateMessage] = [] - raw_messages: list[Any] = data.get(DurableStateFields.MESSAGES, []) - for raw_msg in raw_messages: - if isinstance(raw_msg, dict): - messages.append(DurableAgentStateMessage.from_dict(cast(dict[str, Any], raw_msg))) - elif isinstance(raw_msg, DurableAgentStateMessage): - messages.append(raw_msg) - return messages - - -def _parse_history_entries(data_dict: dict[str, Any]) -> list[DurableAgentStateEntry]: - """Parse conversation history entries from a dictionary. - - Args: - data_dict: Dictionary containing a 'conversationHistory' key with a list of entry data - - Returns: - List of DurableAgentStateEntry objects (requests and responses) - """ - history_data: list[Any] = data_dict.get(DurableStateFields.CONVERSATION_HISTORY, []) - deserialized_history: list[DurableAgentStateEntry] = [] - for raw_entry in history_data: - if isinstance(raw_entry, dict): - entry_dict = cast(dict[str, Any], raw_entry) - entry_type = entry_dict.get(DurableStateFields.TYPE_DISCRIMINATOR) or entry_dict.get( - DurableStateFields.JSON_TYPE - ) - if entry_type == DurableAgentStateEntryJsonType.RESPONSE: - deserialized_history.append(DurableAgentStateResponse.from_dict(entry_dict)) - elif entry_type == DurableAgentStateEntryJsonType.REQUEST: - deserialized_history.append(DurableAgentStateRequest.from_dict(entry_dict)) - else: - deserialized_history.append(DurableAgentStateEntry.from_dict(entry_dict)) - elif isinstance(raw_entry, DurableAgentStateEntry): - deserialized_history.append(raw_entry) - return deserialized_history - - -def _parse_contents(data: dict[str, Any]) -> list[DurableAgentStateContent]: - """Parse content items from a dictionary. - - Args: - data: Dictionary containing a 'contents' key with a list of content data - - Returns: - List of DurableAgentStateContent objects - """ - contents: list[DurableAgentStateContent] = [] - raw_contents: list[Any] = data.get(DurableStateFields.CONTENTS, []) - for raw_content in raw_contents: - if isinstance(raw_content, DurableAgentStateContent): - contents.append(raw_content) - - elif isinstance(raw_content, dict): - content_dict = cast(dict[str, Any], raw_content) - content_type: str | None = content_dict.get(DurableStateFields.TYPE_DISCRIMINATOR) - - match content_type: - case ContentTypes.TEXT: - contents.append(DurableAgentStateTextContent(text=content_dict.get(DurableStateFields.TEXT))) - - case ContentTypes.DATA: - contents.append( - DurableAgentStateDataContent( - uri=str(content_dict.get(DurableStateFields.URI, "")), - media_type=content_dict.get(DurableStateFields.MEDIA_TYPE), - ) - ) - - case ContentTypes.ERROR: - contents.append( - DurableAgentStateErrorContent( - message=content_dict.get(DurableStateFields.MESSAGE), - error_code=content_dict.get(DurableStateFields.ERROR_CODE), - details=content_dict.get(DurableStateFields.DETAILS), - ) - ) - - case ContentTypes.FUNCTION_CALL: - contents.append( - DurableAgentStateFunctionCallContent( - call_id=str(content_dict.get(DurableStateFields.CALL_ID, "")), - name=str(content_dict.get(DurableStateFields.NAME, "")), - arguments=content_dict.get(DurableStateFields.ARGUMENTS, {}), - ) - ) - - case ContentTypes.FUNCTION_RESULT: - contents.append( - DurableAgentStateFunctionResultContent( - call_id=str(content_dict.get(DurableStateFields.CALL_ID, "")), - result=content_dict.get(DurableStateFields.RESULT), - ) - ) - - case ContentTypes.HOSTED_FILE: - contents.append( - DurableAgentStateHostedFileContent( - file_id=str(content_dict.get(DurableStateFields.FILE_ID, "")) - ) - ) - - case ContentTypes.HOSTED_VECTOR_STORE: - contents.append( - DurableAgentStateHostedVectorStoreContent( - vector_store_id=str(content_dict.get(DurableStateFields.VECTOR_STORE_ID, "")) - ) - ) - - case ContentTypes.REASONING: - contents.append( - DurableAgentStateTextReasoningContent(text=content_dict.get(DurableStateFields.TEXT)) - ) - - case ContentTypes.URI: - contents.append( - DurableAgentStateUriContent( - uri=str(content_dict.get(DurableStateFields.URI, "")), - media_type=str(content_dict.get(DurableStateFields.MEDIA_TYPE, "")), - ) - ) - - case ContentTypes.USAGE: - usage_data = content_dict.get(DurableStateFields.USAGE) - if usage_data and isinstance(usage_data, dict): - contents.append( - DurableAgentStateUsageContent( - usage=DurableAgentStateUsage.from_dict(cast(dict[str, Any], usage_data)) - ) - ) - - case ContentTypes.UNKNOWN | _: - # Handle UNKNOWN type or any unexpected content types (including None) - contents.append( - DurableAgentStateUnknownContent(content=content_dict.get(DurableStateFields.CONTENT, {})) - ) - - return contents - - -class DurableAgentStateContent: - """Base class for all content types in durable agent state messages. - - This abstract base class defines the interface for content items that can be - stored in conversation history. Content types include text, function calls, - function results, errors, and other specialized content types defined by the - agent framework. - - Subclasses must implement to_dict() and to_ai_content() to handle conversion - between the durable state representation and the agent framework's content objects. - - Attributes: - extensionData: Optional additional metadata (not serialized per schema) - """ - - extensionData: dict[str, Any] | None = None - type: str = "" - - def to_dict(self) -> dict[str, Any]: - """Serialize this content to a dictionary for JSON storage. - - Returns: - Dictionary representation including $type discriminator and content-specific fields - - Raises: - NotImplementedError: Must be implemented by subclasses - """ - raise NotImplementedError - - def to_ai_content(self) -> Any: - """Convert this durable state content back to an agent framework content object. - - Returns: - An agent framework content object (Content of type `text`, `function_call`, etc.) - - Raises: - NotImplementedError: Must be implemented by subclasses - """ - raise NotImplementedError - - @staticmethod - def from_ai_content(content: Any) -> DurableAgentStateContent: - """Create a durable state content object from an agent framework content object. - - This factory method maps agent framework content types to their corresponding durable state representations. - Unknown content types are wrapped in DurableAgentStateUnknownContent. - - Args: - content: An agent framework content object (Content of type `text`, `function_call`, etc.) - - Returns: - The corresponding DurableAgentStateContent subclass instance - """ - # Map AI content type to appropriate DurableAgentStateContent subclass - if not isinstance(content, Content): - return DurableAgentStateUnknownContent.from_unknown_content(content) - - match content.type: - case "data": - return DurableAgentStateDataContent.from_data_content(content) - case "error": - return DurableAgentStateErrorContent.from_error_content(content) - case "function_call": - return DurableAgentStateFunctionCallContent.from_function_call_content(content) - case "function_result": - return DurableAgentStateFunctionResultContent.from_function_result_content(content) - case "hosted_file": - return DurableAgentStateHostedFileContent.from_hosted_file_content(content) - case "hosted_vector_store": - return DurableAgentStateHostedVectorStoreContent.from_hosted_vector_store_content(content) - case "text": - return DurableAgentStateTextContent.from_text_content(content) - case "reasoning": - return DurableAgentStateTextReasoningContent.from_text_reasoning_content(content) - case "uri": - return DurableAgentStateUriContent.from_uri_content(content) - case "usage": - return DurableAgentStateUsageContent.from_usage_content(content) - case _: - return DurableAgentStateUnknownContent.from_unknown_content(content) - - -# Core state classes - - -class DurableAgentStateData: - """Container for the core data within durable agent state. - - This class holds the primary data structures for agent conversation state, - including the conversation history (a sequence of request and response entries) - and optional extension data for custom metadata. - - The data structure is nested within DurableAgentState under the "data" property, - conforming to the durable-agent-entity-state.json schema structure. - - Attributes: - conversation_history: Ordered list of conversation entries (requests and responses) - extension_data: Optional dictionary for custom metadata (not part of core schema) - """ - - conversation_history: list[DurableAgentStateEntry] - extension_data: dict[str, Any] | None - - def __init__( - self, - conversation_history: list[DurableAgentStateEntry] | None = None, - extension_data: dict[str, Any] | None = None, - ) -> None: - """Initialize the data container. - - Args: - conversation_history: Initial conversation history (defaults to empty list) - extension_data: Optional custom metadata - """ - self.conversation_history = conversation_history or [] - self.extension_data = extension_data - - def to_dict(self) -> dict[str, Any]: - result: dict[str, Any] = { - DurableStateFields.CONVERSATION_HISTORY: [entry.to_dict() for entry in self.conversation_history], - } - if self.extension_data is not None: - result[DurableStateFields.EXTENSION_DATA] = self.extension_data - return result - - @classmethod - def from_dict(cls, data_dict: dict[str, Any]) -> DurableAgentStateData: - return cls( - conversation_history=_parse_history_entries(data_dict), - extension_data=data_dict.get(DurableStateFields.EXTENSION_DATA), - ) - - -class DurableAgentState: - """Manages durable agent state conforming to the durable-agent-entity-state.json schema. - - This class provides the root container for agent conversation state that can be persisted - in Azure Durable Entities. It maintains the conversation history as a sequence of request - and response entries, each with their messages, timestamps, and metadata. - - The state follows a versioned schema (see SCHEMA_VERSION class constant) that defines the structure for: - - Request entries: User/system messages with optional response format specifications - - Response entries: Assistant messages with token usage information - - Messages: Individual chat messages with role, content items, and timestamps - - Content items: Text, function calls, function results, errors, and other content types - - State is serialized to JSON with this structure: - { - "schemaVersion": "", - "data": { - "conversationHistory": [ - {"$type": "request", "correlationId": "...", "createdAt": "...", "messages": [...]}, - {"$type": "response", "correlationId": "...", "createdAt": "...", "messages": [...], "usage": {...}} - ] - } - } - - Attributes: - data: Container for conversation history and optional extension data - schema_version: Schema version string (defaults to SCHEMA_VERSION) - """ - - # Durable Agent Schema version - SCHEMA_VERSION: str = "1.1.0" - - data: DurableAgentStateData - schema_version: str = SCHEMA_VERSION - - def __init__(self, schema_version: str = SCHEMA_VERSION): - """Initialize a new durable agent state. - - Args: - schema_version: Schema version to use (defaults to SCHEMA_VERSION) - """ - self.data = DurableAgentStateData() - self.schema_version = schema_version - - def to_dict(self) -> dict[str, Any]: - - return { - DurableStateFields.SCHEMA_VERSION: self.schema_version, - DurableStateFields.DATA: self.data.to_dict(), - } - - def to_json(self) -> str: - return json.dumps(self.to_dict()) - - @classmethod - def from_dict(cls, state: dict[str, Any]) -> DurableAgentState: - """Restore state from a dictionary. - - Args: - state: Dictionary containing schemaVersion and data (full state structure) - """ - schema_version = state.get(DurableStateFields.SCHEMA_VERSION) - if schema_version is None: - logger.warning("Resetting state as it is incompatible with the current schema, all history will be lost") - return cls() - - instance = cls(schema_version=state.get(DurableStateFields.SCHEMA_VERSION, DurableAgentState.SCHEMA_VERSION)) - instance.data = DurableAgentStateData.from_dict(state.get(DurableStateFields.DATA, {})) - - return instance - - @classmethod - def from_json(cls, json_str: str) -> DurableAgentState: - try: - obj = json.loads(json_str) - except json.JSONDecodeError as e: - raise ValueError("The durable agent state is not valid JSON.") from e - - return cls.from_dict(obj) - - @property - def message_count(self) -> int: - """Get the count of conversation entries (requests + responses).""" - return len(self.data.conversation_history) - - def try_get_agent_response(self, correlation_id: str) -> AgentResponse | None: - """Try to get an agent response by correlation ID. - - This method searches the conversation history for a response entry matching the given - correlation ID and returns a dictionary suitable for HTTP API responses. - - Note: The returned dictionary includes computed properties (message_count) that are - NOT part of the persisted state schema. These are derived values included for backward - compatibility with the HTTP API response format and should not be considered part of - the durable state structure. - - Args: - correlation_id: The correlation ID to search for - - Returns: - Response data dict with 'content', 'message_count', and 'correlationId' if found, - None otherwise - """ - # Search through conversation history for a response with this correlationId - for entry in self.data.conversation_history: - if entry.correlation_id == correlation_id and isinstance(entry, DurableAgentStateResponse): - # Found the entry, extract response data - return DurableAgentStateResponse.to_run_response(entry) - - return None - - -class DurableAgentStateEntry: - """Base class for conversation history entries (requests and responses). - - This class represents a single entry in the conversation history. Each entry can be - either a request (user/system messages sent to the agent) or a response (assistant - messages from the agent). The $type discriminator field determines which type of entry - it represents. - - Entries are linked together using correlation IDs, allowing responses to be matched - with their originating requests. - - Common Attributes: - json_type: Discriminator for entry type ("request" or "response") - correlationId: Unique identifier linking requests and responses - created_at: Timestamp when the entry was created - messages: List of messages in this entry - extensionData: Optional additional metadata (not serialized per schema) - - Request-only Attributes: - responseType: Expected response type ("text" or "json") - only for request entries - responseSchema: JSON schema for structured responses - only for request entries - - Response-only Attributes: - usage: Token usage statistics - only for response entries - """ - - json_type: DurableAgentStateEntryJsonType - correlation_id: str | None - created_at: datetime - messages: list[DurableAgentStateMessage] - extension_data: dict[str, Any] | None - - def __init__( - self, - json_type: DurableAgentStateEntryJsonType, - correlation_id: str | None, - created_at: datetime, - messages: list[DurableAgentStateMessage], - extension_data: dict[str, Any] | None = None, - ) -> None: - self.json_type = json_type - self.correlation_id = correlation_id - self.created_at = created_at - self.messages = messages - self.extension_data = extension_data - - def to_dict(self) -> dict[str, Any]: - return { - DurableStateFields.TYPE_DISCRIMINATOR: self.json_type, - DurableStateFields.CORRELATION_ID: self.correlation_id, - DurableStateFields.CREATED_AT: self.created_at.isoformat(), - DurableStateFields.MESSAGES: [m.to_dict() for m in self.messages], - } - - @classmethod - def from_dict(cls, data: dict[str, Any]) -> DurableAgentStateEntry: - created_at = _parse_created_at(data.get(DurableStateFields.CREATED_AT)) - messages = _parse_messages(data) - - return cls( - json_type=DurableAgentStateEntryJsonType(data.get(DurableStateFields.TYPE_DISCRIMINATOR)), - correlation_id=data.get(DurableStateFields.CORRELATION_ID), - created_at=created_at, - messages=messages, - extension_data=data.get(DurableStateFields.EXTENSION_DATA), - ) - - -class DurableAgentStateRequest(DurableAgentStateEntry): - """Represents a request entry in the durable agent conversation history. - - A request entry captures a user or system message sent to the agent, along with - optional response format specifications. Each request is stored as a separate - entry in the conversation history with a unique correlation ID. - - Attributes: - response_type: Expected response type ("text" or "json") - response_schema: JSON schema for structured responses (when response_type is "json") - orchestration_id: ID of the orchestration that initiated this request (if any) - correlationId: Unique identifier linking this request to its response - created_at: Timestamp when the request was created - messages: List of messages included in this request - json_type: Always "request" for this class - """ - - response_type: str | None = None - response_schema: dict[str, Any] | None = None - orchestration_id: str | None = None - - def __init__( - self, - correlation_id: str | None, - created_at: datetime, - messages: list[DurableAgentStateMessage], - extension_data: dict[str, Any] | None = None, - response_type: str | None = None, - response_schema: dict[str, Any] | None = None, - orchestration_id: str | None = None, - ) -> None: - super().__init__( - json_type=DurableAgentStateEntryJsonType.REQUEST, - correlation_id=correlation_id, - created_at=created_at, - messages=messages, - extension_data=extension_data, - ) - self.response_type = response_type - self.response_schema = response_schema - self.orchestration_id = orchestration_id - - def to_dict(self) -> dict[str, Any]: - data = super().to_dict() - if self.orchestration_id is not None: - data[DurableStateFields.ORCHESTRATION_ID] = self.orchestration_id - if self.response_type is not None: - data[DurableStateFields.RESPONSE_TYPE] = self.response_type - if self.response_schema is not None: - data[DurableStateFields.RESPONSE_SCHEMA] = self.response_schema - return data - - @classmethod - def from_dict(cls, data: dict[str, Any]) -> DurableAgentStateRequest: - created_at = _parse_created_at(data.get(DurableStateFields.CREATED_AT)) - messages = _parse_messages(data) - - return cls( - correlation_id=data.get(DurableStateFields.CORRELATION_ID), - created_at=created_at, - messages=messages, - extension_data=data.get(DurableStateFields.EXTENSION_DATA), - response_type=data.get(DurableStateFields.RESPONSE_TYPE), - response_schema=data.get(DurableStateFields.RESPONSE_SCHEMA), - orchestration_id=data.get(DurableStateFields.ORCHESTRATION_ID), - ) - - @staticmethod - def from_run_request(request: RunRequest) -> DurableAgentStateRequest: - # Determine response_type based on response_format - return DurableAgentStateRequest( - correlation_id=request.correlation_id, - messages=[DurableAgentStateMessage.from_run_request(request)], - created_at=_parse_created_at(request.created_at), - response_type=request.request_response_format, - response_schema=serialize_response_format(request.response_format), - orchestration_id=request.orchestration_id, - ) - - -class DurableAgentStateResponse(DurableAgentStateEntry): - """Represents a response entry in the durable agent conversation history. - - A response entry captures the agent's reply to a user request, including any - assistant messages, tool calls, and token usage information. Each response is - linked to its originating request via a correlation ID. - - Attributes: - usage: Token usage statistics for this response (input, output, and total tokens) - is_error: Flag indicating if this response represents an error (not persisted in schema) - correlation_id: Unique identifier linking this response to its request - created_at: Timestamp when the response was created - messages: List of assistant messages in this response - json_type: Always "response" for this class - """ - - usage: DurableAgentStateUsage | None = None - is_error: bool = False - - def __init__( - self, - correlation_id: str | None, - created_at: datetime, - messages: list[DurableAgentStateMessage], - extension_data: dict[str, Any] | None = None, - usage: DurableAgentStateUsage | None = None, - is_error: bool = False, - ) -> None: - super().__init__( - json_type=DurableAgentStateEntryJsonType.RESPONSE, - correlation_id=correlation_id, - created_at=created_at, - messages=messages, - extension_data=extension_data, - ) - self.usage = usage - self.is_error = is_error - - def to_dict(self) -> dict[str, Any]: - data = super().to_dict() - if self.usage is not None: - data[DurableStateFields.USAGE] = self.usage.to_dict() - return data - - @classmethod - def from_dict(cls, data: dict[str, Any]) -> DurableAgentStateResponse: - created_at = _parse_created_at(data.get(DurableStateFields.CREATED_AT)) - messages = _parse_messages(data) - - usage_dict = data.get(DurableStateFields.USAGE) - usage: DurableAgentStateUsage | None = None - if usage_dict and isinstance(usage_dict, dict): - usage = DurableAgentStateUsage.from_dict(cast(dict[str, Any], usage_dict)) - - return cls( - correlation_id=data.get(DurableStateFields.CORRELATION_ID), - created_at=created_at, - messages=messages, - extension_data=data.get(DurableStateFields.EXTENSION_DATA), - usage=usage, - ) - - @staticmethod - def from_run_response(correlation_id: str, response: AgentResponse) -> DurableAgentStateResponse: - """Creates a DurableAgentStateResponse from an AgentResponse.""" - return DurableAgentStateResponse( - correlation_id=correlation_id, - created_at=_parse_created_at(response.created_at), - messages=[DurableAgentStateMessage.from_chat_message(m) for m in response.messages], - usage=DurableAgentStateUsage.from_usage(response.usage_details), - ) - - @staticmethod - def to_run_response( - response_entry: DurableAgentStateResponse, - ) -> AgentResponse: - """Converts a DurableAgentStateResponse back to an AgentResponse.""" - messages = [m.to_chat_message() for m in response_entry.messages] - - usage_details = response_entry.usage.to_usage_details() if response_entry.usage is not None else UsageDetails() - - return AgentResponse( - created_at=response_entry.created_at.isoformat(), - messages=messages, - usage_details=usage_details, - ) - - -class DurableAgentStateMessage: - """Represents a message within a conversation history entry. - - A message contains the role (user, assistant, system), content items (text, function calls, - tool results, etc.), and optional metadata. Messages are the building blocks of both - request and response entries in the conversation history. - - Attributes: - role: The sender role ("user", "assistant", or "system") - contents: List of content items (text, function calls, errors, etc.) - author_name: Optional name of the message author (typically set for assistant messages) - created_at: Optional timestamp when the message was created - extension_data: Optional additional metadata (not serialized per schema) - """ - - role: str - contents: list[DurableAgentStateContent] - author_name: str | None = None - created_at: datetime | None = None - extension_data: dict[str, Any] | None = None - - def __init__( - self, - role: str, - contents: list[DurableAgentStateContent], - author_name: str | None = None, - created_at: datetime | None = None, - extension_data: dict[str, Any] | None = None, - ) -> None: - self.role = role - self.contents = contents - self.author_name = author_name - self.created_at = created_at - self.extension_data = extension_data - - def to_dict(self) -> dict[str, Any]: - result: dict[str, Any] = { - DurableStateFields.ROLE: self.role, - DurableStateFields.CONTENTS: [ - { - DurableStateFields.TYPE_DISCRIMINATOR: c.to_dict().get( - DurableStateFields.TYPE_INTERNAL, ContentTypes.TEXT - ), - **{k: v for k, v in c.to_dict().items() if k != DurableStateFields.TYPE_INTERNAL}, - } - for c in self.contents - ], - } - # Only include optional fields if they have values - if self.created_at is not None: - result[DurableStateFields.CREATED_AT] = self.created_at.isoformat() - if self.author_name is not None: - result[DurableStateFields.AUTHOR_NAME] = self.author_name - return result - - @classmethod - def from_dict(cls, data: dict[str, Any]) -> DurableAgentStateMessage: - data_created_at = data.get(DurableStateFields.CREATED_AT) - created_at = _parse_created_at(data_created_at) if data_created_at else None - - return cls( - role=data.get(DurableStateFields.ROLE, ""), - contents=_parse_contents(data), - author_name=data.get(DurableStateFields.AUTHOR_NAME), - created_at=created_at, - extension_data=data.get(DurableStateFields.EXTENSION_DATA), - ) - - @property - def text(self) -> str: - """Extract text from the contents list.""" - text_parts: list[str] = [] - for content in self.contents: - if isinstance(content, DurableAgentStateTextContent): - text_parts.append(content.text or "") - return "".join(text_parts) - - @staticmethod - def from_run_request(request: RunRequest) -> DurableAgentStateMessage: - """Converts a RunRequest from the agent framework to a DurableAgentStateMessage. - - Args: - request: RunRequest object with role, message/contents, and metadata - Returns: - DurableAgentStateMessage with converted content items and metadata - """ - return DurableAgentStateMessage( - role=request.role, - contents=[DurableAgentStateTextContent(text=request.message)], - created_at=_parse_created_at(request.created_at) if request.created_at else None, - ) - - @staticmethod - def from_chat_message(chat_message: Message) -> DurableAgentStateMessage: - """Converts an Agent Framework chat message to a durable state message. - - Args: - chat_message: Message object with role, contents, and metadata to convert - - Returns: - DurableAgentStateMessage with converted content items and metadata - """ - contents_list: list[DurableAgentStateContent] = [ - DurableAgentStateContent.from_ai_content(c) for c in chat_message.contents - ] - - return DurableAgentStateMessage( - role=chat_message.role if hasattr(chat_message.role, "value") else str(chat_message.role), - contents=contents_list, - author_name=chat_message.author_name, - extension_data=dict(chat_message.additional_properties) if chat_message.additional_properties else None, - ) - - def to_chat_message(self) -> Any: - """Converts this DurableAgentStateMessage back to an agent framework Message. - - Returns: - Message object with role, contents, and metadata converted back to agent framework types - """ - # Convert DurableAgentStateContent objects back to agent_framework content objects - ai_contents = [c.to_ai_content() for c in self.contents] - - # Build kwargs for Message - kwargs: dict[str, Any] = { - "role": self.role, - "contents": ai_contents, - } - - if self.author_name is not None: - kwargs["author_name"] = self.author_name - - if self.extension_data is not None: - kwargs["additional_properties"] = self.extension_data - - return Message(**kwargs) - - -class DurableAgentStateDataContent(DurableAgentStateContent): - """Represents data content with a URI reference. - - This content type is used to reference data stored at a specific URI location, - optionally with a media type specification. Common use cases include referencing - files, documents, or other data resources. - - Attributes: - uri: URI pointing to the data resource - media_type: Optional MIME type of the data (e.g., "application/json", "text/plain") - """ - - uri: str = "" - media_type: str | None = None - type: str = ContentTypes.DATA - - def __init__(self, uri: str, media_type: str | None = None) -> None: - self.uri = uri - self.media_type = media_type - - def to_dict(self) -> dict[str, Any]: - return { - DurableStateFields.TYPE_DISCRIMINATOR: self.type, - DurableStateFields.URI: self.uri, - DurableStateFields.MEDIA_TYPE: self.media_type, - } - - @staticmethod - def from_data_content(content: Content) -> DurableAgentStateDataContent: - if content.uri is None: - raise ValueError("uri is required for data content") - return DurableAgentStateDataContent(uri=content.uri, media_type=content.media_type) - - def to_ai_content(self) -> Content: - return Content.from_uri(uri=self.uri, media_type=self.media_type) - - -class DurableAgentStateErrorContent(DurableAgentStateContent): - """Represents error content in agent responses. - - This content type is used to communicate errors that occurred during agent execution, - including error messages, error codes, and additional details for debugging. - - Attributes: - message: Human-readable error message - error_code: Machine-readable error code or exception type - details: Additional error details or stack trace information - """ - - message: str | None = None - error_code: str | None = None - details: str | None = None - - type: str = ContentTypes.ERROR - - def __init__(self, message: str | None = None, error_code: str | None = None, details: str | None = None) -> None: - self.message = message - self.error_code = error_code - self.details = details - - def to_dict(self) -> dict[str, Any]: - return { - DurableStateFields.TYPE_DISCRIMINATOR: self.type, - DurableStateFields.MESSAGE: self.message, - DurableStateFields.ERROR_CODE: self.error_code, - DurableStateFields.DETAILS: self.details, - } - - @staticmethod - def from_error_content(content: Content) -> DurableAgentStateErrorContent: - return DurableAgentStateErrorContent( - message=content.message, error_code=content.error_code, details=content.error_details - ) - - def to_ai_content(self) -> Content: - return Content.from_error(message=self.message, error_code=self.error_code, error_details=self.details) - - -class DurableAgentStateFunctionCallContent(DurableAgentStateContent): - """Represents a function/tool call request from the agent. - - This content type is used when the agent requests execution of a function or tool, - including the function name, arguments, and a unique call identifier for tracking - the call-result pair. - - Attributes: - call_id: Unique identifier for this function call (used to match with results) - name: Name of the function/tool to execute - arguments: Dictionary of argument names to values for the function call - """ - - call_id: str - name: str - arguments: dict[str, Any] - - type: str = ContentTypes.FUNCTION_CALL - - def __init__(self, call_id: str, name: str, arguments: dict[str, Any]) -> None: - self.call_id = call_id - self.name = name - self.arguments = arguments - - def to_dict(self) -> dict[str, Any]: - return { - DurableStateFields.TYPE_DISCRIMINATOR: self.type, - DurableStateFields.CALL_ID: self.call_id, - DurableStateFields.NAME: self.name, - DurableStateFields.ARGUMENTS: self.arguments, - } - - @staticmethod - def from_function_call_content(content: Content) -> DurableAgentStateFunctionCallContent: - if content.call_id is None: - raise ValueError("call_id is required for function call content") - if content.name is None: - raise ValueError("name is required for function call content") - # Ensure arguments is a dict; parse string if needed - arguments: dict[str, Any] = {} - if content.arguments: - if isinstance(content.arguments, dict): - arguments = content.arguments - elif isinstance(content.arguments, str): - # Parse JSON string to dict - try: - arguments = json.loads(content.arguments) - except json.JSONDecodeError: - arguments = {} - - return DurableAgentStateFunctionCallContent(call_id=content.call_id, name=content.name, arguments=arguments) - - def to_ai_content(self) -> Content: - return Content.from_function_call(call_id=self.call_id, name=self.name, arguments=json.dumps(self.arguments)) - - -class DurableAgentStateFunctionResultContent(DurableAgentStateContent): - """Represents the result of a function/tool call execution. - - This content type is used to communicate the result of executing a function or tool - that was previously requested by the agent. The call_id links this result back to - the original function call request. - - Attributes: - call_id: Unique identifier matching the original function call - result: The return value from the function execution (can be any serializable type) - """ - - call_id: str - result: object | None = None - - type: str = ContentTypes.FUNCTION_RESULT - - def __init__(self, call_id: str, result: Any | None = None) -> None: - self.call_id = call_id - self.result = result - - def to_dict(self) -> dict[str, Any]: - return { - DurableStateFields.TYPE_DISCRIMINATOR: self.type, - DurableStateFields.CALL_ID: self.call_id, - DurableStateFields.RESULT: self.result, - } - - @staticmethod - def from_function_result_content(content: Content) -> DurableAgentStateFunctionResultContent: - if content.call_id is None: - raise ValueError("call_id is required for function result content") - return DurableAgentStateFunctionResultContent(call_id=content.call_id, result=content.result) - - def to_ai_content(self) -> Content: - return Content.from_function_result(call_id=self.call_id, result=self.result) - - -class DurableAgentStateHostedFileContent(DurableAgentStateContent): - """Represents a reference to a hosted file resource. - - This content type is used to reference files that are hosted by the agent platform - or a file storage service, identified by a unique file ID. - - Attributes: - file_id: Unique identifier for the hosted file - """ - - file_id: str - - type: str = ContentTypes.HOSTED_FILE - - def __init__(self, file_id: str) -> None: - self.file_id = file_id - - def to_dict(self) -> dict[str, Any]: - return {DurableStateFields.TYPE_DISCRIMINATOR: self.type, DurableStateFields.FILE_ID: self.file_id} - - @staticmethod - def from_hosted_file_content(content: Content) -> DurableAgentStateHostedFileContent: - if content.file_id is None: - raise ValueError("file_id is required for hosted file content") - return DurableAgentStateHostedFileContent(file_id=content.file_id) - - def to_ai_content(self) -> Content: - return Content.from_hosted_file(file_id=self.file_id) - - -class DurableAgentStateHostedVectorStoreContent(DurableAgentStateContent): - """Represents a reference to a hosted vector store resource. - - This content type is used to reference vector stores (used for semantic search - and retrieval-augmented generation) that are hosted by the agent platform, - identified by a unique vector store ID. - - Attributes: - vector_store_id: Unique identifier for the hosted vector store - """ - - vector_store_id: str - - type: str = ContentTypes.HOSTED_VECTOR_STORE - - def __init__(self, vector_store_id: str) -> None: - self.vector_store_id = vector_store_id - - def to_dict(self) -> dict[str, Any]: - return { - DurableStateFields.TYPE_DISCRIMINATOR: self.type, - DurableStateFields.VECTOR_STORE_ID: self.vector_store_id, - } - - @staticmethod - def from_hosted_vector_store_content( - content: Content, - ) -> DurableAgentStateHostedVectorStoreContent: - if content.vector_store_id is None: - raise ValueError("vector_store_id is required for hosted vector store content") - return DurableAgentStateHostedVectorStoreContent(vector_store_id=content.vector_store_id) - - def to_ai_content(self) -> Content: - return Content.from_hosted_vector_store(vector_store_id=self.vector_store_id) - - -class DurableAgentStateTextContent(DurableAgentStateContent): - """Represents plain text content in messages. - - This is the most common content type, used for regular text messages from users - and text responses from the agent. - - Attributes: - text: The text content of the message - """ - - type: str = ContentTypes.TEXT - - def __init__(self, text: str | None) -> None: - self.text = text - - def to_dict(self) -> dict[str, Any]: - return {DurableStateFields.TYPE_DISCRIMINATOR: self.type, DurableStateFields.TEXT: self.text} - - @staticmethod - def from_text_content(content: Content) -> DurableAgentStateTextContent: - return DurableAgentStateTextContent(text=content.text) - - def to_ai_content(self) -> Content: - return Content.from_text(text=self.text or "") - - -class DurableAgentStateTextReasoningContent(DurableAgentStateContent): - """Represents reasoning or thought process text from the agent. - - This content type is used to capture the agent's internal reasoning, chain of thought, - or explanation of its decision-making process, separate from the final response text. - - Attributes: - text: The reasoning or thought process text - """ - - type: str = ContentTypes.REASONING - - def __init__(self, text: str | None) -> None: - self.text = text - - def to_dict(self) -> dict[str, Any]: - return {DurableStateFields.TYPE_DISCRIMINATOR: self.type, DurableStateFields.TEXT: self.text} - - @staticmethod - def from_text_reasoning_content(content: Content) -> DurableAgentStateTextReasoningContent: - return DurableAgentStateTextReasoningContent(text=content.text) - - def to_ai_content(self) -> Content: - return Content.from_text_reasoning(text=self.text) - - -class DurableAgentStateUriContent(DurableAgentStateContent): - """Represents content referenced by a URI with media type. - - This content type is used to reference external content via a URI, with an associated - media type to indicate how the content should be interpreted. - - Attributes: - uri: URI pointing to the content resource - media_type: MIME type of the content (e.g., "image/png", "application/pdf") - """ - - uri: str - media_type: str - - type: str = ContentTypes.URI - - def __init__(self, uri: str, media_type: str) -> None: - self.uri = uri - self.media_type = media_type - - def to_dict(self) -> dict[str, Any]: - return { - DurableStateFields.TYPE_DISCRIMINATOR: self.type, - DurableStateFields.URI: self.uri, - DurableStateFields.MEDIA_TYPE: self.media_type, - } - - @staticmethod - def from_uri_content(content: Content) -> DurableAgentStateUriContent: - if content.uri is None: - raise ValueError("uri is required for uri content") - if content.media_type is None: - raise ValueError("media_type is required for uri content") - return DurableAgentStateUriContent(uri=content.uri, media_type=content.media_type) - - def to_ai_content(self) -> Content: - return Content.from_uri(uri=self.uri, media_type=self.media_type) - - -class DurableAgentStateUsage: - """Represents token usage statistics for agent responses. - - This class tracks the number of tokens consumed during agent execution, - including input tokens (from the request), output tokens (in the response), - and the total token count. - - Attributes: - input_token_count: Number of tokens in the input/request - output_token_count: Number of tokens in the output/response - total_token_count: Total number of tokens consumed (input + output) - extensionData: Optional additional metadata - """ - - # UsageDetails field name constants (snake_case keys from agent_framework.UsageDetails) - _INPUT_TOKEN_COUNT = "input_token_count" # ruff:ignore[hardcoded-password-string] # nosec B105 - _OUTPUT_TOKEN_COUNT = "output_token_count" # ruff:ignore[hardcoded-password-string] # nosec B105 - _TOTAL_TOKEN_COUNT = "total_token_count" # ruff:ignore[hardcoded-password-string] # nosec B105 - - # Standard fields in UsageDetails that are mapped to dedicated attributes - _STANDARD_USAGE_FIELDS: ClassVar[set[str]] = {_INPUT_TOKEN_COUNT, _OUTPUT_TOKEN_COUNT, _TOTAL_TOKEN_COUNT} - - input_token_count: int | None = None - output_token_count: int | None = None - total_token_count: int | None = None - extensionData: dict[str, Any] | None = None - - def __init__( - self, - input_token_count: int | None = None, - output_token_count: int | None = None, - total_token_count: int | None = None, - extensionData: dict[str, Any] | None = None, - ) -> None: - self.input_token_count = input_token_count - self.output_token_count = output_token_count - self.total_token_count = total_token_count - self.extensionData = extensionData - - def to_dict(self) -> dict[str, Any]: - result: dict[str, Any] = { - DurableStateFields.INPUT_TOKEN_COUNT: self.input_token_count, - DurableStateFields.OUTPUT_TOKEN_COUNT: self.output_token_count, - DurableStateFields.TOTAL_TOKEN_COUNT: self.total_token_count, - } - if self.extensionData is not None: - result[DurableStateFields.EXTENSION_DATA] = self.extensionData - return result - - @classmethod - def from_dict(cls, data: dict[str, Any]) -> DurableAgentStateUsage: - return cls( - input_token_count=data.get(DurableStateFields.INPUT_TOKEN_COUNT), - output_token_count=data.get(DurableStateFields.OUTPUT_TOKEN_COUNT), - total_token_count=data.get(DurableStateFields.TOTAL_TOKEN_COUNT), - extensionData=data.get(DurableStateFields.EXTENSION_DATA), - ) - - @staticmethod - def from_usage(usage: UsageDetails | MutableMapping[str, Any] | None) -> DurableAgentStateUsage | None: - if usage is None: - return None - - # Collect all non-standard fields into extension_data - extension_data: dict[str, Any] = { - k: v for k, v in usage.items() if k not in DurableAgentStateUsage._STANDARD_USAGE_FIELDS - } - - return DurableAgentStateUsage( - input_token_count=cast("int | None", usage.get(DurableAgentStateUsage._INPUT_TOKEN_COUNT)), - output_token_count=cast("int | None", usage.get(DurableAgentStateUsage._OUTPUT_TOKEN_COUNT)), - total_token_count=cast("int | None", usage.get(DurableAgentStateUsage._TOTAL_TOKEN_COUNT)), - extensionData=extension_data if extension_data else None, - ) - - def to_usage_details(self) -> UsageDetails: - # Convert back to AI SDK UsageDetails - result = UsageDetails( - input_token_count=self.input_token_count, - output_token_count=self.output_token_count, - total_token_count=self.total_token_count, - ) - if self.extensionData: - result.update(self.extensionData) # type: ignore[typeddict-item] - return result - - -class DurableAgentStateUsageContent(DurableAgentStateContent): - """Represents token usage information as message content. - - This content type is used to communicate token usage statistics as part of - message content, allowing usage information to be tracked alongside other - content types in the conversation history. - - Attributes: - usage: DurableAgentStateUsage object containing token counts - """ - - usage: DurableAgentStateUsage = DurableAgentStateUsage() - - type: str = ContentTypes.USAGE - - def __init__(self, usage: DurableAgentStateUsage | None) -> None: - self.usage = usage if usage is not None else DurableAgentStateUsage() - - def to_dict(self) -> dict[str, Any]: - return { - DurableStateFields.TYPE_DISCRIMINATOR: self.type, - DurableStateFields.USAGE: self.usage.to_dict(), - } - - @staticmethod - def from_usage_content(content: Content) -> DurableAgentStateUsageContent: - return DurableAgentStateUsageContent(usage=DurableAgentStateUsage.from_usage(content.usage_details)) - - def to_ai_content(self) -> Content: - return Content.from_usage(usage_details=self.usage.to_usage_details()) - - -class DurableAgentStateUnknownContent(DurableAgentStateContent): - """Represents unknown or unrecognized content types. - - This content type serves as a fallback for content that doesn't match any of the - known content type classes. It preserves the original content object for later - inspection or processing. - - Attributes: - content: The unknown content object - """ - - content: Any - - type: str = ContentTypes.UNKNOWN - - def __init__(self, content: Any) -> None: - self.content = content - - def to_dict(self) -> dict[str, Any]: - return {DurableStateFields.TYPE_DISCRIMINATOR: self.type, DurableStateFields.CONTENT: self.content} - - @staticmethod - def from_unknown_content(content: Any) -> DurableAgentStateUnknownContent: - if isinstance(content, Content): - return DurableAgentStateUnknownContent(content=content.to_dict()) - return DurableAgentStateUnknownContent(content=content) - - def to_ai_content(self) -> Content: - if not self.content: - raise Exception("The content is missing and cannot be converted to valid AI content.") - content_value: Any = self.content - if isinstance(content_value, dict) and "type" in content_value: - try: - return Content.from_dict(cast(dict[str, Any], content_value)) - except (ValueError, TypeError): - pass - return Content(type=self.type, additional_properties={"content": self.content}) # type: ignore diff --git a/python/packages/durabletask/agent_framework_durabletask/_entities.py b/python/packages/durabletask/agent_framework_durabletask/_entities.py deleted file mode 100644 index b86f2594145..00000000000 --- a/python/packages/durabletask/agent_framework_durabletask/_entities.py +++ /dev/null @@ -1,353 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Durable Task entity implementations for Microsoft Agent Framework.""" - -from __future__ import annotations - -import inspect -import logging -from datetime import datetime, timezone -from typing import Any, cast - -from agent_framework import ( - AgentResponse, - AgentResponseUpdate, - Content, - Message, - ResponseStream, - SupportsAgentRun, -) -from durabletask.entities import DurableEntity - -from ._callbacks import AgentCallbackContext, AgentResponseCallbackProtocol -from ._durable_agent_state import ( - DurableAgentState, - DurableAgentStateEntry, - DurableAgentStateMessage, - DurableAgentStateRequest, - DurableAgentStateResponse, -) -from ._models import RunRequest - -logger = logging.getLogger("agent_framework.durabletask") - - -class AgentEntityStateProviderMixin: - """Mixin implementing durable agent state caching + (de)serialization + persistence. - - Concrete classes must implement: - - _get_state_dict(): fetch raw persisted state dict (default should be {}) - - _set_state_dict(): persist raw state dict - - _get_thread_id_from_entity(): fetch the thread ID from the underlying context - """ - - _state_cache: DurableAgentState | None = None - - def _get_state_dict(self) -> dict[str, Any]: - raise NotImplementedError - - def _set_state_dict(self, state: dict[str, Any]) -> None: - raise NotImplementedError - - def _get_thread_id_from_entity(self) -> str: - raise NotImplementedError - - @property - def thread_id(self) -> str: - return self._get_thread_id_from_entity() - - @property - def state(self) -> DurableAgentState: - if self._state_cache is None: - raw_state = self._get_state_dict() - self._state_cache = DurableAgentState.from_dict(raw_state) if raw_state else DurableAgentState() - return self._state_cache - - @state.setter - def state(self, value: DurableAgentState) -> None: - self._state_cache = value - self.persist_state() - - def persist_state(self) -> None: - """Persist the current state to the underlying storage provider.""" - if self._state_cache is None: - self._state_cache = DurableAgentState() - self._set_state_dict(self._state_cache.to_dict()) - - def reset(self) -> None: - """Clear conversation history by resetting state to a fresh DurableAgentState.""" - self._state_cache = DurableAgentState() - self.persist_state() - logger.debug("[AgentEntityStateProviderMixin.reset] State reset complete") - - -class AgentEntity: - """Platform-agnostic agent execution logic. - - This class encapsulates the core logic for executing an agent within a durable entity context. - """ - - agent: SupportsAgentRun - callback: AgentResponseCallbackProtocol | None - - def __init__( - self, - agent: SupportsAgentRun, - callback: AgentResponseCallbackProtocol | None = None, - *, - state_provider: AgentEntityStateProviderMixin, - ) -> None: - self.agent = agent - self.callback = callback - self._state_provider = state_provider - - logger.debug("[AgentEntity] Initialized with agent type: %s", type(agent).__name__) - - @property - def state(self) -> DurableAgentState: - return self._state_provider.state - - @state.setter - def state(self, value: DurableAgentState) -> None: - self._state_provider.state = value - - def persist_state(self) -> None: - self._state_provider.persist_state() - - def reset(self) -> None: - self._state_provider.reset() - - def _is_error_response(self, entry: DurableAgentStateEntry) -> bool: - """Check if a conversation history entry is an error response.""" - if isinstance(entry, DurableAgentStateResponse): - return entry.is_error - return False - - async def run( - self, - request: RunRequest | dict[str, Any] | str, - ) -> AgentResponse: - """Execute the agent with a message.""" - if isinstance(request, str): - run_request = RunRequest.from_json(request) - elif isinstance(request, dict): - run_request = RunRequest.from_dict(request) - else: - run_request = request - - message = run_request.message - thread_id = self._state_provider.thread_id - correlation_id = run_request.correlation_id - if not thread_id: - raise ValueError("Entity State Provider must provide a thread_id") - options: dict[str, Any] = dict(run_request.options) - options.setdefault("response_format", run_request.response_format) - if not run_request.enable_tool_calls: - options.setdefault("tools", None) - - logger.debug("[AgentEntity.run] Received ThreadId %s Message: %s", thread_id, run_request) - - state_request = DurableAgentStateRequest.from_run_request(run_request) - self.state.data.conversation_history.append(state_request) - - try: - chat_messages: list[Message] = [ - replayable_message - for entry in self.state.data.conversation_history - if not self._is_error_response(entry) - for m in entry.messages - if (replayable_message := self._to_replayable_message(m)) is not None - ] - - run_kwargs: dict[str, Any] = {"messages": chat_messages, "options": options} - - agent_run_response: AgentResponse = await self._invoke_agent( - run_kwargs=run_kwargs, - correlation_id=correlation_id, - thread_id=thread_id, - request_message=message, - ) - - state_response = DurableAgentStateResponse.from_run_response(correlation_id, agent_run_response) - self.state.data.conversation_history.append(state_response) - self.persist_state() - - return agent_run_response - - except Exception as exc: - logger.exception("[AgentEntity.run] Agent execution failed.") - - error_message = Message( - role="assistant", contents=[Content.from_error(message=str(exc), error_code=type(exc).__name__)] - ) - error_response = AgentResponse( - messages=[error_message], - created_at=datetime.now(tz=timezone.utc).isoformat(), - ) - - error_state_response = DurableAgentStateResponse.from_run_response(correlation_id, error_response) - error_state_response.is_error = True - self.state.data.conversation_history.append(error_state_response) - self.persist_state() - - return error_response - - @staticmethod - def _to_replayable_message(message: DurableAgentStateMessage) -> Message | None: - """Convert persisted history into a message safe to replay into chat clients.""" - chat_message = message.to_chat_message() - replayable_contents = [content for content in chat_message.contents if content.type != "reasoning"] - if not replayable_contents: - return None - - return Message( - role=chat_message.role, - contents=replayable_contents, - author_name=chat_message.author_name, - additional_properties=chat_message.additional_properties, - ) - - async def _invoke_agent( - self, - run_kwargs: dict[str, Any], - correlation_id: str, - thread_id: str, - request_message: str, - ) -> AgentResponse: - """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_callable = self.agent.run - - # Try streaming first with run(stream=True) - try: - stream_candidate = run_callable(stream=True, **run_kwargs) - if inspect.isawaitable(stream_candidate): - stream_candidate = await stream_candidate - - return await self._consume_stream( - stream=stream_candidate, - callback_context=callback_context, - ) - except TypeError as type_error: - if "__aiter__" not in str(type_error) and "stream" not in str(type_error): - raise - logger.debug( - "run(stream=True) returned a non-async result; falling back to run(): %s", - type_error, - ) - except Exception as stream_error: - logger.warning( - "run(stream=True) failed; falling back to run(): %s", - stream_error, - exc_info=True, - ) - agent_run_response = run_callable(**run_kwargs) - if inspect.isawaitable(agent_run_response): - agent_run_response = await agent_run_response - - if not isinstance(agent_run_response, AgentResponse): - raise TypeError( - f"Agent run() must return an AgentResponse instance; received {type(agent_run_response).__name__}" - ) - await self._notify_final_response(agent_run_response, callback_context) - return agent_run_response - - async def _consume_stream( - self, - stream: ResponseStream[AgentResponseUpdate, AgentResponse], - callback_context: AgentCallbackContext | None = None, - ) -> AgentResponse: - """Consume streaming responses and build the final AgentResponse.""" - updates: list[AgentResponseUpdate] = [] - - async for update in stream: - updates.append(update) - await self._notify_stream_update(update, callback_context) - - response = await stream.get_final_response() - - await self._notify_final_response(response, callback_context) - return response - - async def _notify_stream_update( - self, - update: AgentResponseUpdate, - 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: AgentResponse, - 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, - ) - - -class DurableTaskEntityStateProvider(DurableEntity, AgentEntityStateProviderMixin): - """DurableTask Durable Entity state provider for AgentEntity. - - This class utilizes the Durable Entity context from `durabletask` package - to get and set the state of the agent entity. - """ - - def __init__(self) -> None: - super().__init__() - - def _get_state_dict(self) -> dict[str, Any]: - raw = self.get_state(dict, default={}) - return cast(dict[str, Any], raw) - - def _set_state_dict(self, state: dict[str, Any]) -> None: - self.set_state(state) - - def _get_thread_id_from_entity(self) -> str: - return self.entity_context.entity_id.key diff --git a/python/packages/durabletask/agent_framework_durabletask/_executors.py b/python/packages/durabletask/agent_framework_durabletask/_executors.py deleted file mode 100644 index eea17effbb2..00000000000 --- a/python/packages/durabletask/agent_framework_durabletask/_executors.py +++ /dev/null @@ -1,527 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Provider strategies for Durable Agent execution. - -These classes are internal execution strategies used by the DurableAIAgent shim. -They are intentionally separate from the public client/orchestration APIs to keep -only `get_agent` exposed to consumers. Executors implement the execution contract -and are injected into the shim. -""" - -from __future__ import annotations - -import logging -import time -import uuid -from abc import ABC, abstractmethod -from datetime import datetime, timezone -from typing import Any, Generic, TypeVar - -from agent_framework import AgentResponse, AgentSession, Content, Message -from durabletask.client import TaskHubGrpcClient -from durabletask.entities import EntityInstanceId -from durabletask.task import CompletableTask, CompositeTask, OrchestrationContext, Task -from pydantic import BaseModel - -from ._constants import DEFAULT_MAX_POLL_RETRIES, DEFAULT_POLL_INTERVAL_SECONDS -from ._durable_agent_state import DurableAgentState -from ._models import AgentSessionId, DurableAgentSession, RunRequest -from ._response_utils import ensure_response_format, load_agent_response - -logger = logging.getLogger("agent_framework.durabletask") - -# TypeVar for the task type returned by executors -TaskT = TypeVar("TaskT") - - -class DurableAgentTask(CompositeTask[AgentResponse], CompletableTask[AgentResponse]): - """A custom Task that wraps entity calls and provides typed AgentResponse results. - - This task wraps the underlying entity call task and intercepts its completion - to convert the raw result into a typed AgentResponse object. - - When yielded in an orchestration, this task returns an AgentResponse: - response: AgentResponse = yield durable_agent_task - """ - - def __init__( - self, - entity_task: CompletableTask[Any], - response_format: type[BaseModel] | None, - correlation_id: str, - ): - """Initialize the DurableAgentTask. - - Args: - entity_task: The underlying entity call task - response_format: Optional Pydantic model for response parsing - correlation_id: Correlation ID for logging - """ - self._response_format = response_format - self._correlation_id = correlation_id - super().__init__([entity_task]) - - def on_child_completed(self, task: Task[Any]) -> None: - """Handle completion of the underlying entity task. - - Parameters - ---------- - task : Task - The entity call task that just completed - """ - if self.is_complete: - return - - if task.is_failed: - # Propagate the failure - pass the original exception directly - self.fail("call_entity Task failed", task.get_exception()) - return - - # Task succeeded - transform the raw result - raw_result = task.get_result() - logger.debug( - "[DurableAgentTask] Converting raw result for correlation_id %s", - self._correlation_id, - ) - - try: - response = load_agent_response(raw_result) - - if self._response_format is not None: - ensure_response_format( - self._response_format, - self._correlation_id, - response, - ) - - # Set the typed AgentResponse as this task's result - self.complete(response) - - except Exception as ex: - err_msg = "[DurableAgentTask] Failed to convert result for correlation_id: " + self._correlation_id - logger.exception(err_msg) - self.fail(err_msg, ex) - - -class DurableAgentExecutor(ABC, Generic[TaskT]): - """Abstract base class for durable agent execution strategies. - - Type Parameters: - TaskT: The task type returned by this executor - """ - - @abstractmethod - def run_durable_agent( - self, - agent_name: str, - run_request: RunRequest, - session: AgentSession | None = None, - ) -> TaskT: - """Execute the durable agent. - - Returns: - TaskT: The task type specific to this executor implementation - """ - raise NotImplementedError - - def get_new_session( - self, - agent_name: str, - *, - session_id: str | None = None, - service_session_id: str | None = None, - ) -> DurableAgentSession: - """Create a new DurableAgentSession with random session ID.""" - durable_session_id = self._create_session_id(agent_name) - return DurableAgentSession( - durable_session_id=durable_session_id, - session_id=session_id, - service_session_id=service_session_id, - ) - - def _create_session_id( - self, - agent_name: str, - session: AgentSession | None = None, - ) -> AgentSessionId: - """Create the AgentSessionId for the execution.""" - if isinstance(session, DurableAgentSession) and session.durable_session_id is not None: - return session.durable_session_id - # Create new session ID - either no session provided or it's a regular AgentSession - key = self.generate_unique_id() - return AgentSessionId(name=agent_name, key=key) - - def generate_unique_id(self) -> str: - """Generate a new Unique ID.""" - return uuid.uuid4().hex - - def get_run_request( - self, - message: str, - *, - options: dict[str, Any] | None = None, - ) -> RunRequest: - """Create a RunRequest from message and options.""" - correlation_id = self.generate_unique_id() - - # Create a copy to avoid modifying the caller's dict - opts = dict(options) if options else {} - - # Extract and REMOVE known keys from options copy - response_format = opts.pop("response_format", None) - enable_tool_calls = opts.pop("enable_tool_calls", True) - wait_for_response = opts.pop("wait_for_response", True) - - return RunRequest( - message=message, - response_format=response_format, - enable_tool_calls=enable_tool_calls, - wait_for_response=wait_for_response, - correlation_id=correlation_id, - options=opts, - ) - - def _create_acceptance_response(self, correlation_id: str) -> AgentResponse: - """Create an acceptance response for fire-and-forget mode. - - Args: - correlation_id: Correlation ID for tracking the request - - Returns: - AgentResponse: Acceptance response with correlation ID - """ - acceptance_message = Message( - role="system", - contents=[ - Content.from_text( - f"Request accepted for processing (correlation_id: {correlation_id}). " - f"Agent is executing in the background. " - f"Retrieve response via your configured streaming or callback mechanism." - ) - ], - ) - return AgentResponse( - messages=[acceptance_message], - created_at=datetime.now(timezone.utc).isoformat(), - ) - - -class ClientAgentExecutor(DurableAgentExecutor[AgentResponse]): - """Execution strategy for external clients. - - Note: Returns AgentResponse directly since the execution - is blocking until response is available via polling - as per the design of TaskHubGrpcClient. - """ - - def __init__( - self, - client: TaskHubGrpcClient, - max_poll_retries: int = DEFAULT_MAX_POLL_RETRIES, - poll_interval_seconds: float = DEFAULT_POLL_INTERVAL_SECONDS, - ): - self._client = client - self.max_poll_retries = max_poll_retries - self.poll_interval_seconds = poll_interval_seconds - - def run_durable_agent( - self, - agent_name: str, - run_request: RunRequest, - session: AgentSession | None = None, - ) -> AgentResponse: - """Execute the agent via the durabletask client. - - Signals the agent entity with a message request, then polls the entity - state to retrieve the response once processing is complete. - - Note: This is a blocking/synchronous operation (in line with how - TaskHubGrpcClient works) that polls until a response is available or - timeout occurs. - - Args: - agent_name: Name of the agent to execute - run_request: The run request containing message and optional response format - session: Optional conversation session (creates new if not provided) - - Returns: - AgentResponse: The agent's response after execution completes, or an immediate - acknowledgement if wait_for_response is False - """ - # Signal the entity with the request - entity_id = self._signal_agent_entity(agent_name, run_request, session) - - # If fire-and-forget mode, return immediately without polling - if not run_request.wait_for_response: - logger.info( - "[ClientAgentExecutor] Fire-and-forget mode: request signaled (correlation: %s)", - run_request.correlation_id, - ) - return self._create_acceptance_response(run_request.correlation_id) - - # Poll for the response - agent_response = self._poll_for_agent_response(entity_id, run_request.correlation_id) - - # Handle and return the result - return self._handle_agent_response(agent_response, run_request.response_format, run_request.correlation_id) - - def _signal_agent_entity( - self, - agent_name: str, - run_request: RunRequest, - session: AgentSession | None, - ) -> EntityInstanceId: - """Signal the agent entity with a run request. - - Args: - agent_name: Name of the agent to execute - run_request: The run request containing message and optional response format - session: Optional conversation session - - Returns: - entity_id - """ - # Get or create session ID - session_id = self._create_session_id(agent_name, session) - - # Create the entity ID - entity_id = EntityInstanceId( - entity=session_id.entity_name, - key=session_id.key, - ) - - logger.debug( - "[ClientAgentExecutor] Signaling entity '%s' (session: %s, correlation: %s)", - agent_name, - session_id, - run_request.correlation_id, - ) - - self._client.signal_entity(entity_id, "run", run_request.to_dict()) - return entity_id - - def _poll_for_agent_response( - self, - entity_id: EntityInstanceId, - correlation_id: str, - ) -> AgentResponse | None: - """Poll the entity for a response with retries. - - Args: - entity_id: Entity instance identifier - correlation_id: Correlation ID to track the request - - Returns: - The agent response if found, None if timeout occurs - """ - agent_response = None - - for attempt in range(1, self.max_poll_retries + 1): - # Initial sleep is intentional - give the entity time to process before first poll - time.sleep(self.poll_interval_seconds) - - agent_response = self._poll_entity_for_response(entity_id, correlation_id) - if agent_response is not None: - logger.info( - "[ClientAgentExecutor] Found response (attempt %d/%d, correlation: %s)", - attempt, - self.max_poll_retries, - correlation_id, - ) - break - - logger.debug( - "[ClientAgentExecutor] Response not ready (attempt %d/%d)", - attempt, - self.max_poll_retries, - ) - - return agent_response - - def _handle_agent_response( - self, - agent_response: AgentResponse | None, - response_format: type[BaseModel] | None, - correlation_id: str, - ) -> AgentResponse: - """Handle the agent response or create an error response. - - Args: - agent_response: The response from polling, or None if timeout - response_format: Optional response format for validation - correlation_id: Correlation ID for logging - - Returns: - AgentResponse with either the agent's response or an error message - """ - if agent_response is not None: - try: - # Validate response format if specified - if response_format is not None: - ensure_response_format( - response_format, - correlation_id, - agent_response, - ) - - return agent_response - - except Exception as e: - logger.exception( - "[ClientAgentExecutor] Error converting response for correlation: %s", - correlation_id, - ) - error_message = Message( - role="system", - contents=[ - Content.from_error( - message=f"Error processing agent response: {e}", - error_code="response_processing_error", - ) - ], - ) - else: - logger.warning( - "[ClientAgentExecutor] Timeout after %d attempts (correlation: %s)", - self.max_poll_retries, - correlation_id, - ) - error_message = Message( - role="system", - contents=[ - Content.from_error( - message=f"Timeout waiting for agent response after {self.max_poll_retries} attempts", - error_code="response_timeout", - ) - ], - ) - - return AgentResponse( - messages=[error_message], - created_at=datetime.now(timezone.utc).isoformat(), - ) - - def _poll_entity_for_response( - self, - entity_id: EntityInstanceId, - correlation_id: str, - ) -> AgentResponse | None: - """Poll the entity state for a response matching the correlation ID. - - Args: - entity_id: Entity instance identifier - correlation_id: Correlation ID to search for - - Returns: - Response AgentResponse, None otherwise - """ - try: - entity_metadata = self._client.get_entity(entity_id, include_state=True) - - if entity_metadata is None: - return None - - state_json = entity_metadata.get_state() - if not state_json: - return None - - state = DurableAgentState.from_json(state_json) - - # Use the helper method to get response by correlation ID - return state.try_get_agent_response(correlation_id) - - except Exception as e: - logger.warning( - "[ClientAgentExecutor] Error reading entity state: %s", - e, - ) - return None - - -class OrchestrationAgentExecutor(DurableAgentExecutor[DurableAgentTask]): - """Execution strategy for orchestrations (sync/yield).""" - - def __init__(self, context: OrchestrationContext): - self._context = context - logger.debug("[OrchestrationAgentExecutor] Initialized") - - def generate_unique_id(self) -> str: - """Create a new UUID that is safe for replay within an orchestration or operation.""" - return self._context.new_uuid() - - def get_run_request( - self, - message: str, - *, - options: dict[str, Any] | None = None, - ) -> RunRequest: - """Get the current run request from the orchestration context. - - Returns: - RunRequest: The current run request - """ - request = super().get_run_request( - message, - options=options, - ) - request.orchestration_id = self._context.instance_id - return request - - def run_durable_agent( - self, - agent_name: str, - run_request: RunRequest, - session: AgentSession | None = None, - ) -> DurableAgentTask: - """Execute the agent via orchestration context. - - Calls the agent entity and returns a DurableAgentTask that can be yielded - in orchestrations to wait for the entity's response. - - Args: - agent_name: Name of the agent to execute - run_request: The run request containing message and optional response format - session: Optional conversation session (creates new if not provided) - - Returns: - DurableAgentTask: A task wrapping the entity call that yields AgentResponse - """ - # Resolve session - session_id = self._create_session_id(agent_name, session) - - # Create the entity ID - entity_id = EntityInstanceId( - entity=session_id.entity_name, - key=session_id.key, - ) - - logger.debug( - "[OrchestrationAgentExecutor] correlation_id: %s entity_id: %s session_id: %s", - run_request.correlation_id, - entity_id, - session_id, - ) - - # Branch based on wait_for_response - if not run_request.wait_for_response: - # Fire-and-forget mode: signal entity and return pre-completed task - logger.info( - "[OrchestrationAgentExecutor] Fire-and-forget mode: signaling entity (correlation: %s)", - run_request.correlation_id, - ) - self._context.signal_entity(entity_id, "run", run_request.to_dict()) - - # Create a pre-completed task with acceptance response - acceptance_response = self._create_acceptance_response(run_request.correlation_id) - entity_task: CompletableTask[AgentResponse] = CompletableTask() - entity_task.complete(acceptance_response) - else: - # Blocking mode: call entity and wait for response - entity_task = self._context.call_entity(entity_id, "run", run_request.to_dict()) - - # Wrap in DurableAgentTask for response transformation - return DurableAgentTask( - entity_task=entity_task, - response_format=run_request.response_format, - correlation_id=run_request.correlation_id, - ) diff --git a/python/packages/durabletask/agent_framework_durabletask/_feature_usage.py b/python/packages/durabletask/agent_framework_durabletask/_feature_usage.py deleted file mode 100644 index fba5fc35cc0..00000000000 --- a/python/packages/durabletask/agent_framework_durabletask/_feature_usage.py +++ /dev/null @@ -1,9 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -from enum import IntEnum - - -class FeatureIndex(IntEnum): - """Durable Task-owned feature-usage indexes.""" - - DURABLETASK = 77 diff --git a/python/packages/durabletask/agent_framework_durabletask/_models.py b/python/packages/durabletask/agent_framework_durabletask/_models.py deleted file mode 100644 index e8eabca0788..00000000000 --- a/python/packages/durabletask/agent_framework_durabletask/_models.py +++ /dev/null @@ -1,334 +0,0 @@ -# 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 json -import uuid -from dataclasses import dataclass, field -from datetime import datetime, timezone -from importlib import import_module -from typing import TYPE_CHECKING, Any, cast - -from agent_framework import AgentSession - -from ._constants import REQUEST_RESPONSE_FORMAT_TEXT - -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 - - -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 - request_response_format: The desired response format (e.g., "text" or "json") - 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 - wait_for_response: If True (default), caller will wait for agent response. If False, - returns immediately after signaling (fire-and-forget mode) - correlation_id: Correlation ID for tracking the response to this specific request - created_at: Optional timestamp when the request was created - orchestration_id: Optional ID of the orchestration that initiated this request - options: Optional options dictionary forwarded to the agent - """ - - message: str - request_response_format: str - correlation_id: str - role: str = "user" - response_format: type[BaseModel] | None = None - enable_tool_calls: bool = True - wait_for_response: bool = True - created_at: datetime | None = None - orchestration_id: str | None = None - options: dict[str, Any] = field(default_factory=lambda: {}) - - def __init__( - self, - message: str, - correlation_id: str, - request_response_format: str = REQUEST_RESPONSE_FORMAT_TEXT, - role: str | None = "user", - response_format: type[BaseModel] | None = None, - enable_tool_calls: bool = True, - wait_for_response: bool = True, - created_at: datetime | None = None, - orchestration_id: str | None = None, - options: dict[str, Any] | None = None, - ) -> None: - self.message = message - self.correlation_id = correlation_id - self.role = self.coerce_role(role) - self.response_format = response_format - self.request_response_format = request_response_format - self.enable_tool_calls = enable_tool_calls - self.wait_for_response = wait_for_response - self.created_at = created_at if created_at is not None else datetime.now(tz=timezone.utc) - self.orchestration_id = orchestration_id - self.options = options if options is not None else {} - - @staticmethod - def coerce_role(value: str | None) -> str: - """Normalize various role representations into a role string.""" - if isinstance(value, str): - normalized = value.strip() - if not normalized: - return "user" - return normalized.lower() - return "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, - "wait_for_response": self.wait_for_response, - "role": self.role, - "request_response_format": self.request_response_format, - "correlationId": self.correlation_id, - "options": self.options, - } - if self.response_format: - result["response_format"] = serialize_response_format(self.response_format) - if self.created_at: - result["created_at"] = self.created_at.isoformat() - if self.orchestration_id: - result["orchestrationId"] = self.orchestration_id - return result - - @classmethod - def from_json(cls, data: str) -> RunRequest: - """Create RunRequest from JSON string.""" - try: - dict_data = json.loads(data) - except json.JSONDecodeError as e: - raise ValueError("The durable agent state is not valid JSON.") from e - - return cls.from_dict(dict_data) - - @classmethod - def from_dict(cls, data: dict[str, Any]) -> RunRequest: - """Create RunRequest from dictionary.""" - created_at = data.get("created_at") - if isinstance(created_at, str): - try: - created_at = datetime.fromisoformat(created_at) - except ValueError: - created_at = None - - correlation_id = data.get("correlationId") - if not correlation_id: - raise ValueError("correlationId is required in RunRequest data") - - options = data.get("options") - - return cls( - message=data.get("message", ""), - correlation_id=correlation_id, - request_response_format=data.get("request_response_format", REQUEST_RESPONSE_FORMAT_TEXT), - role=cls.coerce_role(data.get("role")), - response_format=_deserialize_response_format(data.get("response_format")), - wait_for_response=data.get("wait_for_response", True), - enable_tool_calls=data.get("enable_tool_calls", True), - created_at=created_at, - orchestration_id=data.get("orchestrationId"), - options=cast(dict[str, Any], options) if isinstance(options, dict) else {}, - ) - - -@dataclass -class AgentSessionId: - """Represents an agent session identifier (name + key).""" - - name: str - key: str - - ENTITY_NAME_PREFIX: str = "dafx-" - - @staticmethod - def to_entity_name(name: str) -> str: - return f"{AgentSessionId.ENTITY_NAME_PREFIX}{name}" - - @staticmethod - def with_random_key(name: str) -> AgentSessionId: - return AgentSessionId(name=name, key=uuid.uuid4().hex) - - @property - def entity_name(self) -> str: - return self.to_entity_name(self.name) - - def __str__(self) -> str: - return f"@{self.name}@{self.key}" - - def __repr__(self) -> str: - return f"AgentSessionId(name='{self.name}', key='{self.key}')" - - @staticmethod - def parse(session_id_string: str, agent_name: str | None = None) -> AgentSessionId: - """Parses a string representation of an agent session ID. - - Args: - session_id_string: A string in the form @name@key, or a plain key string - when agent_name is provided. - agent_name: Optional agent name to use instead of parsing from the string. - If provided, only the key portion is extracted from session_id_string - (for @name@key format) or the entire string is used as the key - (for plain strings). - - Returns: - AgentSessionId instance - - Raises: - ValueError: If the string format is invalid and agent_name is not provided - """ - # Check if string is in @name@key format - if session_id_string.startswith("@") and "@" in session_id_string[1:]: - parts = session_id_string[1:].split("@", 1) - name = agent_name if agent_name is not None else parts[0] - return AgentSessionId(name=name, key=parts[1]) - - # Plain string format - only valid when agent_name is provided - if agent_name is not None: - return AgentSessionId(name=agent_name, key=session_id_string) - - raise ValueError(f"Invalid agent session ID format: {session_id_string}") - - -class DurableAgentSession(AgentSession): - """Durable agent session that tracks the owning :class:`AgentSessionId`.""" - - _SERIALIZED_SESSION_ID_KEY = "durable_session_id" - - def __init__( - self, - *, - durable_session_id: AgentSessionId | None = None, - session_id: str | None = None, - service_session_id: str | None = None, - ) -> None: - super().__init__(session_id=session_id, service_session_id=service_session_id) - self.durable_session_id: AgentSessionId | None = durable_session_id - - def to_dict(self) -> dict[str, Any]: - state = super().to_dict() - if self.durable_session_id is not None: - state[self._SERIALIZED_SESSION_ID_KEY] = str(self.durable_session_id) - return state - - @classmethod - def from_session_id( - cls, - durable_session_id: AgentSessionId, - *, - session_id: str | None = None, - service_session_id: str | None = None, - ) -> DurableAgentSession: - """Create a DurableAgentSession from an AgentSessionId.""" - return cls( - durable_session_id=durable_session_id, - session_id=session_id, - service_session_id=service_session_id, - ) - - @classmethod - def from_dict(cls, data: dict[str, Any]) -> DurableAgentSession: - """Create a DurableAgentSession from a state dict.""" - data = dict(data) # defensive copy — avoid mutating caller's dict - session_id_value = data.pop(cls._SERIALIZED_SESSION_ID_KEY, None) - session = super().from_dict(data) - service_session_id = session.service_session_id - if service_session_id is not None and not isinstance(service_session_id, str): - raise ValueError("durable sessions require service_session_id to be a string when present") - durable_session_id: AgentSessionId | None = None - # We need to create a DurableAgentSession from the base AgentSession - if session_id_value is not None: - if not isinstance(session_id_value, str): - raise ValueError("durable_session_id must be a string when present in serialized state") - durable_session_id = AgentSessionId.parse(session_id_value) - - durable_session = cls( - durable_session_id=durable_session_id, - session_id=session.session_id, - service_session_id=service_session_id, - ) - durable_session.state.update(session.state) - return durable_session diff --git a/python/packages/durabletask/agent_framework_durabletask/_orchestration_context.py b/python/packages/durabletask/agent_framework_durabletask/_orchestration_context.py deleted file mode 100644 index d5bd648482a..00000000000 --- a/python/packages/durabletask/agent_framework_durabletask/_orchestration_context.py +++ /dev/null @@ -1,76 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Orchestration context wrapper for Durable Task Agent Framework. - -This module provides the DurableAIAgentOrchestrationContext class for use inside -orchestration functions to interact with durable agents. -""" - -from __future__ import annotations - -import logging - -from durabletask.task import OrchestrationContext - -from ._executors import DurableAgentTask, OrchestrationAgentExecutor -from ._shim import DurableAgentProvider, DurableAIAgent - -logger = logging.getLogger("agent_framework.durabletask") - - -class DurableAIAgentOrchestrationContext(DurableAgentProvider[DurableAgentTask]): - """Orchestration context wrapper for interacting with durable agents internally. - - This class wraps a durabletask OrchestrationContext and provides a convenient - interface for retrieving and executing durable agents from within orchestration - functions. - - Example: - ```python - from durabletask import Orchestration - from agent_framework.azure import DurableAIAgentOrchestrationContext - - - def my_orchestration(context: OrchestrationContext): - # Wrap the context - agent_context = DurableAIAgentOrchestrationContext(context) - - # Get an agent reference - agent = agent_context.get_agent("assistant") - - # Run the agent (returns a Task to be yielded) - result = yield agent.run("Hello, how are you?") - - return result.text - ``` - """ - - def __init__(self, context: OrchestrationContext): - """Initialize the orchestration context wrapper. - - Args: - context: The durabletask orchestration context to wrap - """ - self._context = context - self._executor = OrchestrationAgentExecutor(self._context) - logger.debug("[DurableAIAgentOrchestrationContext] Initialized") - - def get_agent(self, agent_name: str) -> DurableAIAgent[DurableAgentTask]: - """Retrieve a DurableAIAgent shim for the specified agent. - - This method returns a proxy object that can be used to execute the agent - within an orchestration. The agent's run() method will return a Task that - must be yielded. - - Args: - agent_name: Name of the agent to retrieve (without the dafx- prefix) - - Returns: - DurableAIAgent instance that can be used to run the agent - - Note: - Validation is deferred to execution time. The entity must be registered - on a worker with the name f"dafx-{agent_name}". - """ - logger.debug("[DurableAIAgentOrchestrationContext] Creating agent proxy for: %s", agent_name) - return DurableAIAgent(self._executor, agent_name) diff --git a/python/packages/durabletask/agent_framework_durabletask/_response_utils.py b/python/packages/durabletask/agent_framework_durabletask/_response_utils.py deleted file mode 100644 index 2d0ee84d3e4..00000000000 --- a/python/packages/durabletask/agent_framework_durabletask/_response_utils.py +++ /dev/null @@ -1,76 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Shared utilities for handling AgentResponse parsing and validation.""" - -import logging -from typing import Any - -from agent_framework import AgentResponse -from pydantic import BaseModel - -logger = logging.getLogger("agent_framework.durabletask") - - -def load_agent_response(agent_response: AgentResponse | dict[str, Any] | None) -> AgentResponse: - """Convert raw payloads into AgentResponse instance. - - Args: - agent_response: The response to convert, can be an AgentResponse, dict, or None - - Returns: - AgentResponse: The converted response object - - Raises: - ValueError: If agent_response is None - TypeError: If agent_response is an unsupported type - """ - if agent_response is None: - raise ValueError("agent_response cannot be None") - - logger.debug("[load_agent_response] Loading agent response of type: %s", type(agent_response)) - - if isinstance(agent_response, AgentResponse): - return agent_response - if isinstance(agent_response, dict): - logger.debug("[load_agent_response] Converting dict payload using AgentResponse.from_dict") - return AgentResponse.from_dict(agent_response) - - raise TypeError(f"Unsupported type for agent_response: {type(agent_response)}") - - -def ensure_response_format( - response_format: type[BaseModel] | None, - correlation_id: str, - response: AgentResponse, -) -> None: - """Ensure the AgentResponse value is parsed into the expected response_format. - - This function modifies the response in-place by parsing its value attribute - into the specified Pydantic model format. - - Args: - response_format: Optional Pydantic model class to parse the response value into - correlation_id: Correlation ID for logging purposes - response: The AgentResponse object to validate and parse - - Raises: - ValueError: If response_format is specified but response.value cannot be parsed - """ - if response_format is not None: - # Set the response format on the response so .value knows how to parse - response._response_format = response_format # pyright: ignore[reportPrivateUsage] - response._value_parsed = False # pyright: ignore[reportPrivateUsage] # Reset to allow re-parsing with new format - - # Access response.value to trigger parsing (may raise ValidationError) - # Validate that parsing succeeded - if not isinstance(response.value, response_format): - raise ValueError( - f"Response value could not be parsed into required format {response_format.__name__} " - f"for correlation_id {correlation_id}" - ) - - logger.debug( - "[ensure_response_format] Loaded AgentResponse.value for correlation_id %s with type: %s", - correlation_id, - type(response.value).__name__, - ) diff --git a/python/packages/durabletask/agent_framework_durabletask/_shim.py b/python/packages/durabletask/agent_framework_durabletask/_shim.py deleted file mode 100644 index ed8a752a458..00000000000 --- a/python/packages/durabletask/agent_framework_durabletask/_shim.py +++ /dev/null @@ -1,171 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Durable Agent Shim for Durable Task Framework. - -This module provides the DurableAIAgent shim that implements SupportsAgentRun -and provides a consistent interface for both Client and Orchestration contexts. -The actual execution is delegated to the context-specific providers. -""" - -from __future__ import annotations - -from abc import ABC, abstractmethod -from typing import Any, Generic, Literal, TypeVar - -from agent_framework import AgentSession, ServiceSessionId, SupportsAgentRun, normalize_messages -from agent_framework._telemetry import mark_feature_used -from agent_framework._types import AgentRunInputs - -from ._executors import DurableAgentExecutor -from ._feature_usage import FeatureIndex -from ._models import DurableAgentSession - -# TypeVar for the task type returned by executors -# Covariant because TaskT only appears in return positions (output) -TaskT = TypeVar("TaskT", covariant=True) - - -class DurableAgentProvider(ABC, Generic[TaskT]): - """Abstract provider for constructing durable agent proxies. - - Implemented by context-specific wrappers (client/orchestration) to return a - `DurableAIAgent` shim backed by their respective `DurableAgentExecutor` - implementation, ensuring a consistent `get_agent` entry point regardless of - execution context. - """ - - @abstractmethod - def get_agent(self, agent_name: str) -> DurableAIAgent[TaskT]: - """Retrieve a DurableAIAgent shim for the specified agent. - - Args: - agent_name: Name of the agent to retrieve - - Returns: - DurableAIAgent instance that can be used to run the agent - - Raises: - NotImplementedError: Must be implemented by subclasses - """ - raise NotImplementedError("Subclasses must implement get_agent()") - - -class DurableAIAgent(SupportsAgentRun, Generic[TaskT]): - """A durable agent proxy that delegates execution to the provider. - - This class implements SupportsAgentRun but with one critical difference: - - SupportsAgentRun.run() returns a Coroutine (async, must await) - - DurableAIAgent.run() returns TaskT (sync Task object - must yield - or the AgentResponse directly in the case of TaskHubGrpcClient) - - This represents fundamentally different execution models but maintains the same - interface contract for all other properties and methods. - - The underlying provider determines how execution occurs (entity calls, HTTP requests, etc.) - and what type of Task object is returned. - - Type Parameters: - TaskT: The task type returned by this agent (e.g., AgentResponse, DurableAgentTask, AgentTask) - """ - - id: str - name: str - display_name: str - description: str | None - - def __init__(self, executor: DurableAgentExecutor[TaskT], name: str, *, agent_id: str | None = None): - """Initialize the shim with a provider and agent name. - - Args: - executor: The execution provider (Client or OrchestrationContext) - name: The name of the agent to execute - agent_id: Optional unique identifier for the agent (defaults to name) - """ - self._executor = executor - self.name = name # pyright: ignore[reportIncompatibleVariableOverride] - self.id = agent_id if agent_id is not None else name - self.display_name = name - self.description = f"Durable agent proxy for {name}" - - def run( # type: ignore[override] - self, - messages: AgentRunInputs | None = None, - *, - stream: Literal[False] = False, - session: AgentSession | None = None, - options: dict[str, Any] | None = None, - ) -> TaskT: - """Execute the agent via the injected provider. - - Args: - messages: The message(s) to send to the agent - stream: Whether to use streaming for the response (must be False) - DurableAgents do not support streaming mode. - session: Optional agent session for conversation context - options: Optional options dictionary. Supported keys include - ``response_format``, ``enable_tool_calls``, and ``wait_for_response``. - Additional keys are forwarded to the agent execution. - - Note: - This method overrides SupportsAgentRun.run() with a different return type: - - SupportsAgentRun.run() returns Coroutine[Any, Any, AgentResponse] (async) - - DurableAIAgent.run() returns TaskT (Task object for yielding) - - This is intentional to support orchestration contexts that use yield patterns - instead of async/await patterns. - - Returns: - TaskT: The task type specific to the executor - - Raises: - ValueError: If wait_for_response=False is used in an unsupported context - """ - if stream is not False: - raise ValueError("DurableAIAgent does not support streaming mode (stream must be False)") - message_str = self._normalize_messages(messages) - - run_request = self._executor.get_run_request( - message=message_str, - options=options, - ) - - mark_feature_used(FeatureIndex.DURABLETASK) - return self._executor.run_durable_agent( - agent_name=self.name, - run_request=run_request, - session=session, - ) - - def create_session(self, *, session_id: str | None = None) -> DurableAgentSession: - """Create a new agent session via the provider.""" - return self._executor.get_new_session(self.name) - - def get_session(self, service_session_id: str | ServiceSessionId, *, session_id: str | None = None) -> AgentSession: - """Retrieve an existing session via the provider.""" - if not isinstance(service_session_id, str): - raise ValueError("DurableAIAgent requires service_session_id to be a string") - return self._executor.get_new_session(self.name, service_session_id=service_session_id, session_id=session_id) - - def _normalize_messages(self, messages: AgentRunInputs | None) -> str: - """Convert supported message inputs to a single string. - - Args: - messages: The messages to normalize - - Returns: - A single string representation of the messages - - Raises: - ValueError: If normalized messages contain non-text content only. - """ - normalized_messages = normalize_messages(messages) - if not normalized_messages: - return "" - - message_texts: list[str] = [] - for message in normalized_messages: - if not message.text: - raise ValueError("DurableAIAgent only supports text message inputs.") - message_texts.append(message.text) - - return "\n".join(message_texts) diff --git a/python/packages/durabletask/agent_framework_durabletask/_worker.py b/python/packages/durabletask/agent_framework_durabletask/_worker.py deleted file mode 100644 index 64bfd543347..00000000000 --- a/python/packages/durabletask/agent_framework_durabletask/_worker.py +++ /dev/null @@ -1,424 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Worker wrapper for Durable Task Agent Framework. - -This module provides the DurableAIAgentWorker class that wraps a durabletask worker -and enables registration of agents as durable entities, and optionally workflows -as durable orchestrations with automatically generated activity functions. -""" - -from __future__ import annotations - -import logging -from typing import Any - -from agent_framework import SupportsAgentRun, Workflow -from agent_framework._telemetry import mark_feature_used -from durabletask.task import ActivityContext, OrchestrationContext -from durabletask.worker import TaskHubGrpcWorker - -from ._async_bridge import run_agent_coroutine -from ._callbacks import AgentResponseCallbackProtocol -from ._entities import AgentEntity, DurableTaskEntityStateProvider -from ._feature_usage import FeatureIndex -from ._workflows.activity import execute_workflow_activity -from ._workflows.dt_context import DurableTaskWorkflowContext -from ._workflows.naming import ( - validate_executor_id, - validate_workflow_name, - workflow_executor_activity_name, - workflow_orchestrator_name, - workflow_scoped_executor_id, -) -from ._workflows.orchestrator import run_workflow_orchestrator -from ._workflows.registration import collect_hosted_workflows, plan_workflow_registration - -logger = logging.getLogger("agent_framework.durabletask") - - -class DurableAIAgentWorker: - """Wrapper for a durabletask worker that hosts agents and workflows. - - This class wraps an existing TaskHubGrpcWorker instance and is the single - host-side registration surface for a worker process. It supports two - complementary kinds of work: - - - **Agents** via :meth:`add_agent`, which registers each agent as a durable entity. - - **Workflows** via :meth:`configure_workflow`, which registers a MAF - ``Workflow`` (its agent executors as entities, its non-agent executors as - activities, and the workflow orchestrator). - - A single worker process commonly hosts both, so registration is intentionally - aggregated on one object rather than split per kind. (On the *client* side the - surfaces are split into :class:`DurableAIAgentClient` and ``DurableWorkflowClient``, - because a caller invokes one or the other.) - - Example: - ```python - from durabletask.worker import TaskHubGrpcWorker - from agent_framework import Agent - from agent_framework.openai import OpenAIChatCompletionClient - from agent_framework_durabletask import DurableAIAgentWorker - - # Create the underlying worker - worker = TaskHubGrpcWorker(host_address="localhost:4001") - - # Wrap it with the agent worker - agent_worker = DurableAIAgentWorker(worker) - - # Register agents (or call configure_workflow(workflow) to host a workflow) - client = OpenAIChatCompletionClient() - my_agent = Agent(client=client, name="assistant") - agent_worker.add_agent(my_agent) - - # Start the worker - worker.start() - ``` - """ - - def __init__( - self, - worker: TaskHubGrpcWorker, - callback: AgentResponseCallbackProtocol | None = None, - ): - """Initialize the worker wrapper. - - Args: - worker: The durabletask worker instance to wrap - callback: Optional callback for agent response notifications - """ - self._worker = worker - self._callback = callback - self._registered_agents: dict[str, SupportsAgentRun] = {} - self._workflows: dict[str, Workflow] = {} - # Every workflow whose orchestration has been registered (top-level plus nested - # sub-workflows), keyed by case-folded name -> the registered instance, so a - # sub-workflow shared across the tree is registered once while two different - # workflows whose names collide (including case-only differences) are rejected. - self._registered_orchestrations: dict[str, Workflow] = {} - logger.debug("[DurableAIAgentWorker] Initialized with worker type: %s", type(worker).__name__) - - def add_agent( - self, - agent: SupportsAgentRun, - callback: AgentResponseCallbackProtocol | None = None, - *, - entity_id: str | None = None, - ) -> None: - """Register an agent with the worker. - - This method creates a durable entity class for the agent and registers - it with the underlying durabletask worker. The entity will be accessible - by the name "dafx-{entity_id or agent_name}". - - Args: - agent: The agent to register (must have a name) - callback: Optional callback for this specific agent (overrides worker-level callback) - entity_id: Optional identity to register the entity under instead of - ``agent.name``. Workflow hosting passes the executor's ``id`` so the - entity matches the identity the orchestrator dispatches to. - - Raises: - ValueError: If the agent doesn't have a name or is already registered - """ - registration_name = entity_id or agent.name - if not registration_name: - raise ValueError("Agent must have a name to be registered") - - if registration_name in self._registered_agents: - raise ValueError(f"Agent '{registration_name}' is already registered") - - logger.info( - "[DurableAIAgentWorker] Registering agent: %s as entity: dafx-%s", registration_name, registration_name - ) - - # Store the agent reference - self._registered_agents[registration_name] = agent - - # Use agent-specific callback if provided, otherwise use worker-level callback - effective_callback = callback or self._callback - - # Create a configured entity class using the factory - entity_class = self.__create_agent_entity(agent, effective_callback, entity_id=registration_name) - - # Register the entity class with the worker - # The worker.add_entity method takes a class - entity_registered: str = self._worker.add_entity(entity_class) - - logger.debug( - "[DurableAIAgentWorker] Successfully registered entity class %s for agent: %s", - entity_registered, - registration_name, - ) - - def start(self) -> None: - """Start the worker to begin processing tasks. - - Note: - This method delegates to the underlying worker's start method. - The worker will block until stopped. - """ - logger.info("[DurableAIAgentWorker] Starting worker with %d registered agents", len(self._registered_agents)) - mark_feature_used(FeatureIndex.DURABLETASK) - self._worker.start() - - def stop(self) -> None: - """Stop the worker gracefully. - - Note: - This method delegates to the underlying worker's stop method. - """ - logger.info("[DurableAIAgentWorker] Stopping worker") - self._worker.stop() - - @property - def registered_agent_names(self) -> list[str]: - """Get the names of all registered agents. - - Returns: - List of agent names (without the dafx- prefix) - """ - return list(self._registered_agents.keys()) - - @property - def registered_workflow_names(self) -> list[str]: - """Get the names of all workflows configured on this worker. - - Returns: - List of workflow names (the identities used to derive each workflow's - ``dafx-{name}`` orchestration). - """ - return list(self._workflows.keys()) - - # ----------------------------------------------------------------- - # Workflow support - # ----------------------------------------------------------------- - - def configure_workflow( - self, - workflow: Workflow, - callback: AgentResponseCallbackProtocol | None = None, - ) -> None: - """Register a :class:`Workflow` for automatic orchestration. - - This extracts agents from the workflow and registers them as durable - entities, registers non-agent executors as activities, and creates an - orchestrator function that drives the workflow graph. - - Multiple workflows can be hosted on one worker: call this method once per - workflow. Each workflow is keyed by its :attr:`Workflow.name`, and its - durable primitives are scoped by that name (orchestration - ``dafx-{name}``; activities/entities ``dafx-{name}-{executorId}``) so two - co-hosted workflows that reuse an executor id do not collide. - - Sub-workflows nest: if the workflow contains - :class:`~agent_framework.WorkflowExecutor` nodes, each inner workflow's - orchestration/agents/activities are registered too (deduped by name) so the - parent can drive them as durable child orchestrations. - - Args: - workflow: The MAF :class:`Workflow` to register. Must have an explicit, - stable :attr:`Workflow.name` (an auto-generated - ``WorkflowBuilder-`` name is rejected because it is not stable - across restarts and would break durable resume). Every nested - sub-workflow must likewise be named. - callback: Optional callback for agent response notifications. - - Raises: - ValueError: If the workflow (or a nested sub-workflow) name is missing, - invalid, or auto-generated, or if the top-level workflow name is - already registered on this worker. - """ - workflow_name = workflow.name - validate_workflow_name(workflow_name) - if any(name.casefold() == workflow_name.casefold() for name in self._workflows): - raise ValueError( - f"Workflow '{workflow_name}' is already registered on this worker " - "(workflow names are compared case-insensitively)." - ) - - # Validate the whole composition (top-level plus every nested sub-workflow) - # up front, so an invalid/auto-generated nested name (or an executor id that - # would break durable naming / nested-HITL addressing) fails before any - # registration side effects leave the worker partially configured. - hosted_workflows = list(collect_hosted_workflows(workflow)) - for hosted in hosted_workflows: - validate_workflow_name(hosted.name) - for executor_id in hosted.executors: - validate_executor_id(executor_id) - - # Check every cross-call collision *before* mutating any state, so a clash - # between a nested sub-workflow and an already-registered orchestration cannot - # leave the worker partially configured (e.g. the top-level name added to - # ``_workflows`` while a later child fails). Registration below is then a pure - # commit step. - for hosted in hosted_workflows: - existing = self._registered_orchestrations.get(hosted.name.casefold()) - if existing is not None and existing is not hosted: - raise ValueError( - f"A different workflow named '{hosted.name}' collides with already-registered " - f"'{existing.name}' on this worker. A workflow name maps to a single durable " - f"orchestration ('dafx-{hosted.name}'), compared case-insensitively; rename one " - "of them." - ) - - self._workflows[workflow_name] = workflow - - # Commit: register the top-level workflow and every nested sub-workflow (deduped - # by name), so the parent can drive sub-workflows as durable child orchestrations. - for hosted in hosted_workflows: - if hosted.name.casefold() in self._registered_orchestrations: - continue - self._register_single_workflow(hosted, callback) - - def _register_single_workflow( - self, - workflow: Workflow, - callback: AgentResponseCallbackProtocol | None, - ) -> None: - """Register one workflow's durable primitives (no recursion into sub-workflows). - - The "what to register" decision (agent -> entity, non-agent -> activity, - sub-workflow -> child orchestration) is shared with the Azure Functions host - via ``plan_workflow_registration``. - """ - validate_workflow_name(workflow.name) - self._registered_orchestrations[workflow.name.casefold()] = workflow - plan = plan_workflow_registration(workflow) - - # Register agent executors as durable entities, scoped by workflow name so - # two workflows that reuse an executor id register distinct entities. The - # entity is keyed by the scoped identity (the same identity the orchestrator - # dispatches to); the entity *key* at run time is the orchestration instance - # id, which keeps conversation state isolated per run. - for agent_executor in plan.agent_executors: - scoped_id = workflow_scoped_executor_id(workflow.name, agent_executor.id) - if scoped_id not in self._registered_agents: - self.add_agent(agent_executor.agent, callback=callback, entity_id=scoped_id) - - # Register non-agent executors as durable activities, scoped by workflow name. - # WorkflowExecutor nodes are intentionally not registered as activities: their - # inner workflows are registered separately (above, via collect_hosted_workflows) - # and driven as child orchestrations. - for executor in plan.activity_executors: - self._register_executor_activity(workflow, executor) - - # Register this workflow's orchestrator under its per-workflow name. - self._register_workflow_orchestrator(workflow) - - logger.info( - "[DurableAIAgentWorker] Workflow '%s' configured with %d executors " - "(%d agents, %d activities, %d sub-workflows)", - workflow.name, - len(workflow.executors), - len(plan.agent_executors), - len(plan.activity_executors), - len(plan.subworkflow_executors), - ) - - def _register_executor_activity(self, workflow: Workflow, executor: Any) -> None: - """Register a non-agent executor as a durabletask activity (workflow-scoped).""" - captured_executor = executor - captured_workflow = workflow - activity_name = workflow_executor_activity_name(workflow.name, executor.id) - - def executor_activity(ctx: ActivityContext, input_data: str) -> str: - return execute_workflow_activity(captured_executor, input_data, captured_workflow) - - # Give the function the expected name for registration - executor_activity.__name__ = activity_name - executor_activity.__qualname__ = activity_name - - self._worker.add_activity(executor_activity) - logger.debug("[DurableAIAgentWorker] Registered activity: %s", activity_name) - - def _register_workflow_orchestrator(self, workflow: Workflow) -> None: - """Register a workflow's orchestrator function under its per-workflow name.""" - captured_workflow = workflow - orchestrator_name = workflow_orchestrator_name(workflow.name) - - def workflow_orchestrator(context: OrchestrationContext, input_data: Any) -> Any: - # Pass the deserialized client input straight to the shared engine, which - # reconstructs the start executor's declared type (see _coerce_initial_input). - initial_message = input_data - shared_state: dict[str, Any] = {} - - dt_ctx = DurableTaskWorkflowContext(context) - outputs = yield from run_workflow_orchestrator(dt_ctx, captured_workflow, initial_message, shared_state) - return outputs # ruff:ignore[return-in-generator] - - workflow_orchestrator.__name__ = orchestrator_name - workflow_orchestrator.__qualname__ = orchestrator_name - - self._worker.add_orchestrator(workflow_orchestrator) - logger.debug("[DurableAIAgentWorker] Registered workflow orchestrator: %s", orchestrator_name) - - def __create_agent_entity( - self, - agent: SupportsAgentRun, - callback: AgentResponseCallbackProtocol | None = None, - *, - entity_id: str | None = None, - ) -> type[DurableTaskEntityStateProvider]: - """Factory function to create a DurableEntity class configured with an agent. - - This factory creates a new class that combines the entity state provider - with the agent execution logic. Each agent gets its own entity class. - - Args: - agent: The agent instance to wrap - callback: Optional callback for agent responses - entity_id: Optional identity to register the entity under instead of - ``agent.name`` (used by workflow hosting to key entities by - executor id). - - Returns: - A new DurableEntity subclass configured for this agent - """ - agent_name = entity_id or agent.name or type(agent).__name__ - entity_name = f"dafx-{agent_name}" - - class ConfiguredAgentEntity(DurableTaskEntityStateProvider): - """Durable entity configured with a specific agent instance.""" - - def __init__(self) -> None: - super().__init__() - # Create the AgentEntity with this state provider - self._agent_entity = AgentEntity( - agent=agent, - callback=callback, - state_provider=self, - ) - logger.debug( - "[ConfiguredAgentEntity] Initialized entity for agent: %s (entity name: %s)", - agent_name, - entity_name, - ) - - def run(self, request: Any) -> Any: - """Handle run requests from clients or orchestrations. - - Args: - request: RunRequest as dict or string - - Returns: - AgentResponse as dict - """ - logger.debug("[ConfiguredAgentEntity.run] Executing agent: %s", agent_name) - # Run on the shared persistent loop so async resources created by - # shared agent clients/credentials stay bound to a live loop across - # successive entity invocations (avoids cross-loop hangs). - response = run_agent_coroutine(self._agent_entity.run(request)) - return response.to_dict() - - def reset(self) -> None: - """Reset the agent's conversation history.""" - logger.debug("[ConfiguredAgentEntity.reset] Resetting agent: %s", agent_name) - self._agent_entity.reset() - - # Set the entity name to match the prefixed agent name - # This is used by durabletask to register the entity - ConfiguredAgentEntity.__name__ = entity_name - ConfiguredAgentEntity.__qualname__ = entity_name - - return ConfiguredAgentEntity diff --git a/python/packages/durabletask/agent_framework_durabletask/_workflows/__init__.py b/python/packages/durabletask/agent_framework_durabletask/_workflows/__init__.py deleted file mode 100644 index 3ebffaa7e78..00000000000 --- a/python/packages/durabletask/agent_framework_durabletask/_workflows/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Durable hosting of Microsoft Agent Framework workflows. - -This subpackage turns a MAF :class:`~agent_framework.Workflow` into durable -primitives -- a single orchestrator, agent entities, and non-agent executor -activities -- that run on either a standalone Durable Task worker or Azure -Functions. The host-agnostic engine lives here; each host programs against the -:class:`~.context.WorkflowOrchestrationContext` protocol. -""" diff --git a/python/packages/durabletask/agent_framework_durabletask/_workflows/activity.py b/python/packages/durabletask/agent_framework_durabletask/_workflows/activity.py deleted file mode 100644 index 821d19201e1..00000000000 --- a/python/packages/durabletask/agent_framework_durabletask/_workflows/activity.py +++ /dev/null @@ -1,191 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Host-agnostic execution of non-agent workflow executors as durable activities. - -When a MAF :class:`Workflow` runs as a durable orchestration, each non-agent -executor is dispatched as a durable *activity*. The activity body is identical -regardless of host (Azure Functions or a standalone durabletask worker): it -deserializes the activity input, runs the executor (or a human-in-the-loop -response handler), diffs the shared state, and serializes the executor's -outputs, sent messages, shared-state changes, and any pending HITL requests back -to the orchestrator. - -This module provides that shared body as :func:`execute_workflow_activity` so -both host adapters call one implementation instead of duplicating it. -""" - -from __future__ import annotations - -import asyncio -import json -from copy import deepcopy -from typing import Any, cast - -from agent_framework import Executor, Workflow, WorkflowEvent -from agent_framework._workflows._runner_context import YieldOutputEventType -from agent_framework._workflows._state import State - -from .orchestrator import ( - SOURCE_HITL_RESPONSE, - SOURCE_ORCHESTRATOR, - execute_hitl_response_handler, -) -from .runner_context import CapturingRunnerContext -from .serialization import deserialize_value, serialize_value, serialize_workflow_event - - -def execute_workflow_activity(executor: Executor, input_json: str, workflow: Workflow | None = None) -> str: - """Execute a single non-agent workflow executor and return its serialized result. - - This is the host-agnostic activity body shared by the Azure Functions and - standalone durabletask workflow hosts. - - Args: - executor: The non-agent executor instance to run. - input_json: JSON-encoded activity input with keys ``message``, - ``shared_state_snapshot``, and ``source_executor_ids``. - workflow: The owning workflow, used to classify the executor's - ``yield_output`` payloads as final ``output`` vs ``intermediate``. - When omitted, all yielded outputs are treated as final outputs. - - Returns: - A JSON string with keys ``sent_messages``, ``outputs``, ``events``, - ``shared_state_updates``, ``shared_state_deletes``, and - ``pending_request_info_events``. - - Raises: - ValueError: If the input does not decode to a JSON object, or a HITL - message payload is not a JSON object. - """ - data_obj = json.loads(input_json) - if not isinstance(data_obj, dict): - raise ValueError("Activity input must decode to a JSON object") - data = cast(dict[str, Any], data_obj) - - message_data = data.get("message") - # The orchestrator may pass null for these when shared state / sources are - # omitted, so coerce None to the appropriate empty default. - shared_state_snapshot: dict[str, Any] = data.get("shared_state_snapshot") or {} - source_executor_ids = cast(list[str], data.get("source_executor_ids") or [SOURCE_ORCHESTRATOR]) - # Orchestration metadata the orchestrator attaches when dispatching this activity - # (instance_id, workflow_name). Absent for in-process / legacy inputs, so default - # to None and surface it on the runner context for executors that want it. - host_context = cast("dict[str, Any] | None", data.get("host_context")) - - # Reconstruct the message - deserialize_value restores the original typed - # objects from the encoded data (with type markers). - message = deserialize_value(message_data) - - # A HITL response is identified by a source id starting with the HITL prefix. - is_hitl_response = any(s.startswith(SOURCE_HITL_RESPONSE) for s in source_executor_ids) - - def classify_yielded_output(executor_id: str) -> YieldOutputEventType | None: - # Mirror the core runner's classification so intermediate executors' - # yields are not surfaced as final workflow outputs. - if workflow is None: - return "output" - if workflow.is_terminal_executor(executor_id): - return "output" - if workflow.is_intermediate_executor(executor_id): - return "intermediate" - return None - - async def _run() -> dict[str, Any]: - runner_context = CapturingRunnerContext() - runner_context.set_yield_output_classifier(classify_yielded_output) - runner_context.set_host_metadata(host_context) - shared_state = State() - - # Deserialize shared state values to reconstruct dataclasses / Pydantic models. - deserialized_state: dict[str, Any] = {str(k): deserialize_value(v) for k, v in shared_state_snapshot.items()} - # Snapshot the deserialized (in-memory) state for diffing. State.export_state() - # returns the in-memory committed objects, so the snapshot must hold objects - # too (deepcopy) - comparing against a serialized snapshot would mark every - # key as changed. - original_snapshot = deepcopy(deserialized_state) - shared_state.import_state(deserialized_state) - - if is_hitl_response: - if not isinstance(message, dict): - raise ValueError("HITL message payload must be a JSON object") - await execute_hitl_response_handler( - executor=executor, - hitl_message=cast(dict[str, Any], message), - shared_state=shared_state, - runner_context=runner_context, - ) - else: - await executor.execute( - message=message, - source_executor_ids=source_executor_ids, - state=shared_state, - runner_context=runner_context, - ) - - # Commit pending state changes and compute the diff vs the original snapshot. - shared_state.commit() - current_state = shared_state.export_state() - original_keys: set[str] = set(original_snapshot.keys()) - current_keys: set[str] = set(current_state.keys()) - - # Deleted = was in original, not in current. - deletes: set[str] = original_keys - current_keys - - # Updates = keys that are new or whose value changed. - updates: dict[str, Any] = {} - for key in current_keys: - if key not in original_keys or current_state[key] != original_snapshot.get(key): - updates[key] = current_state[key] - - sent_messages = await runner_context.drain_messages() - events = await runner_context.drain_events() - - # Serialize the executor's workflow events so the orchestrator can republish - # them to the streaming custom status. Output payloads are also extracted - # separately for message routing and the final workflow result. - outputs: list[Any] = [] - serialized_events: list[dict[str, Any]] = [] - for event in events: - if not isinstance(event, WorkflowEvent): - continue - serialized_events.append(serialize_workflow_event(event)) - if event.type == "output": - outputs.append(serialize_value(event.data)) - - # Serialize pending HITL request info events for the orchestrator. - pending_request_info_events = await runner_context.get_pending_request_info_events() - serialized_pending_requests: list[dict[str, Any]] = [] - for _request_id, event in pending_request_info_events.items(): - serialized_pending_requests.append({ - "request_id": event.request_id, - "source_executor_id": event.source_executor_id, - "data": serialize_value(event.data), - "request_type": f"{type(event.data).__module__}:{type(event.data).__name__}", - "response_type": f"{event.response_type.__module__}:{event.response_type.__name__}" - if event.response_type - else None, - }) - - # Serialize sent messages for JSON compatibility. - serialized_sent_messages: list[dict[str, Any]] = [] - for _source_id, msg_list in sent_messages.items(): - for msg in msg_list: - serialized_sent_messages.append({ - "message": serialize_value(msg.data), - "target_id": msg.target_id, - "source_id": msg.source_id, - }) - - serialized_updates = {k: serialize_value(v) for k, v in updates.items()} - - return { - "sent_messages": serialized_sent_messages, - "outputs": outputs, - "events": serialized_events, - "shared_state_updates": serialized_updates, - "shared_state_deletes": list(deletes), - "pending_request_info_events": serialized_pending_requests, - } - - result = asyncio.run(_run()) - return json.dumps(result) diff --git a/python/packages/durabletask/agent_framework_durabletask/_workflows/client.py b/python/packages/durabletask/agent_framework_durabletask/_workflows/client.py deleted file mode 100644 index 4a045463b68..00000000000 --- a/python/packages/durabletask/agent_framework_durabletask/_workflows/client.py +++ /dev/null @@ -1,527 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Workflow client wrapper for Durable Task Agent Framework. - -This module provides :class:`DurableWorkflowClient` for external clients to start, -await, and drive (including human-in-the-loop) workflows registered on a worker via -``DurableAIAgentWorker.configure_workflow``. -""" - -from __future__ import annotations - -import asyncio -import json -import logging -import time -from collections.abc import AsyncIterator -from typing import Any, cast - -from agent_framework import WorkflowEvent -from durabletask.client import TaskHubGrpcClient - -from .naming import ( - qualify_subworkflow_request_id, - split_subworkflow_request_id, - workflow_orchestrator_name, -) -from .serialization import ( - deserialize_workflow_event, - deserialize_workflow_output, - strip_pickle_markers, - strip_subworkflow_markers, -) - -logger = logging.getLogger("agent_framework.durabletask") - - -class DurableWorkflowClient: - """Client wrapper for starting and driving durable workflows externally. - - This class wraps a durabletask ``TaskHubGrpcClient`` and provides a convenient - interface for the workflow registered by ``DurableAIAgentWorker.configure_workflow``: - starting it, awaiting its output, and responding to human-in-the-loop (HITL) pauses. - - For interacting with individual durable *agents*, use - :class:`~agent_framework_durabletask.DurableAIAgentClient` instead. Both wrap the - same underlying ``TaskHubGrpcClient``, so an application that needs both can - construct both over one client. - - Example: - ```python - from durabletask.azuremanaged.client import DurableTaskSchedulerClient - from agent_framework.azure import DurableWorkflowClient - - # Create the underlying client - client = DurableTaskSchedulerClient(host_address="localhost:8080", taskhub="default") - - # Wrap it with the workflow client, defaulting to the workflow named "orders" - workflow_client = DurableWorkflowClient(client, workflow_name="orders") - - # Start a workflow and wait for its output - instance_id = workflow_client.start_workflow(input="some input") - output = workflow_client.await_workflow_output(instance_id) - print(output) - - # A client without a default targets workflows explicitly per call: - multi = DurableWorkflowClient(client) - instance_id = multi.start_workflow(input="...", workflow_name="billing") - ``` - """ - - def __init__(self, client: TaskHubGrpcClient, *, workflow_name: str | None = None): - """Initialize the workflow client wrapper. - - Args: - client: The durabletask client instance to wrap. - workflow_name: Optional default workflow name to target. When set, the - per-call ``workflow_name`` may be omitted. When a worker hosts a - single workflow, set this once here; when it hosts several, either - set a default and override per call, or pass ``workflow_name`` on - each call. - """ - self._client = client - self._default_workflow_name = workflow_name - logger.debug("[DurableWorkflowClient] Initialized with client type: %s", type(client).__name__) - - def _resolve_workflow_name(self, workflow_name: str | None) -> str: - """Resolve the effective workflow name from a per-call value or the default. - - Raises: - ValueError: If neither a per-call ``workflow_name`` nor a constructor - default was provided. - """ - name = workflow_name or self._default_workflow_name - if not name: - raise ValueError( - "No workflow name provided. Pass workflow_name=... (or set a default on " - "DurableWorkflowClient(workflow_name=...)) so the client can target the " - "right orchestration." - ) - return name - - def start_workflow( - self, input: Any = None, *, workflow_name: str | None = None, instance_id: str | None = None - ) -> str: - """Start the workflow orchestration registered by ``configure_workflow``. - - This schedules the orchestration ``dafx-{workflow_name}`` that - ``DurableAIAgentWorker.configure_workflow`` auto-registers, so callers do - not need to know its internal name. - - Args: - input: The initial message/payload for the workflow. - workflow_name: The workflow to start. Optional if a default was set on - the client; required otherwise. - instance_id: Optional explicit orchestration instance ID. If omitted, one - is generated. - - Returns: - The orchestration instance ID, for use with ``await_workflow_output``. - """ - orchestration_name = workflow_orchestrator_name(self._resolve_workflow_name(workflow_name)) - new_instance_id = self._client.schedule_new_orchestration( - orchestration_name, - # Neutralize a forged sub-workflow envelope before scheduling: only an - # internal child dispatch (post trust boundary) may carry those reserved - # keys, so stripping them here keeps untrusted input off the orchestrator's - # trusted-deserialization path even if start_workflow is exposed remotely. - input=strip_subworkflow_markers(input), - instance_id=instance_id, - ) - logger.debug("[DurableWorkflowClient] Started workflow instance: %s", new_instance_id) - return new_instance_id - - def _is_owned_orchestration(self, state: Any, workflow_name: str | None) -> bool: - """Return whether ``state`` belongs to the targeted workflow. - - Ownership validation is opt-in: when neither a per-call ``workflow_name`` - nor a constructor default is set there is nothing to validate against, so - this returns ``True``. When a name is resolvable, the instance's - orchestration name must equal ``dafx-{workflow_name}`` (compared - case-insensitively, mirroring the Azure Functions host's route-scoping - check). This guards against addressing an instance that belongs to a - different workflow on the same task hub. - """ - name = workflow_name or self._default_workflow_name - if not name: - return True - expected = workflow_orchestrator_name(name) - actual = getattr(state, "name", None) - return isinstance(actual, str) and actual.casefold() == expected.casefold() - - def await_workflow_output( - self, instance_id: str, *, workflow_name: str | None = None, timeout_seconds: int = 300 - ) -> Any: - """Wait for a workflow orchestration to complete and return its output. - - Args: - instance_id: The instance ID returned by ``start_workflow``. - workflow_name: Optional workflow name; when set (or a client default is - set) the instance's orchestration is validated to belong to that - workflow. - timeout_seconds: Maximum time, in seconds, to wait for completion. - - Returns: - The deserialized workflow output (typically a list of yielded outputs), - or ``None`` if the workflow produced no output. - - Raises: - TimeoutError: If the workflow does not complete within ``timeout_seconds``. - RuntimeError: If the workflow completes with a non-successful status. - ValueError: If the instance does not belong to the targeted workflow. - """ - metadata = self._client.wait_for_orchestration_completion(instance_id, timeout=timeout_seconds) - if metadata is None: - raise TimeoutError(f"Workflow '{instance_id}' did not complete within {timeout_seconds}s") - - if not self._is_owned_orchestration(metadata, workflow_name): - raise ValueError(f"Instance '{instance_id}' does not belong to the targeted workflow.") - - status = metadata.runtime_status.name - if status != "COMPLETED": - raise RuntimeError(f"Workflow '{instance_id}' ended with status {status}: {metadata.serialized_output}") - - if metadata.serialized_output is None: - return None - # The shared activity encodes each yielded output with serialize_value() - # before it reaches the orchestrator, so typed objects come back as - # checkpoint-marker dicts. Reconstruct the originals before returning. - return deserialize_workflow_output(json.loads(metadata.serialized_output)) - - async def run_workflow( - self, - input: Any = None, - *, - workflow_name: str | None = None, - instance_id: str | None = None, - wait: bool = True, - timeout_seconds: int = 300, - ) -> Any: - """Start the workflow and, by default, await its output. - - The async counterpart to ``start_workflow`` + ``await_workflow_output``. The - underlying durabletask client is synchronous, so the blocking calls run in a - worker thread to avoid blocking the event loop. - - Args: - input: The initial message/payload for the workflow. - workflow_name: The workflow to start. Optional if a default was set on - the client; required otherwise. - instance_id: Optional explicit orchestration instance ID. If omitted, - one is generated. - wait: When ``True`` (default), wait for completion and return the - deserialized output. When ``False``, return the instance ID as - soon as the workflow is scheduled (use with ``stream_workflow`` or - the HITL methods). - timeout_seconds: Maximum time, in seconds, to wait for completion when - ``wait`` is ``True``. - - Returns: - The deserialized workflow output when ``wait`` is ``True``; otherwise - the orchestration instance ID. - - Raises: - TimeoutError: If ``wait`` is ``True`` and the workflow does not complete - within ``timeout_seconds``. - RuntimeError: If ``wait`` is ``True`` and the workflow ends with a - non-successful status. - """ - new_instance_id = await asyncio.to_thread( - self.start_workflow, input, workflow_name=workflow_name, instance_id=instance_id - ) - if not wait: - return new_instance_id - return await asyncio.to_thread( - self.await_workflow_output, new_instance_id, workflow_name=workflow_name, timeout_seconds=timeout_seconds - ) - - def get_runtime_status(self, instance_id: str, *, workflow_name: str | None = None) -> str | None: - """Return the workflow's current runtime status name, or ``None`` if unknown. - - Lets callers distinguish a workflow that is still running or paused for - human input from one that has reached a terminal state (for example - ``COMPLETED``, ``FAILED``, or ``TERMINATED``) — useful when polling, so a - workflow that ends without pausing is not mistaken for one that never paused. - - Args: - instance_id: The instance ID returned by ``start_workflow``. - workflow_name: Optional workflow name; when set (or a client default is - set) an instance that does not belong to that workflow returns - ``None`` (treated as "not found"). - - Returns: - The runtime status name (e.g. ``"RUNNING"``, ``"COMPLETED"``), or - ``None`` if no state is available for the instance or it belongs to a - different workflow. - """ - state = self._client.get_orchestration_state(instance_id) - if state is None: - return None - if not self._is_owned_orchestration(state, workflow_name): - return None - return state.runtime_status.name - - async def stream_workflow( - self, - instance_id: str, - *, - workflow_name: str | None = None, - poll_interval_seconds: float = 1.0, - timeout_seconds: int | None = None, - ) -> AsyncIterator[WorkflowEvent]: - """Stream the workflow's events as typed :class:`WorkflowEvent` objects. - - Yields the workflow's events (``executor_invoked`` / ``executor_completed`` / - ``output`` / ``request_info`` / ...) in order, finishing when the workflow - reaches a terminal state. Each event's ``data`` payload is already - reconstructed into its original typed object, so callers do not deserialize - anything themselves. - - This is brokerless: it polls the orchestration custom status, into which the - orchestrator publishes accumulated events after each superstep. Granularity is - per executor and per yielded output, not token-level. Non-agent executors emit - events with data payloads; agent executors emit coarse ``executor_invoked`` / - ``executor_completed`` lifecycle events. The custom status accumulates events - for the run, so this suits workflows with a bounded number of executors rather - than very long-running fan-outs. - - Args: - instance_id: The instance ID returned by ``start_workflow``. - workflow_name: Optional workflow name; when set (or a client default is - set) the instance is validated to belong to that workflow before - streaming. - poll_interval_seconds: Delay between status polls. - timeout_seconds: Optional overall timeout; ``None`` streams until the - workflow reaches a terminal state. - - Yields: - :class:`WorkflowEvent` objects as the workflow progresses. - - Raises: - TimeoutError: If ``timeout_seconds`` elapses before completion. - ValueError: If the instance does not belong to the targeted workflow. - """ - cursor = 0 - terminal_statuses = {"COMPLETED", "FAILED", "TERMINATED"} - deadline = None if timeout_seconds is None else time.monotonic() + timeout_seconds - ownership_checked = False - - while True: - state = await asyncio.to_thread(self._client.get_orchestration_state, instance_id) - - # Validate ownership once, on the first poll that returns state. - if state is not None and not ownership_checked: - if not self._is_owned_orchestration(state, workflow_name): - raise ValueError(f"Instance '{instance_id}' does not belong to the targeted workflow.") - ownership_checked = True - - if state is not None: - status = self._parse_custom_status(state.serialized_custom_status) - if status is not None: - events = status.get("events") - if isinstance(events, list): - typed_events = cast("list[dict[str, Any]]", events) - while cursor < len(typed_events): - yield deserialize_workflow_event(typed_events[cursor]) - cursor += 1 - - runtime_status = state.runtime_status.name if state is not None else None - if runtime_status in terminal_statuses: - return - - if deadline is not None and time.monotonic() >= deadline: - raise TimeoutError(f"Workflow '{instance_id}' did not complete within {timeout_seconds}s") - - await asyncio.sleep(poll_interval_seconds) - - def get_pending_hitl_requests(self, instance_id: str, *, workflow_name: str | None = None) -> list[dict[str, Any]]: - """Return the workflow's pending human-in-the-loop (HITL) requests, if any. - - While a workflow is paused awaiting human input, the orchestrator records the - open requests in its custom status. This method reads and normalizes that - status so callers do not need to know its internal schema. - - Args: - instance_id: The workflow instance ID returned by ``start_workflow``. - workflow_name: Optional workflow name; when set (or a client default is - set) an instance that does not belong to that workflow returns an - empty list (treated as "not found"). - - Returns: - A list of pending requests. Each entry contains ``request_id``, - ``source_executor_id``, ``data``, ``request_type``, and ``response_type``. - Empty if the workflow is not currently waiting for human input. - - Note: - Requests originating in a nested sub-workflow are included with a - **qualified** ``request_id`` (``{executorId}~{ordinal}~{requestId}``, nested - for deeper levels). Pass that qualified id straight back to - :meth:`send_hitl_response`; it is routed to the owning child orchestration - automatically, so the caller only ever addresses the top-level instance. - """ - state = self._client.get_orchestration_state(instance_id) - if state is None or not state.serialized_custom_status: - return [] - if not self._is_owned_orchestration(state, workflow_name): - return [] - - return self._collect_pending_hitl_requests(state.serialized_custom_status) - - @staticmethod - def _parse_custom_status(serialized_custom_status: str | None) -> dict[str, Any] | None: - """Parse a serialized custom status into a dict, or ``None`` if unusable. - - Returns ``None`` for an empty/absent status or any value that is not a JSON - object (the only shape the orchestrator ever writes), so callers can treat - "no usable status" uniformly. - """ - if not serialized_custom_status: - return None - try: - parsed = json.loads(serialized_custom_status) - except (json.JSONDecodeError, TypeError): - return None - return cast("dict[str, Any]", parsed) if isinstance(parsed, dict) else None - - def _collect_pending_hitl_requests(self, serialized_custom_status: str) -> list[dict[str, Any]]: - """Collect an orchestration's pending requests plus any nested sub-workflow ones. - - Nested requests (discovered via the ``subworkflows`` map the parent records in - its custom status as ``{executorId: [childInstanceId, ...]}``) are qualified by - ``(executorId, ordinal)`` so deeper requests accumulate a full - ``{executorId}~{ordinal}~...~{requestId}`` path and a node with several children - keeps each one addressable. Child instances are reached directly by id (already - trusted, having come from the parent's status), so no per-child ownership check - is applied. - """ - status_dict = self._parse_custom_status(serialized_custom_status) - if status_dict is None: - return [] - - requests: list[dict[str, Any]] = [] - - pending = status_dict.get("pending_requests") - if isinstance(pending, dict): - for request_id, req_data in cast(dict[str, Any], pending).items(): - if not isinstance(req_data, dict): - continue - req = cast(dict[str, Any], req_data) - requests.append({ - "request_id": req.get("request_id", request_id), - "source_executor_id": req.get("source_executor_id"), - "data": req.get("data"), - "request_type": req.get("request_type"), - "response_type": req.get("response_type"), - }) - - subworkflows = status_dict.get("subworkflows") - if isinstance(subworkflows, dict): - for executor_id, child_ids in cast(dict[str, Any], subworkflows).items(): - children: list[Any] = cast("list[Any]", child_ids) if isinstance(child_ids, list) else [] - for ordinal, child_instance_id in enumerate(children): - if not isinstance(child_instance_id, str): - continue - child_state = self._client.get_orchestration_state(child_instance_id) - if child_state is None or not child_state.serialized_custom_status: - continue - for child_req in self._collect_pending_hitl_requests(child_state.serialized_custom_status): - qualified = dict(child_req) - qualified["request_id"] = qualify_subworkflow_request_id( - executor_id, ordinal, child_req["request_id"] - ) - requests.append(qualified) - - return requests - - def send_hitl_response( - self, instance_id: str, request_id: str, response: Any, *, workflow_name: str | None = None - ) -> None: - """Send a response to a pending HITL request, resuming the workflow. - - The orchestrator correlates the response by using ``request_id`` as the - external-event name, so callers do not need to know that convention. - - Args: - instance_id: The workflow instance ID. - request_id: The pending request's ID (from ``get_pending_hitl_requests``). - May be a **qualified** id (``{executorId}~{ordinal}~{requestId}``) for a - request that originated in a nested sub-workflow; it is routed to the - owning child orchestration automatically. - response: The response payload (e.g. a dict matching the expected - response type the executor's ``@response_handler`` expects). - workflow_name: Optional workflow name; when set (or a client default is - set) the instance is validated to belong to that workflow before the - event is raised, so a response is never injected into a different - workflow's orchestration. - - Raises: - ValueError: If the instance does not belong to the targeted workflow, or a - qualified id references a sub-workflow that is not currently active. - - Note: - The payload is sanitized with ``strip_pickle_markers`` before delivery to - neutralize pickle-marker injection, since the worker deserializes it. - """ - # Validate ownership before raising the event when a target is resolvable. - if workflow_name or self._default_workflow_name: - state = self._client.get_orchestration_state(instance_id) - if state is None or not self._is_owned_orchestration(state, workflow_name): - raise ValueError(f"Instance '{instance_id}' does not belong to the targeted workflow.") - - # A qualified id addresses a nested sub-workflow: resolve it to the owning child - # orchestration instance and the bare request id the child is actually waiting on. - target_instance_id, bare_request_id = self._resolve_hitl_target(instance_id, request_id) - - safe_response = strip_pickle_markers(response) - self._client.raise_orchestration_event(target_instance_id, event_name=bare_request_id, data=safe_response) - logger.debug( - "[DurableWorkflowClient] Sent HITL response for request %s on instance %s", - bare_request_id, - target_instance_id, - ) - - def _resolve_hitl_target(self, instance_id: str, request_id: str) -> tuple[str, str]: - """Resolve a possibly-qualified request id to ``(owning_instance_id, bare_request_id)``. - - An unqualified id (no well-formed hop) targets ``instance_id`` directly. A - qualified id ``{executorId}~{ordinal}~{rest}`` addresses a nested sub-workflow: - the executor's child instance id is read from this instance's ``subworkflows`` - custom-status map (a list selected by ``ordinal``) and the remainder is resolved - recursively, so arbitrarily deep nesting lands on the leaf child orchestration - and its bare request id. - """ - hop = split_subworkflow_request_id(request_id) - if hop is None: - return instance_id, request_id - - executor_id, ordinal, remainder = hop - child_instance_id = self._lookup_subworkflow_instance(instance_id, executor_id, ordinal) - if child_instance_id is None: - raise ValueError( - f"No active sub-workflow '{executor_id}' (ordinal {ordinal}) found for instance " - f"'{instance_id}' while routing HITL response for request '{request_id}'." - ) - return self._resolve_hitl_target(child_instance_id, remainder) - - def _lookup_subworkflow_instance(self, instance_id: str, executor_id: str, ordinal: int) -> str | None: - """Return the child orchestration instance id for ``(executor_id, ordinal)``, if active. - - Reads the ``subworkflows`` map (``{executorId: [childInstanceId, ...]}``) the - parent records in its custom status while dispatching sub-workflow nodes, and - selects the child at ``ordinal`` (its dispatch order this superstep). - """ - state = self._client.get_orchestration_state(instance_id) - custom_status = self._parse_custom_status(state.serialized_custom_status if state else None) - if custom_status is None: - return None - subworkflows = custom_status.get("subworkflows") - if not isinstance(subworkflows, dict): - return None - children_raw = cast(dict[str, Any], subworkflows).get(executor_id) - if not isinstance(children_raw, list): - return None - children = cast("list[Any]", children_raw) - if ordinal < 0 or ordinal >= len(children): - return None - child = children[ordinal] - return child if isinstance(child, str) else None diff --git a/python/packages/durabletask/agent_framework_durabletask/_workflows/context.py b/python/packages/durabletask/agent_framework_durabletask/_workflows/context.py deleted file mode 100644 index d757d00ecc1..00000000000 --- a/python/packages/durabletask/agent_framework_durabletask/_workflows/context.py +++ /dev/null @@ -1,195 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Protocol definition for workflow orchestration contexts. - -This module defines the ``WorkflowOrchestrationContext`` protocol that abstracts -the differences between Azure Functions' ``DurableOrchestrationContext`` and the -standalone ``durabletask.task.OrchestrationContext``. The shared workflow -orchestrator (:func:`run_workflow_orchestrator`) programs against this protocol -so that the same orchestration logic works on any host. - -Each host provides a thin adapter that maps its native context to this protocol: - -- ``DurableTaskWorkflowContext`` (this package) — wraps ``OrchestrationContext`` -- ``AzureFunctionsWorkflowContext`` (azurefunctions package) — wraps - ``DurableOrchestrationContext`` -""" - -from __future__ import annotations - -from datetime import datetime -from typing import Any, Protocol, runtime_checkable - - -@runtime_checkable -class WorkflowOrchestrationContext(Protocol): - """Host-agnostic interface for workflow orchestration primitives. - - All methods that return yieldable tasks return ``Any`` because the concrete - task types differ between hosting SDKs (``TaskBase`` for Azure Functions, - ``Task[T]`` for durabletask). The generator-based orchestrator simply - yields these opaque objects back to the hosting framework. - """ - - @property - def instance_id(self) -> str: - """The unique ID of the current orchestration instance.""" - ... - - @property - def is_replaying(self) -> bool: - """Whether the orchestrator is replaying previously-recorded history. - - Side effects intended to be observed live exactly once (for example, - publishing streaming status to the custom status) must be skipped while - this is ``True`` so they are not re-emitted on replay. - """ - ... - - @property - def supports_event_streaming(self) -> bool: - """Whether this host streams the workflow event timeline via custom status. - - The orchestrator accumulates the full :class:`WorkflowEvent` history and can - publish it to the orchestration custom status so a streaming client can - replay it (see ``DurableWorkflowClient.stream_workflow``). A host returns - ``True`` only when both are true: it has a streaming consumer *and* its - custom status can carry an accumulating, payload-bearing event log. - - The Azure Functions host returns ``False``: its Durable Functions custom - status is capped at 16 KB (UTF-16) by the WebJobs extension, and its HTTP - status endpoint exposes only ``state`` / ``pending_requests`` / ``output``, - never the event stream. Publishing the accumulating event log there would - overflow the cap and fail the orchestrator without serving any consumer. - - When ``False``, the orchestrator skips event accumulation and omits - ``events`` from the custom status; ``state`` and any ``pending_requests`` - (needed for human-in-the-loop) are still published. - """ - ... - - @property - def current_utc_datetime(self) -> datetime: - """The current replay-safe UTC datetime.""" - ... - - def prepare_agent_task(self, executor_id: str, message: str, orchestration_instance_id: str) -> Any: - """Create a yieldable task that runs an agent executor. - - Args: - executor_id: Agent name / executor ID. - message: The text message to send to the agent. - orchestration_instance_id: Instance ID used as the entity session key. - - Returns: - A yieldable task whose result is an ``AgentResponse``. - """ - ... - - def prepare_activity_task(self, activity_name: str, input_json: str) -> Any: - """Create a yieldable task that runs an activity executor. - - Args: - activity_name: The registered activity function name. - input_json: JSON-serialized activity input. - - Returns: - A yieldable task whose result is a JSON string. - """ - ... - - def call_sub_orchestrator(self, name: str, input: Any, instance_id: str | None = None) -> Any: - """Create a yieldable task that runs a nested workflow as a child orchestration. - - Used to drive a :class:`~agent_framework.WorkflowExecutor` node: the inner - workflow runs as its own durable orchestration (named ``dafx-{innerName}``), - independently checkpointed and observable, and its result flows back into - the parent's edge routing like any other executor's output. - - Args: - name: The registered orchestration name to invoke (``dafx-{innerName}``). - input: The JSON-serializable input for the child orchestration. - instance_id: Optional deterministic child instance ID. The orchestrator - derives one from the parent instance so nested runs are discoverable - and replay-safe. - - Returns: - A yieldable task whose result is the child orchestration's output. - """ - ... - - def task_all(self, tasks: list[Any]) -> Any: - """Create a yieldable composite task that completes when *all* tasks complete. - - Args: - tasks: List of yieldable tasks. - - Returns: - A yieldable task whose result is a list of individual results. - """ - ... - - def task_any(self, tasks: list[Any]) -> Any: - """Create a yieldable composite task that completes when *any* task completes. - - Args: - tasks: List of yieldable tasks. - - Returns: - A yieldable task whose result is the winning task. - """ - ... - - def wait_for_external_event(self, name: str) -> Any: - """Create a yieldable task that waits for a named external event. - - Args: - name: Event name to wait for. - - Returns: - A yieldable task whose result is the event payload. - """ - ... - - def create_timer(self, fire_at: datetime) -> Any: - """Create a yieldable timer task. - - Args: - fire_at: UTC datetime when the timer should fire. - - Returns: - A yieldable timer task. - """ - ... - - def set_custom_status(self, status: Any) -> None: - """Set the orchestration's custom status (visible to external clients). - - Args: - status: JSON-serializable status object. - """ - ... - - def new_uuid(self) -> str: - """Generate a replay-safe UUID.""" - ... - - def cancel_task(self, task: Any) -> None: - """Best-effort cancellation of a pending task. - - Args: - task: The task to cancel. If the underlying SDK does not support - cancellation this is a no-op. - """ - ... - - def get_task_result(self, task: Any) -> Any: - """Extract the result from a completed task. - - Args: - task: A completed task object. - - Returns: - The result value. - """ - ... diff --git a/python/packages/durabletask/agent_framework_durabletask/_workflows/dt_context.py b/python/packages/durabletask/agent_framework_durabletask/_workflows/dt_context.py deleted file mode 100644 index 7388a0acf59..00000000000 --- a/python/packages/durabletask/agent_framework_durabletask/_workflows/dt_context.py +++ /dev/null @@ -1,110 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""DurableTask SDK adapter for WorkflowOrchestrationContext. - -Wraps ``durabletask.task.OrchestrationContext`` to satisfy the -:class:`WorkflowOrchestrationContext` protocol. -""" - -from __future__ import annotations - -import logging -from datetime import datetime -from typing import Any, cast - -from durabletask.task import ( - OrchestrationContext, - Task, - when_all, - when_any, -) - -from .._executors import OrchestrationAgentExecutor -from .._models import AgentSessionId, DurableAgentSession -from .._shim import DurableAIAgent -from .context import WorkflowOrchestrationContext - -logger = logging.getLogger(__name__) - - -class DurableTaskWorkflowContext: - """Adapter that maps ``OrchestrationContext`` to :class:`WorkflowOrchestrationContext`.""" - - def __init__(self, context: OrchestrationContext) -> None: - self._context = context - self._executor = OrchestrationAgentExecutor(context) - - # -- Properties ----------------------------------------------------------- - - @property - def instance_id(self) -> str: - return self._context.instance_id - - @property - def is_replaying(self) -> bool: - return self._context.is_replaying - - @property - def supports_event_streaming(self) -> bool: - # The standalone DurableTask host exposes the event timeline to clients via - # DurableWorkflowClient.stream_workflow, and its DTS backend imposes no 16 KB - # custom-status cap, so the full accumulated event stream is published. - return True - - @property - def current_utc_datetime(self) -> datetime: - return self._context.current_utc_datetime - - # -- Agent / Activity dispatch -------------------------------------------- - - def prepare_agent_task(self, executor_id: str, message: str, orchestration_instance_id: str) -> Any: - session_id = AgentSessionId(name=executor_id, key=orchestration_instance_id) - session = DurableAgentSession(durable_session_id=session_id) - agent = DurableAIAgent(self._executor, executor_id) - return agent.run(message, session=session) - - def prepare_activity_task(self, activity_name: str, input_json: str) -> Any: - return cast(Any, self._context.call_activity(activity_name, input=input_json)) - - def call_sub_orchestrator(self, name: str, input: Any, instance_id: str | None = None) -> Any: - return cast(Any, self._context.call_sub_orchestrator(name, input=input, instance_id=instance_id)) - - # -- Composite tasks ------------------------------------------------------ - - def task_all(self, tasks: list[Any]) -> Any: - return when_all(tasks) - - def task_any(self, tasks: list[Any]) -> Any: - return when_any(tasks) - - # -- External events / timers --------------------------------------------- - - def wait_for_external_event(self, name: str) -> Any: - return cast(Any, self._context).wait_for_external_event(name) - - def create_timer(self, fire_at: datetime) -> Any: - return cast(Any, self._context).create_timer(fire_at) - - # -- Status / utility ----------------------------------------------------- - - def set_custom_status(self, status: Any) -> None: - self._context.set_custom_status(status) - - def new_uuid(self) -> str: - return self._context.new_uuid() - - def cancel_task(self, task: Any) -> None: - # durabletask Task doesn't expose cancel(); this is a best-effort no-op. - cancel_fn = getattr(task, "cancel", None) - if callable(cancel_fn): - cancel_fn() - - def get_task_result(self, task: Any) -> Any: - if isinstance(task, Task): - return cast(Any, task.get_result()) - return getattr(task, "result", None) - - -# Ensure the adapter satisfies the protocol. Validated statically by the type -# checker (and at every ``run_workflow_orchestrator`` call site) with no runtime cost. -_protocol_check: type[WorkflowOrchestrationContext] = DurableTaskWorkflowContext diff --git a/python/packages/durabletask/agent_framework_durabletask/_workflows/naming.py b/python/packages/durabletask/agent_framework_durabletask/_workflows/naming.py deleted file mode 100644 index b1b90727b4a..00000000000 --- a/python/packages/durabletask/agent_framework_durabletask/_workflows/naming.py +++ /dev/null @@ -1,299 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Durable naming helpers for hosting MAF Workflows. - -A hosted workflow maps to durable primitives (an orchestration, plus an activity -or entity per executor) whose names must be **stable** across worker restarts: -durable replay only resumes an in-flight orchestration if the orchestration, -activity, and entity names still resolve to the same functions. This module -centralizes how those names are derived from a workflow name so every host (the -Azure Functions host and the standalone durabletask worker) and the client agree -on one scheme. - -Naming scheme (the orchestration name is aligned byte-for-byte with .NET's -``WorkflowNamingHelper``):: - - orchestration: dafx-{workflowName} - non-agent activity: dafx-{workflowName}-{executorId} - agent entity: dafx-{workflowName}-{executorId} - -The orchestration name is the identifier the Durable Task tooling/UI surfaces, so -it matches .NET exactly. The inner activity/entity names are scoped by workflow in -Python (unlike .NET's bare ``dafx-{executorId}``) so two co-hosted workflows that -reuse an executor id cannot collide. -""" - -from __future__ import annotations - -import re - -__all__ = [ - "DURABLE_NAME_PREFIX", - "MAX_EXECUTOR_ID_LENGTH", - "SUBWORKFLOW_REQUEST_SEPARATOR", - "is_auto_generated_workflow_name", - "qualify_subworkflow_request_id", - "split_subworkflow_request_id", - "validate_executor_id", - "validate_workflow_name", - "workflow_executor_activity_name", - "workflow_name_from_orchestrator", - "workflow_orchestrator_name", - "workflow_scoped_executor_id", -] - -# Shared prefix for every durable name this hosting layer registers. Matches -# .NET's ``WorkflowNamingHelper.OrchestrationFunctionPrefix`` and the existing -# ``AgentSessionId.ENTITY_NAME_PREFIX``. -DURABLE_NAME_PREFIX = "dafx-" - -# Separator used to qualify a nested sub-workflow's pending HITL request when it is -# bubbled up to the top-level instance (one top-level addressing surface). A qualified id -# is a path of ``{executorId}~{ordinal}`` hops ending in the leaf's bare request id, -# e.g. ``review~0~approve~1~``. Both hosts and the client must agree on it -# so a qualified id round-trips: the read side prepends hops; the respond side peels -# them to route the response to the owning child orchestration. -# -# ``~`` (RFC 3986 "unreserved", so URL-path-safe) is deliberately **not** ``::``: -# core emits ``auto::{index}`` request ids for functional ``@workflow`` HITL, so a -# ``::`` separator would mis-parse those leaf ids. ``~`` does not appear in core -# request ids (uuid4 or ``auto::N``); executor ids are validated to exclude it (see -# :func:`validate_executor_id`), so only the structural hops carry the separator. -SUBWORKFLOW_REQUEST_SEPARATOR = "~" - -# Upper bound on an executor id's length when a workflow is hosted durably. The id is -# interpolated into durable activity/entity names (``dafx-{workflow}-{executor}``) and, -# for sub-workflow nodes, into recursively-nested child orchestration instance ids -# (``{parent}::{executor}::{n}``). Capping it keeps those derived strings within typical -# durable backend name/id limits; combined with the workflow-name cap, the worst-case -# instance id stays bounded even for deeply-nested sub-workflows. -MAX_EXECUTOR_ID_LENGTH = 128 - -# A workflow name is interpolated into durable orchestration/activity/entity names -# *and* into HTTP route segments (``workflow/{workflowName}/run``), so it must be -# conservative enough to be safe in every position: ASCII letters, digits, '_' or -# '-', starting with a letter, at most 63 characters. The length cap leaves room -# for the ``dafx-`` prefix and an ``-{executorId}`` suffix within typical durable -# name limits. -_WORKFLOW_NAME_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_-]{0,62}$") - -# Names auto-generated by ``WorkflowBuilder`` when the caller does not pass one, -# e.g. ``"WorkflowBuilder-3f2b1c0a-1234-5678-9abc-def012345678"``. They embed a -# fresh ``uuid4`` per process build, so they are not stable identities and must be -# rejected for durable hosting (see :func:`validate_workflow_name`). -_AUTO_GENERATED_NAME_RE = re.compile( - r"^WorkflowBuilder-[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" -) - - -def workflow_orchestrator_name(workflow_name: str) -> str: - """Return the durable orchestration name for a workflow. - - Args: - workflow_name: The workflow's name. Must satisfy - :func:`validate_workflow_name`. - - Returns: - ``"dafx-{workflow_name}"``. - - Raises: - ValueError: If ``workflow_name`` is not a valid, stable workflow name. - """ - validate_workflow_name(workflow_name) - return f"{DURABLE_NAME_PREFIX}{workflow_name}" - - -def workflow_name_from_orchestrator(orchestrator_name: str) -> str | None: - """Recover the workflow name from a durable orchestration name. - - The inverse of :func:`workflow_orchestrator_name`. Intended to be applied to - orchestration names (for example a durable instance's ``status.name``); it - strips the shared :data:`DURABLE_NAME_PREFIX`. - - Args: - orchestrator_name: A durable orchestration name. - - Returns: - The workflow name, or ``None`` if ``orchestrator_name`` does not carry the - expected prefix (so a caller can treat it as "not one of ours"). - """ - if not orchestrator_name.startswith(DURABLE_NAME_PREFIX): - return None - name = orchestrator_name[len(DURABLE_NAME_PREFIX) :] - return name or None - - -def workflow_scoped_executor_id(workflow_name: str, executor_id: str) -> str: - """Return the workflow-scoped identity for an executor. - - Inner executors (non-agent activities and agent entities) are scoped by - workflow so two co-hosted workflows that reuse an ``executor_id`` register and - dispatch to distinct durable primitives instead of colliding on one global - name. This is the **unprefixed** identity (e.g. used as - :class:`~agent_framework_durabletask.AgentSessionId` ``name``, which the entity - layer then prefixes); see :func:`workflow_executor_activity_name` for the full - activity function name. - - Args: - workflow_name: The owning workflow's name. - executor_id: The executor's id within that workflow. - - Returns: - ``"{workflow_name}-{executor_id}"``. - """ - return f"{workflow_name}-{executor_id}" - - -def workflow_executor_activity_name(workflow_name: str, executor_id: str) -> str: - """Return the durable activity function name for a non-agent executor. - - Args: - workflow_name: The owning workflow's name. - executor_id: The executor's id within that workflow. - - Returns: - ``"dafx-{workflow_name}-{executor_id}"``. - """ - return f"{DURABLE_NAME_PREFIX}{workflow_scoped_executor_id(workflow_name, executor_id)}" - - -def validate_workflow_name(workflow_name: str) -> None: - """Validate that a workflow name is usable as a stable durable identity. - - The name is **validated and rejected** rather than silently sanitized. A - workflow name is an identity baked into durable orchestration/activity/entity - names and HTTP routes, so transforming it could either (a) collapse two - distinct names into one and reintroduce the cross-workflow collision this - scheme exists to prevent, or (b) change the resolved name across versions and - break resume of in-flight instances. A loud error is safer than a silent - rename. - - Args: - workflow_name: The candidate name. - - Raises: - ValueError: If the name is empty, an auto-generated ``WorkflowBuilder`` - name, or contains characters outside - ``[A-Za-z][A-Za-z0-9_-]{0,62}``. - """ - if not workflow_name: - raise ValueError("Workflow name must be a non-empty string.") - if is_auto_generated_workflow_name(workflow_name): - raise ValueError( - f"Workflow name '{workflow_name}' is an auto-generated WorkflowBuilder name, which is " - "not stable across restarts. Pass an explicit, stable name to WorkflowBuilder(name=...) " - "before hosting the workflow durably." - ) - if not _WORKFLOW_NAME_RE.match(workflow_name): - raise ValueError( - f"Workflow name '{workflow_name}' is invalid. Use 1-63 characters consisting of ASCII " - "letters, digits, '_' or '-', and starting with a letter." - ) - - -def is_auto_generated_workflow_name(workflow_name: str) -> bool: - """Return whether a name looks like ``WorkflowBuilder``'s auto-generated default. - - ``WorkflowBuilder`` names an otherwise-unnamed workflow - ``f"WorkflowBuilder-{uuid4()}"``, which changes on every process build and is - therefore not a stable durable identity. - - Args: - workflow_name: The candidate name. - - Returns: - ``True`` if the name matches the auto-generated pattern. - """ - return bool(_AUTO_GENERATED_NAME_RE.match(workflow_name)) - - -def validate_executor_id(executor_id: str) -> None: - """Validate that an executor id is safe to host durably. - - An executor id is interpolated into durable activity/entity names and, for - sub-workflow nodes, into nested child-orchestration instance ids and the - qualified ids used to address nested human-in-the-loop requests. Two properties - must hold: - - * It must not contain :data:`SUBWORKFLOW_REQUEST_SEPARATOR`. That sequence - separates the structural hops of a qualified nested-HITL request id, so an id - containing it would make a qualified id ambiguous and mis-route a response. - * It must be at most :data:`MAX_EXECUTOR_ID_LENGTH` characters, so the durable - names and (recursively nested) instance ids derived from it stay within typical - durable backend limits. - - Args: - executor_id: The executor's id within a hosted workflow. - - Raises: - ValueError: If the id is empty, contains the reserved separator, or is too - long. - """ - if not executor_id: - raise ValueError("Executor id must be a non-empty string.") - if SUBWORKFLOW_REQUEST_SEPARATOR in executor_id: - raise ValueError( - f"Executor id '{executor_id}' contains the reserved sub-workflow request separator " - f"'{SUBWORKFLOW_REQUEST_SEPARATOR}', which is used to address nested human-in-the-loop " - "requests. Rename the executor so its id does not contain that sequence." - ) - if len(executor_id) > MAX_EXECUTOR_ID_LENGTH: - raise ValueError( - f"Executor id '{executor_id[:32]}...' is too long ({len(executor_id)} > " - f"{MAX_EXECUTOR_ID_LENGTH}). Durable activity/entity names and nested instance ids are " - "derived from it; use a shorter id." - ) - - -def qualify_subworkflow_request_id(executor_id: str, ordinal: int, inner_request_id: str) -> str: - """Prepend one sub-workflow hop to a (possibly already-qualified) request id. - - Produces ``{executor_id}~{ordinal}~{inner_request_id}``. ``ordinal`` selects the - specific child orchestration among several a single ``WorkflowExecutor`` node may - dispatch in one superstep, so two children of the same executor stay distinctly - addressable. ``inner_request_id`` is the child's bare leaf request id or its own - already-qualified path for deeper nesting. - - Args: - executor_id: The sub-workflow node's executor id (separator-free; see - :func:`validate_executor_id`). - ordinal: The child's index in the parent's ``subworkflows`` status list. - inner_request_id: The request id (bare or qualified) within the child. - - Returns: - The qualified request id one level higher. - """ - sep = SUBWORKFLOW_REQUEST_SEPARATOR - return f"{executor_id}{sep}{ordinal}{sep}{inner_request_id}" - - -def split_subworkflow_request_id(request_id: str) -> tuple[str, int, str] | None: - """Peel the outermost sub-workflow hop off a qualified request id. - - The inverse of :func:`qualify_subworkflow_request_id` for a single level. - Returns ``(executor_id, ordinal, remainder)`` where ``remainder`` is the still - (possibly) qualified id one level deeper, or ``None`` when ``request_id`` carries - no well-formed hop -- i.e. it is a bare leaf request id that targets the current - instance directly. A leaf id may itself contain the separator (e.g. core's - ``auto::N`` does not, but a custom id could); because only structural hops use the - ``{executor}~{int-ordinal}~`` shape, a value whose second segment is not an integer - is treated as a bare leaf rather than a hop. - - Args: - request_id: A bare or qualified request id. - - Returns: - ``(executor_id, ordinal, remainder)`` for a qualified id, else ``None``. - """ - sep = SUBWORKFLOW_REQUEST_SEPARATOR - if sep not in request_id: - return None - parts = request_id.split(sep, 2) - if len(parts) < 3: - return None - executor_id, ordinal_str, remainder = parts - try: - ordinal = int(ordinal_str) - except ValueError: - return None - return executor_id, ordinal, remainder diff --git a/python/packages/durabletask/agent_framework_durabletask/_workflows/orchestrator.py b/python/packages/durabletask/agent_framework_durabletask/_workflows/orchestrator.py deleted file mode 100644 index c3068394de1..00000000000 --- a/python/packages/durabletask/agent_framework_durabletask/_workflows/orchestrator.py +++ /dev/null @@ -1,1265 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Host-agnostic workflow orchestration engine. - -This module provides the shared workflow orchestration logic that executes MAF -Workflows as durable task orchestrations. It programs against the -:class:`WorkflowOrchestrationContext` protocol so that the same code runs on -both Azure Functions and standalone durabletask hosts. - -Key components: - -* :func:`run_workflow_orchestrator` — main generator-based orchestrator -* Routing helpers (edge groups, fan-in, HITL) -* Result processing helpers - -All host-specific task creation (agent dispatch, activity dispatch, task_all / -task_any) is delegated to the ``WorkflowOrchestrationContext`` adapter. -""" - -from __future__ import annotations - -import inspect -import json -import logging -from collections import defaultdict -from collections.abc import Generator -from dataclasses import dataclass -from enum import Enum -from typing import Any, cast - -from agent_framework import ( - AgentExecutor, - AgentExecutorRequest, - AgentExecutorResponse, - AgentResponse, - Executor, - Message, - Workflow, - WorkflowConvergenceException, - WorkflowExecutor, -) -from agent_framework._workflows._edge import ( - Edge, - EdgeGroup, - FanInEdgeGroup, - FanOutEdgeGroup, - SingleEdgeGroup, - SwitchCaseEdgeGroup, -) -from agent_framework._workflows._state import State - -from .context import WorkflowOrchestrationContext -from .naming import ( - qualify_subworkflow_request_id, - workflow_executor_activity_name, - workflow_orchestrator_name, - workflow_scoped_executor_id, -) -from .runner_context import ( - HOST_METADATA_INSTANCE_ID, - HOST_METADATA_REQUEST_PATH_PREFIX, - HOST_METADATA_WORKFLOW_NAME, -) -from .serialization import ( - SUBWORKFLOW_ADDRESS_KEY, - SUBWORKFLOW_INPUT_KEY, - SUBWORKFLOW_RESULT_KEY, - deserialize_value, - reconstruct_to_type, - resolve_type, - serialize_value, - strip_pickle_markers, -) - -logger = logging.getLogger(__name__) - - -# ============================================================================ -# Source Marker Constants -# ============================================================================ - -SOURCE_WORKFLOW_START = "__workflow_start__" -SOURCE_ORCHESTRATOR = "__orchestrator__" -SOURCE_HITL_RESPONSE = "__hitl_response__" - -# A WorkflowExecutor node runs its inner workflow as a durable child orchestration. -# The parent wraps the node's input in SUBWORKFLOW_INPUT_KEY (defined alongside the -# trust-boundary sanitizer in serialization.py) so the child orchestrator can tell a -# trusted sub-orchestration payload apart from untrusted top-level client input. -# -# Nesting is intentionally *not* capped by a depth counter: a workflow graph cannot -# express unbounded recursion (a WorkflowExecutor wraps a concrete Workflow instance, -# so the nesting tree is finite and fixed at build time), and the recursively-derived -# child instance ids grow with depth, so the durable backend's instance-id length -# limit is the natural ceiling for any pathological construction. - - -# ============================================================================ -# Task Types and Data Structures -# ============================================================================ - - -class TaskType(Enum): - """Type of executor task.""" - - AGENT = "agent" - ACTIVITY = "activity" - SUBWORKFLOW = "subworkflow" - - -@dataclass -class TaskMetadata: - """Metadata for a pending task.""" - - executor_id: str - message: Any - source_executor_id: str - task_type: TaskType - remaining_messages: list[tuple[str, Any, str]] | None = None - # For SUBWORKFLOW tasks: the deterministic child orchestration instance id. The - # parent records these in its custom status before awaiting the child so the read - # side can reach nested pending HITL requests while the parent is suspended. - child_instance_id: str | None = None - - -@dataclass -class ExecutorResult: - """Result from executing an agent or activity.""" - - executor_id: str - output_message: AgentExecutorResponse | None - activity_result: dict[str, Any] | None - task_type: TaskType - - -@dataclass -class PendingHITLRequest: - """Tracks a pending Human-in-the-Loop request.""" - - request_id: str - source_executor_id: str - request_data: Any - request_type: str | None - response_type: str | None - - -# ============================================================================ -# Routing Functions -# ============================================================================ - - -def _evaluate_edge_condition_sync(edge: Edge, message: Any) -> bool: - """Evaluate an edge's condition synchronously. - - Durable orchestrators run as generators, so conditions are evaluated - synchronously here; the durabletask host does not support ``async`` edge - conditions. A condition that returns an awaitable cannot be evaluated in - this context, so the edge is treated as *not matched* (not traversed) - rather than assuming a result. - """ - condition = edge._condition # pyright: ignore[reportPrivateUsage] - if condition is None: - return True - result = condition(message) - if inspect.isawaitable(result): - # Async conditions cannot be evaluated in a synchronous orchestrator. - # Close the unawaited coroutine to avoid a "never awaited" warning and - # decline to traverse the edge (treated as not matched). - if inspect.iscoroutine(result): - result.close() - logger.warning( - "Edge condition for %s->%s is async and cannot be evaluated by the durabletask host; " - "the edge is not traversed. Use a synchronous condition.", - edge.source_id, - edge.target_id, - ) - return False - return bool(result) - - -def route_message_through_edge_groups( - edge_groups: list[EdgeGroup], - source_id: str, - message: Any, -) -> list[str]: - """Route a message through edge groups to find target executor IDs.""" - targets: list[str] = [] - - for group in edge_groups: - if source_id not in group.source_executor_ids: - continue - - if isinstance(group, (SwitchCaseEdgeGroup, FanOutEdgeGroup)): - if group.selection_func is not None: - selected = group.selection_func(message, group.target_executor_ids) - targets.extend(selected) - else: - targets.extend(group.target_executor_ids) - - elif isinstance(group, SingleEdgeGroup): - edge = group.edges[0] - if _evaluate_edge_condition_sync(edge, message): - targets.append(edge.target_id) - - elif isinstance(group, FanInEdgeGroup): - pass # Handled separately in the orchestrator loop - - else: - for edge in group.edges: - if edge.source_id == source_id and _evaluate_edge_condition_sync(edge, message): - targets.append(edge.target_id) - - return targets - - -def build_agent_executor_response( - executor_id: str, - response_text: str | None, - structured_response: dict[str, Any] | None, - previous_message: Any, -) -> AgentExecutorResponse: - """Build an AgentExecutorResponse from entity response data.""" - final_text: str = response_text or "" - if structured_response: - final_text = json.dumps(structured_response) - - assistant_message = Message(role="assistant", contents=[final_text]) - agent_response = AgentResponse(messages=[assistant_message]) - - full_conversation: list[Message] = [] - if isinstance(previous_message, AgentExecutorResponse) and previous_message.full_conversation: - full_conversation.extend(previous_message.full_conversation) - elif isinstance(previous_message, str): - full_conversation.append(Message(role="user", contents=[previous_message])) - full_conversation.append(assistant_message) - - return AgentExecutorResponse( - executor_id=executor_id, - agent_response=agent_response, - full_conversation=full_conversation, - ) - - -# ============================================================================ -# Task Preparation Helpers -# ============================================================================ - - -def _prepare_agent_task( - ctx: WorkflowOrchestrationContext, - executor_id: str, - message: Any, - workflow_name: str, -) -> Any: - """Prepare an agent task for execution via the context adapter. - - The agent entity is addressed by the workflow-scoped identity - ``{workflow_name}-{executor_id}`` so two co-hosted workflows that reuse an - executor id dispatch to distinct entities (the entity layer prefixes this with - ``dafx-``). The session *key* stays the orchestration instance id, so - conversation state remains isolated per run. - """ - message_content = _extract_message_content(message) - scoped_id = workflow_scoped_executor_id(workflow_name, executor_id) - return ctx.prepare_agent_task(scoped_id, message_content, ctx.instance_id) - - -def _prepare_activity_task( - ctx: WorkflowOrchestrationContext, - executor_id: str, - message: Any, - source_executor_id: str, - shared_state_snapshot: dict[str, Any] | None, - workflow_name: str, - address: dict[str, str], -) -> Any: - """Prepare an activity task for execution via the context adapter. - - The activity is dispatched under the workflow-scoped name - ``dafx-{workflow_name}-{executor_id}`` so two co-hosted workflows that reuse an - executor id register and dispatch to distinct activity functions. - """ - activity_input = { - "executor_id": executor_id, - "message": serialize_value(message), - "shared_state_snapshot": shared_state_snapshot, - "source_executor_ids": [source_executor_id], - # host_context addresses the *root* (HTTP-routable) orchestration so an executor - # can build a HITL respond URL (see CapturingRunnerContext.host_metadata): - # instance_id / workflow_name name the top-level instance, and - # request_path_prefix is the accumulated ``{executor}~{ordinal}~`` hops from the - # root down to this workflow level. For a top-level workflow the prefix is empty, - # so this reduces to addressing the instance directly. - "host_context": { - HOST_METADATA_INSTANCE_ID: address["root_instance_id"], - HOST_METADATA_WORKFLOW_NAME: address["root_workflow_name"], - HOST_METADATA_REQUEST_PATH_PREFIX: address["request_path_prefix"], - }, - } - activity_input_json = json.dumps(activity_input) - activity_name = workflow_executor_activity_name(workflow_name, executor_id) - return ctx.prepare_activity_task(activity_name, activity_input_json) - - -def _prepare_subworkflow_task( - ctx: WorkflowOrchestrationContext, - executor: WorkflowExecutor, - message: Any, - child_instance_id: str, - child_address: dict[str, str], -) -> Any: - """Prepare a child-orchestration task that runs a ``WorkflowExecutor``'s inner workflow. - - The inner workflow runs as its own durable orchestration (``dafx-{innerName}``), - so its executors are independently durable/observable. The node's message is - serialized and wrapped in a marker so the child orchestrator reconstructs the - original typed object (trusted internal input). A sibling address marker carries - the root instance / workflow name and this child's request-path prefix, so an - executor inside the child can build a respond URL that targets the top-level - instance with a qualified request id. - """ - inner_orchestration_name = workflow_orchestrator_name(executor.workflow.name) - child_input = { - SUBWORKFLOW_INPUT_KEY: serialize_value(message), - SUBWORKFLOW_ADDRESS_KEY: child_address, - } - return ctx.call_sub_orchestrator(inner_orchestration_name, child_input, instance_id=child_instance_id) - - -# ============================================================================ -# Result Processing Helpers -# ============================================================================ - - -def _process_agent_response( - agent_response: AgentResponse, - executor_id: str, - message: Any, -) -> ExecutorResult: - """Process an agent response into an ExecutorResult.""" - response_text = agent_response.text if agent_response else None - structured_response: dict[str, Any] | None = None - - if agent_response and agent_response.value is not None: - model_dump = getattr(agent_response.value, "model_dump", None) - if callable(model_dump): - dumped = model_dump() - if isinstance(dumped, dict): - structured_response = dumped # type: ignore[assignment] - elif isinstance(agent_response.value, dict): - structured_response = agent_response.value - - output_message = build_agent_executor_response( - executor_id=executor_id, - response_text=response_text, - structured_response=structured_response, - previous_message=message, - ) - - return ExecutorResult( - executor_id=executor_id, - output_message=output_message, - activity_result=None, - task_type=TaskType.AGENT, - ) - - -def _process_activity_result( - result_json: str | None, - executor_id: str, - shared_state: dict[str, Any] | None, - workflow_outputs: list[Any], -) -> ExecutorResult: - """Process an activity result and apply shared state updates.""" - result = json.loads(result_json) if result_json else None - - if shared_state is not None and result: - if result.get("shared_state_updates"): - updates = result["shared_state_updates"] - logger.debug("[workflow] Applying SharedState updates from %s: %s", executor_id, updates) - shared_state.update(updates) - if result.get("shared_state_deletes"): - deletes = result["shared_state_deletes"] - logger.debug("[workflow] Applying SharedState deletes from %s: %s", executor_id, deletes) - for key in deletes: - shared_state.pop(key, None) - - if result and result.get("outputs"): - workflow_outputs.extend(result["outputs"]) - - return ExecutorResult( - executor_id=executor_id, - output_message=None, - activity_result=result, - task_type=TaskType.ACTIVITY, - ) - - -def _unpack_subworkflow_result(child_result: Any) -> tuple[list[Any], list[dict[str, Any]]]: - """Split a child orchestration's return value into ``(outputs, events)``. - - A child run by this engine returns a :data:`SUBWORKFLOW_RESULT_KEY` envelope of - ``{"outputs": [...], "events": [...]}``. A bare list / ``None`` (a child that - produced no envelope, or a defensively-handled legacy shape) is treated as - outputs with no events. - """ - if isinstance(child_result, dict): - envelope = cast("dict[str, Any]", child_result) - if envelope.get(SUBWORKFLOW_RESULT_KEY): - raw_outputs = envelope.get("outputs") - outputs = cast("list[Any]", raw_outputs) if isinstance(raw_outputs, list) else [] - raw_events = envelope.get("events") - events = cast("list[dict[str, Any]]", raw_events) if isinstance(raw_events, list) else [] - return outputs, events - if isinstance(child_result, list): - return cast("list[Any]", child_result), [] - if child_result is None: - return [], [] - return [child_result], [] - - -def _process_subworkflow_result( - child_result: Any, - executor: WorkflowExecutor, - workflow_outputs: list[Any], -) -> ExecutorResult: - """Process a child orchestration's result into an ``ExecutorResult``. - - The child orchestration returns a result envelope (see - :data:`SUBWORKFLOW_RESULT_KEY`) carrying the inner workflow's outputs (a list of - values already encoded by the inner activity via ``serialize_value``) plus its - accumulated event timeline. Mirroring the in-process - :class:`~agent_framework.WorkflowExecutor`: - - * ``allow_direct_output`` is ``False`` (default): each inner output becomes a - message routed through the ``WorkflowExecutor`` node's outgoing edges. - * ``allow_direct_output`` is ``True``: each inner output becomes one of the - parent workflow's own outputs. - - The inner workflow's *intermediate* events are bubbled into the parent's event - stream **re-tagged with this node's id** (``executor.id``), matching the - in-process ``WorkflowExecutor`` which forwards child intermediate emissions as - ``WorkflowEvent("intermediate", executor_id=self.id, ...)`` so an outer observer - sees nested progress without needing to know the child's internal executor - layout. Other inner event types are intentionally not re-emitted: inner *outputs* - already flow back as this node's outputs/messages above, and inner lifecycle - events (invoked/completed) are child-internal detail. - """ - outputs, child_events = _unpack_subworkflow_result(child_result) - - sent_messages: list[dict[str, Any]] = [] - if executor.allow_direct_output: - # Inner outputs are already serialized (serialize_value); workflow_outputs - # holds serialized values, so they are directly compatible. - workflow_outputs.extend(outputs) - else: - # Route each inner output as a message from the node; _route_result_messages - # deserializes each "message" value before routing through edge groups. - sent_messages = [{"message": output, "target_id": None, "source_id": executor.id} for output in outputs] - - # Bubble the child's intermediate events up, re-tagged with this node's id (see - # docstring). These already-serialized event dicts are appended to the parent's - # timeline by the caller via append_activity_events (which re-stamps iteration). - bubbled_events = [ - {**event, "executor_id": executor.id} for event in child_events if event.get("type") == "intermediate" - ] - - return ExecutorResult( - executor_id=executor.id, - output_message=None, - activity_result={"sent_messages": sent_messages, "outputs": [], "events": bubbled_events}, - task_type=TaskType.SUBWORKFLOW, - ) - - -# ============================================================================ -# Routing Helpers -# ============================================================================ - - -def _route_result_messages( - result: ExecutorResult, - workflow: Workflow, - next_pending_messages: dict[str, list[tuple[Any, str]]], - fan_in_pending: dict[str, dict[str, list[tuple[Any, str]]]], -) -> None: - """Route messages from an executor result to their targets.""" - executor_id = result.executor_id - messages_to_route: list[tuple[Any, str | None]] = [] - - if result.output_message: - messages_to_route.append((result.output_message, None)) - - if result.activity_result and result.activity_result.get("sent_messages"): - for msg_data in result.activity_result["sent_messages"]: - sent_msg = msg_data.get("message") - target_id = msg_data.get("target_id") - # Use an explicit None check so legitimately falsy payloads - # (empty string, 0, False) are still routed. - if sent_msg is not None: - sent_msg = deserialize_value(sent_msg) - messages_to_route.append((sent_msg, target_id)) - - for msg_to_route, explicit_target in messages_to_route: - logger.debug("Routing output from %s", executor_id) - - if explicit_target: - if explicit_target not in next_pending_messages: - next_pending_messages[explicit_target] = [] - next_pending_messages[explicit_target].append((msg_to_route, executor_id)) - logger.debug("Routed message from %s to explicit target %s", executor_id, explicit_target) - continue - - for group in workflow.edge_groups: - if isinstance(group, FanInEdgeGroup) and executor_id in group.source_executor_ids: - fan_in_pending[group.id][executor_id].append((msg_to_route, executor_id)) - logger.debug("Accumulated message for FanIn group %s from %s", group.id, executor_id) - - targets = route_message_through_edge_groups(workflow.edge_groups, executor_id, msg_to_route) - - for target_id in targets: - logger.debug("Routing to %s", target_id) - if target_id not in next_pending_messages: - next_pending_messages[target_id] = [] - next_pending_messages[target_id].append((msg_to_route, executor_id)) - - -def _check_fan_in_ready( - workflow: Workflow, - fan_in_pending: dict[str, dict[str, list[tuple[Any, str]]]], - next_pending_messages: dict[str, list[tuple[Any, str]]], -) -> None: - """Check if any FanInEdgeGroups are ready and deliver their messages.""" - for group in workflow.edge_groups: - if not isinstance(group, FanInEdgeGroup): - continue - - pending_sources = fan_in_pending.get(group.id, {}) - - if not all(src in pending_sources and pending_sources[src] for src in group.source_executor_ids): - continue - - aggregated: list[Any] = [] - aggregated_sources: list[str] = [] - for src in group.source_executor_ids: - for msg, msg_source in pending_sources[src]: - aggregated.append(msg) - aggregated_sources.append(msg_source) - - target_id = group.target_executor_ids[0] - logger.debug("FanIn group %s ready, delivering %d messages to %s", group.id, len(aggregated), target_id) - - if target_id not in next_pending_messages: - next_pending_messages[target_id] = [] - - first_source = aggregated_sources[0] if aggregated_sources else "__fan_in__" - next_pending_messages[target_id].append((aggregated, first_source)) - - fan_in_pending[group.id] = defaultdict(list) - - -# ============================================================================ -# HITL Helpers -# ============================================================================ - - -def _collect_hitl_requests( - result: ExecutorResult, - pending_hitl_requests: dict[str, PendingHITLRequest], -) -> None: - """Collect pending HITL requests from an activity result.""" - if result.activity_result and result.activity_result.get("pending_request_info_events"): - for req_data in result.activity_result["pending_request_info_events"]: - request_id = req_data.get("request_id") - if request_id: - pending_hitl_requests[request_id] = PendingHITLRequest( - request_id=request_id, - source_executor_id=req_data.get("source_executor_id", result.executor_id), - request_data=req_data.get("data"), - request_type=req_data.get("request_type"), - response_type=req_data.get("response_type"), - ) - logger.debug( - "Collected HITL request %s from executor %s", - request_id, - result.executor_id, - ) - - -def _route_hitl_response( - hitl_request: PendingHITLRequest, - raw_response: Any, - pending_messages: dict[str, list[tuple[Any, str]]], -) -> None: - """Route a HITL response back to the source executor's @response_handler.""" - response_message = { - "request_id": hitl_request.request_id, - "original_request": hitl_request.request_data, - "response": raw_response, - "response_type": hitl_request.response_type, - } - - target_id = hitl_request.source_executor_id - if target_id not in pending_messages: - pending_messages[target_id] = [] - - source_id = f"{SOURCE_HITL_RESPONSE}_{hitl_request.request_id}" - pending_messages[target_id].append((response_message, source_id)) - - logger.debug( - "Routed HITL response for request %s to executor %s", - hitl_request.request_id, - target_id, - ) - - -# ============================================================================ -# Message Content Extraction -# ============================================================================ - - -def _extract_message_content(message: Any) -> str: - """Extract text content from various message types.""" - message_content = "" - if isinstance(message, AgentExecutorResponse) and message.agent_response: - if message.agent_response.text: - message_content = message.agent_response.text - elif message.agent_response.messages: - message_content = message.agent_response.messages[-1].text or "" - elif isinstance(message, AgentExecutorRequest) and message.messages: - message_content = message.messages[-1].text or "" - elif isinstance(message, dict): - key_names = list(message.keys()) # type: ignore[union-attr] - logger.warning("Unexpected dict message in _extract_message_content. Keys: %s", key_names) # type: ignore - elif isinstance(message, str): - message_content = message - return message_content - - -def _select_primary_input_type(executor: Executor) -> type | None: - """Return the executor's primary concrete declared input type, if any. - - The first declared input type that is a concrete class is used; union or - unannotated types yield ``None`` (the caller then passes the value through - unchanged). - """ - for input_type in executor.input_types: - if isinstance(input_type, type): - return input_type - return None - - -def _try_unwrap_subworkflow_input(raw_value: Any) -> tuple[bool, Any]: - """Detect and unwrap a sub-orchestration input marker. - - Returns ``(True, inner)`` when ``raw_value`` is the parent-supplied marker - payload (see :data:`SUBWORKFLOW_INPUT_KEY`), with ``inner`` reconstructed from - the wrapped, parent-serialized message. Returns ``(False, None)`` otherwise. - - Kept separate from :func:`_coerce_initial_input` so the ``isinstance`` narrowing - here does not leak into that function's untyped ``raw_value`` coercion path. - """ - if isinstance(raw_value, dict): - marker_input = cast("dict[str, Any]", raw_value) - if SUBWORKFLOW_INPUT_KEY in marker_input: - return True, deserialize_value(marker_input[SUBWORKFLOW_INPUT_KEY]) - return False, None - - -def _resolve_workflow_address(initial_message: Any, instance_id: str, workflow_name: str) -> dict[str, str]: - """Resolve this orchestration's HITL address context. - - Returns ``{root_instance_id, root_workflow_name, request_path_prefix}`` -- the - values an executor needs to build a respond URL that targets the addressable - top-level instance with a (possibly qualified) request id: - - * A **child** orchestration receives its address from the parent in the - :data:`SUBWORKFLOW_ADDRESS_KEY` marker (the root instance/workflow plus the - ``{executor}~{ordinal}~`` path prefix down to this level), since its own - ``ctx.instance_id`` is a non-addressable child id. - * A **top-level** workflow has no such marker (it is stripped from untrusted input - at the host boundary by :func:`strip_subworkflow_markers`), so it is its own root - with an empty prefix. - """ - if isinstance(initial_message, dict): - marker = cast("dict[str, Any]", initial_message) - addr = marker.get(SUBWORKFLOW_ADDRESS_KEY) - if isinstance(addr, dict): - typed = cast("dict[str, Any]", addr) - root_instance_id = typed.get("root_instance_id") - root_workflow_name = typed.get("root_workflow_name") - request_path_prefix = typed.get("request_path_prefix") - if ( - isinstance(root_instance_id, str) - and isinstance(root_workflow_name, str) - and isinstance(request_path_prefix, str) - ): - return { - "root_instance_id": root_instance_id, - "root_workflow_name": root_workflow_name, - "request_path_prefix": request_path_prefix, - } - return { - "root_instance_id": instance_id, - "root_workflow_name": workflow_name, - "request_path_prefix": "", - } - - -def _coerce_initial_input(workflow: Workflow, raw_value: Any) -> Any: - """Coerce the client's initial workflow input to the start executor's type. - - A durable workflow runs as a durable orchestration, so its initial payload - arrives as plain JSON via ``context.get_input()`` -- without the type markers - that inter-executor messages carry (those are reconstructed by - :func:`deserialize_value`). This single entry hop therefore needs explicit - reconstruction to mirror in-process delivery, where the start executor - receives its declared type: - - * Agent start executors only consume text, so non-text input is stringified. - * Other executors get their primary declared input type reconstructed - (``dict`` -> Pydantic/dataclass, ``str`` -> ``str``, ...) via - :func:`reconstruct_to_type`; union/unannotated types pass through unchanged. - - A sub-orchestration payload (a ``WorkflowExecutor`` invoking this workflow as a - child) carries the node's message wrapped in :data:`SUBWORKFLOW_INPUT_KEY`. That - is trusted internal data the parent produced with :func:`serialize_value`, so it - is reconstructed directly to the original typed object -- mirroring the - in-process ``WorkflowExecutor`` which passes its input straight to the inner - workflow -- without the HTTP-boundary pickle-marker stripping. - """ - unwrapped, inner_input = _try_unwrap_subworkflow_input(raw_value) - if unwrapped: - return inner_input - - start_executor = workflow.executors.get(workflow.start_executor_id) - if start_executor is None: - return raw_value - - if isinstance(start_executor, AgentExecutor): - if isinstance(raw_value, str): - return raw_value - if isinstance(raw_value, (dict, list)): - return json.dumps(raw_value) - return str(raw_value) - - input_type = _select_primary_input_type(start_executor) - if input_type is None: - return strip_pickle_markers(raw_value) - # The initial payload is untrusted external input (HTTP body / client input) with no - # legitimate checkpoint type markers, so neutralize any pickle-marker injection before - # it can reach deserialize_value() inside reconstruct_to_type() (avoids pickle RCE). - return reconstruct_to_type(strip_pickle_markers(raw_value), input_type) - - -# ============================================================================ -# HITL Response Handler Execution -# ============================================================================ - - -async def execute_hitl_response_handler( - executor: Any, - hitl_message: dict[str, Any], - shared_state: State, - runner_context: Any, -) -> None: - """Execute a HITL response handler on an executor. - - Args: - executor: The executor instance that has a @response_handler. - hitl_message: The HITL response message dict. - shared_state: The shared state for the workflow context. - runner_context: The runner context for capturing outputs. - """ - from agent_framework._workflows._workflow_context import WorkflowContext - - original_request_data = hitl_message.get("original_request") - response_data = hitl_message.get("response") - response_type_str = hitl_message.get("response_type") - - original_request = deserialize_value(original_request_data) - response = _deserialize_hitl_response(response_data, response_type_str) - - handler = executor._find_response_handler(original_request, response) - - if handler is None: - logger.warning( - "No response handler found for HITL response in executor %s. Request type: %s, Response type: %s", - executor.id, - type(original_request).__name__, - type(response).__name__, - ) - return - - ctx = WorkflowContext( - executor=executor, - source_executor_ids=[SOURCE_HITL_RESPONSE], - runner_context=runner_context, - state=shared_state, - ) - - logger.debug( - "Invoking response handler for HITL request in executor %s", - executor.id, - ) - await handler(response, ctx) - - -def _deserialize_hitl_response(response_data: Any, response_type_str: str | None) -> Any: - """Deserialize a HITL response to its expected type.""" - logger.debug( - "Deserializing HITL response. response_type_str=%s, response_data type=%s", - response_type_str, - type(response_data).__name__, - ) - - if response_data is None: - return None - - response_data = strip_pickle_markers(response_data) - if response_data is None: - return None - - if not isinstance(response_data, dict): - logger.debug("Response data is not a dict, returning as-is: %s", type(response_data).__name__) - return response_data - - if response_type_str: - response_type = resolve_type(response_type_str) - if response_type: - logger.debug("Found response type %s, attempting reconstruction", response_type) - result = reconstruct_to_type(response_data, response_type) - logger.debug("Reconstructed response type: %s", type(result).__name__) - return result - logger.warning("Could not resolve response type: %s", response_type_str) - - logger.debug("No type hint; returning sanitized data as-is") - return response_data # type: ignore[reportUnknownVariableType] - - -# ============================================================================ -# Task Preparation (All Tasks) -# ============================================================================ - - -def _prepare_all_tasks( - ctx: WorkflowOrchestrationContext, - workflow: Workflow, - pending_messages: dict[str, list[tuple[Any, str]]], - shared_state: dict[str, Any] | None, - subworkflow_counter: list[int], - address: dict[str, str], -) -> tuple[list[Any], list[TaskMetadata], list[tuple[str, Any, str]]]: - """Prepare all pending tasks for parallel execution. - - Groups agent messages by executor ID so that only the first message per agent - runs in the parallel batch. Additional messages to the same agent are returned - for sequential processing. A :class:`~agent_framework.WorkflowExecutor` node is - dispatched as a durable child orchestration (one per message), with a - deterministic child instance id derived from the parent so replay is stable. - - Args: - ctx: The orchestration context used to schedule activities, entity calls, - and child orchestrations. - workflow: The workflow whose executors are being dispatched. - pending_messages: Messages to deliver this superstep, grouped by target - executor id, each paired with its source executor id. - shared_state: Optional dict for cross-executor state sharing. - subworkflow_counter: A single-element mutable counter, persistent across - supersteps, used to derive unique deterministic child instance ids. - address: This orchestration's HITL address context - (``{root_instance_id, root_workflow_name, request_path_prefix}``). Surfaced - to activity executors via ``host_context`` and extended by one - ``{executor}~{ordinal}~`` hop for each dispatched sub-workflow child. - """ - all_tasks: list[Any] = [] - task_metadata_list: list[TaskMetadata] = [] - remaining_agent_messages: list[tuple[str, Any, str]] = [] - - agent_messages_by_executor: dict[str, list[tuple[str, Any, str]]] = defaultdict(list) - - # Per-executor, per-superstep ordinal for sub-workflow dispatch. This must match the - # read side's enumerate() index into the custom-status ``subworkflows[executorId]`` - # list (built in this same dispatch order), so a nested pending request resolves - # back to the right child. It is deliberately distinct from ``subworkflow_counter`` - # (a global, cross-superstep counter that only guarantees child-instance-id - # uniqueness, not addressing position). - per_executor_sub_ordinal: dict[str, int] = defaultdict(int) - - for executor_id, messages_with_sources in pending_messages.items(): - executor = workflow.executors[executor_id] - is_agent = isinstance(executor, AgentExecutor) - is_subworkflow = isinstance(executor, WorkflowExecutor) - - for message, source_executor_id in messages_with_sources: - if is_agent: - agent_messages_by_executor[executor_id].append((executor_id, message, source_executor_id)) - elif is_subworkflow: - # Derive a deterministic, globally-unique child instance id. The counter - # persists across supersteps, so two invocations of the same node (in the - # same or different supersteps, e.g. fan-out) never collide, and the ids - # are stable across orchestration replay. - child_instance_id = f"{ctx.instance_id}::{executor_id}::{subworkflow_counter[0]}" - subworkflow_counter[0] += 1 - # Extend this orchestration's request-path prefix by one hop - # (``{executor}~{ordinal}~``) so an executor inside the child builds a - # respond URL qualified all the way back to the root instance. - ordinal = per_executor_sub_ordinal[executor_id] - per_executor_sub_ordinal[executor_id] += 1 - child_address = { - "root_instance_id": address["root_instance_id"], - "root_workflow_name": address["root_workflow_name"], - "request_path_prefix": address["request_path_prefix"] - + qualify_subworkflow_request_id(executor_id, ordinal, ""), - } - logger.debug("Preparing sub-workflow task: %s -> %s", executor_id, child_instance_id) - task = _prepare_subworkflow_task(ctx, executor, message, child_instance_id, child_address) - all_tasks.append(task) - task_metadata_list.append( - TaskMetadata( - executor_id=executor_id, - message=message, - source_executor_id=source_executor_id, - task_type=TaskType.SUBWORKFLOW, - child_instance_id=child_instance_id, - ) - ) - else: - logger.debug("Preparing activity task: %s", executor_id) - task = _prepare_activity_task( - ctx, executor_id, message, source_executor_id, shared_state, workflow.name, address - ) - all_tasks.append(task) - task_metadata_list.append( - TaskMetadata( - executor_id=executor_id, - message=message, - source_executor_id=source_executor_id, - task_type=TaskType.ACTIVITY, - ) - ) - - for executor_id, messages_list in agent_messages_by_executor.items(): - first_msg = messages_list[0] - remaining = messages_list[1:] - - logger.debug("Preparing agent task: %s", executor_id) - task = _prepare_agent_task(ctx, first_msg[0], first_msg[1], workflow.name) - all_tasks.append(task) - task_metadata_list.append( - TaskMetadata( - executor_id=first_msg[0], - message=first_msg[1], - source_executor_id=first_msg[2], - task_type=TaskType.AGENT, - ) - ) - - remaining_agent_messages.extend(remaining) - - return all_tasks, task_metadata_list, remaining_agent_messages - - -def _index_subworkflows(task_metadata_list: list[TaskMetadata]) -> dict[str, list[str]]: - """Group dispatched sub-workflow child instance ids by executor id, in dispatch order. - - This is the read-side addressing map the parent publishes to its custom status so the - status/respond endpoints can resolve a nested pending request: a request qualified as - ``{executorId}~{ordinal}~{bare}`` maps to ``subworkflows[executorId][ordinal]``. That - ordinal is the child's position in this list, which must equal the write-side ordinal - :func:`_prepare_all_tasks` stamps into the child's request-path prefix. Both derive from - the same ``task_metadata_list`` order, so building the map here in one place keeps the - two sides from drifting (guarded by ``test_readside_index_matches_dispatch_ordinal``). - """ - subworkflows: dict[str, list[str]] = {} - for meta in task_metadata_list: - if meta.task_type == TaskType.SUBWORKFLOW and meta.child_instance_id is not None: - subworkflows.setdefault(meta.executor_id, []).append(meta.child_instance_id) - return subworkflows - - -# ============================================================================ -# Main Orchestrator -# ============================================================================ - - -def run_workflow_orchestrator( - ctx: WorkflowOrchestrationContext, - workflow: Workflow, - initial_message: Any, - shared_state: dict[str, Any] | None = None, -) -> Generator[Any, Any, list[Any] | dict[str, Any]]: - """Traverse and execute the workflow graph as a durable orchestration. - - This is a generator-based orchestrator that works with any host by - programming against the :class:`WorkflowOrchestrationContext` protocol. - - Supports: - - SingleEdgeGroup: Direct 1:1 routing with optional condition - - SwitchCaseEdgeGroup: First matching condition wins - - FanOutEdgeGroup: Broadcast to multiple targets (parallel execution) - - FanInEdgeGroup: Aggregates messages from multiple sources - - SharedState: Cross-executor state sharing (local to orchestration) - - HITL: Human-in-the-loop via request_info / @response_handler - - Args: - ctx: Host-specific orchestration context adapter. - workflow: The MAF Workflow instance to execute. - initial_message: Initial message to send to the start executor. When this - workflow runs as a sub-workflow, this is the parent-supplied marker - payload (see :data:`SUBWORKFLOW_INPUT_KEY`). - shared_state: Optional dict for cross-executor state sharing. - - Returns: - For a top-level run, the list of workflow outputs collected from executor - activities. For a sub-workflow run (``initial_message`` carries - :data:`SUBWORKFLOW_INPUT_KEY`), a :data:`SUBWORKFLOW_RESULT_KEY` envelope - ``{"outputs": [...], "events": [...]}`` so the parent can bubble nested - progress. - """ - pending_messages: dict[str, list[tuple[Any, str]]] = { - workflow.start_executor_id: [(_coerce_initial_input(workflow, initial_message), SOURCE_WORKFLOW_START)] - } - workflow_outputs: list[Any] = [] - iteration = 0 - - # When this run is itself a sub-workflow (the parent dispatched it via - # call_sub_orchestrator with a SUBWORKFLOW_INPUT_KEY envelope), the orchestrator - # returns a SUBWORKFLOW_RESULT_KEY envelope so the parent recovers both the inner - # outputs and the inner event timeline. A top-level run returns a bare list, so the - # external client output path is unchanged. - is_subworkflow = isinstance(initial_message, dict) and SUBWORKFLOW_INPUT_KEY in initial_message - - # Resolve the HITL address context once: a child orchestration inherits the root - # instance/workflow + path prefix from the parent's address marker; a top-level - # workflow is its own root with an empty prefix. Threaded into task dispatch so an - # executor at any depth can build a respond URL targeting the addressable top-level - # instance. - workflow_address = _resolve_workflow_address(initial_message, ctx.instance_id, workflow.name) - - # Monotonic, replay-stable counter for deriving child orchestration instance ids; - # persists across supersteps so repeated sub-workflow invocations never collide. - subworkflow_counter: list[int] = [0] - - # Accumulate workflow events and publish them to the orchestration custom status - # after each superstep so an external client can stream progress by polling. - # Non-agent executors are run inside a durable activity that captures their events - # with data payloads (replayed via append_activity_events); agents contribute only - # synthesized invoked/completed lifecycle events. Events are per executor / per - # yielded output, not token-level, and accumulate for the run. - # - # Only hosts that stream this timeline (ctx.supports_event_streaming) accumulate - # and publish it. The Azure Functions host opts out: its custom status is capped - # at 16 KB and it has no event-streaming endpoint, so accumulating the log would - # only grow orchestrator memory and overflow the cap on publish. - live_events: list[dict[str, Any]] = [] - - def emit_event(event_type: str, executor_id: str) -> None: - if not ctx.supports_event_streaming: - return - live_events.append({"type": event_type, "executor_id": executor_id, "iteration": iteration}) - - def append_activity_events(activity_result: dict[str, Any] | None) -> None: - # Replay the events captured inside the activity, tagging each with the current - # superstep iteration so clients can group events by superstep. - if not ctx.supports_event_streaming or not activity_result: - return - captured = activity_result.get("events") - if not isinstance(captured, list): - return - for serialized_event in cast("list[dict[str, Any]]", captured): - enriched = dict(serialized_event) - enriched["iteration"] = iteration - live_events.append(enriched) - - def publish_live_status( - state: str, - pending_requests: dict[str, Any] | None = None, - subworkflows: dict[str, list[str]] | None = None, - ) -> None: - # Publish only on live execution so events are not re-emitted on replay - # (the custom status set during the first execution already persisted). - if ctx.is_replaying: - return - status: dict[str, Any] = {"state": state} - # Hosts that don't stream the event timeline (e.g. Azure Functions, whose - # custom status is 16 KB-capped) omit the events key entirely, preserving the - # compact {state, pending_requests} status those hosts expect. - if ctx.supports_event_streaming: - status["events"] = live_events - if pending_requests is not None: - status["pending_requests"] = pending_requests - # Map of {executorId: [childInstanceId, ...]} for sub-workflows dispatched this - # superstep. A single WorkflowExecutor node can receive several messages in one - # superstep and dispatch one child each, so the value is a list indexed by - # dispatch order; the read side qualifies nested pending requests by - # (executorId, ordinal) so every child stays addressable behind one top-level surface. - if subworkflows: - status["subworkflows"] = subworkflows - ctx.set_custom_status(status) - - fan_in_pending: dict[str, dict[str, list[tuple[Any, str]]]] = { - group.id: defaultdict(list) for group in workflow.edge_groups if isinstance(group, FanInEdgeGroup) - } - - pending_hitl_requests: dict[str, PendingHITLRequest] = {} - - while pending_messages and iteration < workflow.max_iterations: - logger.debug("Orchestrator iteration %d", iteration) - next_pending_messages: dict[str, list[tuple[Any, str]]] = {} - - # Phase 1: Prepare all tasks - all_tasks, task_metadata_list, remaining_agent_messages = _prepare_all_tasks( - ctx, workflow, pending_messages, shared_state, subworkflow_counter, workflow_address - ) - - # Agents and sub-workflows bypass the per-executor activity, so synthesize their - # invoked event here; activity executors emit their own events from inside the - # activity. - for task_meta in task_metadata_list: - if task_meta.task_type in (TaskType.AGENT, TaskType.SUBWORKFLOW): - emit_event("executor_invoked", task_meta.executor_id) - for invoked_executor_id, _invoked_message, _invoked_source in remaining_agent_messages: - emit_event("executor_invoked", invoked_executor_id) - - # Phase 2: Execute all tasks in parallel - all_results: list[ExecutorResult] = [] - if all_tasks: - logger.debug("Executing %d tasks in parallel (agents + activities)", len(all_tasks)) - # Record dispatched sub-workflow child instance ids before suspending in - # task_all. While a nested sub-workflow waits for human input, this parent - # stays suspended here, so its custom status must already carry the child ids - # for the read side to discover and qualify nested pending requests (see - # _index_subworkflows for the dispatch-order / ordinal addressing contract). - active_subworkflows = _index_subworkflows(task_metadata_list) - if active_subworkflows: - publish_live_status("running", subworkflows=active_subworkflows) - raw_results = yield ctx.task_all(all_tasks) - logger.debug("All %d tasks completed", len(all_tasks)) - - for idx, raw_result in enumerate(raw_results): - metadata = task_metadata_list[idx] - if metadata.task_type == TaskType.AGENT: - result = _process_agent_response(raw_result, metadata.executor_id, metadata.message) - emit_event("executor_completed", metadata.executor_id) - elif metadata.task_type == TaskType.SUBWORKFLOW: - subworkflow_executor = cast(WorkflowExecutor, workflow.executors[metadata.executor_id]) - result = _process_subworkflow_result(raw_result, subworkflow_executor, workflow_outputs) - # Bubble the child's (re-tagged) intermediate events into this - # parent's timeline before the node's completed event, preserving - # chronological order: node invoked -> child progress -> completed. - append_activity_events(result.activity_result) - emit_event("executor_completed", metadata.executor_id) - else: - result = _process_activity_result(raw_result, metadata.executor_id, shared_state, workflow_outputs) - append_activity_events(result.activity_result) - all_results.append(result) - - # Phase 3: Process sequential agent messages - for executor_id, message, _source_executor_id in remaining_agent_messages: - logger.debug("Processing sequential message for agent: %s", executor_id) - task = _prepare_agent_task(ctx, executor_id, message, workflow.name) - agent_response: AgentResponse = yield task - logger.debug("Agent %s sequential response completed", executor_id) - - result = _process_agent_response(agent_response, executor_id, message) - all_results.append(result) - emit_event("executor_completed", executor_id) - - # Phase 4: Collect HITL requests - for result in all_results: - _collect_hitl_requests(result, pending_hitl_requests) - - # Phase 5: Route results - for result in all_results: - _route_result_messages(result, workflow, next_pending_messages, fan_in_pending) - - # Phase 6: Check fan-in readiness - _check_fan_in_ready(workflow, fan_in_pending, next_pending_messages) - - pending_messages = next_pending_messages - - # Publish accumulated events after each superstep. When the workflow is about - # to pause for human input, the HITL block below publishes the waiting status - # with the pending requests instead. - if pending_messages or not pending_hitl_requests: - publish_live_status("running") - - # Phase 7: HITL wait - if not pending_messages and pending_hitl_requests: - logger.debug("Workflow paused for HITL - %d pending requests", len(pending_hitl_requests)) - - publish_live_status( - "waiting_for_human_input", - pending_requests={ - req_id: { - "request_id": req.request_id, - "source_executor_id": req.source_executor_id, - "data": req.request_data, - "request_type": req.request_type, - "response_type": req.response_type, - } - for req_id, req in pending_hitl_requests.items() - }, - ) - - for request_id, hitl_request in list(pending_hitl_requests.items()): - # Wait indefinitely for the human response, matching MAF core's - # request_info (and the .NET durable host); the durable orchestration - # simply stays paused until a response arrives. A payload rejected by - # sanitization (pickle/type markers) does not consume the request, so - # the caller can resubmit a corrected response. - while True: - logger.debug("Waiting for HITL response for request: %s", request_id) - - raw_response = yield ctx.wait_for_external_event(request_id) - logger.debug( - "Received HITL response for request %s. Type: %s, Value: %s", - request_id, - type(raw_response).__name__, - raw_response, - ) - - if isinstance(raw_response, str): - try: - raw_response = json.loads(raw_response) - logger.debug("Parsed JSON string response to: %s", type(raw_response).__name__) - except (json.JSONDecodeError, TypeError): - logger.debug("Response is not JSON, keeping as string") - - # Sanitize against pickle-marker injection in case a caller bypassed - # DurableWorkflowClient.send_hitl_response and raised the external - # event directly (e.g. via the raw DTS client). Sanitize *before* - # consuming the request so a rejected payload can be resubmitted. - sanitized_response = strip_pickle_markers(raw_response) - if sanitized_response is None and raw_response is not None: - logger.warning( - "Rejected HITL response for request %s: payload contained " - "disallowed pickle/type markers. Awaiting a new response.", - request_id, - ) - continue - - del pending_hitl_requests[request_id] - _route_hitl_response( - hitl_request, - sanitized_response, - pending_messages, - ) - break - - publish_live_status("running") - - iteration += 1 - - # Match the core WorkflowRunner: if the loop stopped because max_iterations - # was reached while messages are still pending, the workflow did not converge. - if pending_messages: - raise WorkflowConvergenceException(f"Workflow did not converge after {workflow.max_iterations} iterations.") - - # A sub-workflow returns the outputs + event timeline envelope so the parent can - # bubble nested progress; a top-level run returns the bare outputs list. - if is_subworkflow: - return {SUBWORKFLOW_RESULT_KEY: True, "outputs": workflow_outputs, "events": live_events} - return workflow_outputs # ruff:ignore[return-in-generator] diff --git a/python/packages/durabletask/agent_framework_durabletask/_workflows/registration.py b/python/packages/durabletask/agent_framework_durabletask/_workflows/registration.py deleted file mode 100644 index 63bfcafaa12..00000000000 --- a/python/packages/durabletask/agent_framework_durabletask/_workflows/registration.py +++ /dev/null @@ -1,137 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Host-agnostic plan for registering a MAF Workflow as a durable orchestration. - -A MAF :class:`Workflow` is hosted by turning each graph node into a durable -primitive: - -- each :class:`AgentExecutor` becomes a durable **entity**, -- each :class:`WorkflowExecutor` (a nested sub-workflow) becomes a durable - **child orchestration**, and -- each other :class:`Executor` becomes a durable **activity**, - -driven by a single workflow **orchestrator**. - -The *decision* of which executor maps to which primitive is identical on every -host (Azure Functions or a standalone durabletask worker); only the *mechanism* -for registering them differs (Functions trigger decorators vs. -``worker.add_*``). :func:`plan_workflow_registration` captures the shared -decision so each host applies one consistent plan with its own registration -mechanism — analogous to .NET's shared ``DurableWorkflowOptions`` feeding -host-specific trigger generation. - -Sub-workflows nest: a hosted workflow may contain :class:`WorkflowExecutor` -nodes whose inner workflows must themselves be registered (their orchestrator, -agents, and activities) so the parent can drive them via -``call_sub_orchestrator``. :func:`collect_hosted_workflows` walks that tree so a -host registers every reachable workflow exactly once. -""" - -from __future__ import annotations - -from collections.abc import Iterator -from dataclasses import dataclass - -from agent_framework import AgentExecutor, Executor, Workflow, WorkflowExecutor - - -@dataclass -class WorkflowRegistrationPlan: - """The durable primitives a workflow registers, independent of host. - - Attributes: - agent_executors: Agent executors to register as durable entities. The - full :class:`AgentExecutor` is carried (not just its agent) so each - host can register the entity under the executor's ``id`` — the same - identity the orchestrator dispatches to — which keeps - ``AgentExecutor(agent, id=...)`` working when the id differs from - ``agent.name``. - activity_executors: Non-agent, non-subworkflow executors to register as - durable activities. - subworkflow_executors: :class:`WorkflowExecutor` nodes whose inner - workflows are driven as durable child orchestrations. The node itself - is *not* registered as an activity; its inner workflow is registered - separately (see :func:`collect_hosted_workflows`). - """ - - agent_executors: list[AgentExecutor] - activity_executors: list[Executor] - subworkflow_executors: list[WorkflowExecutor] - - -def plan_workflow_registration(workflow: Workflow) -> WorkflowRegistrationPlan: - """Classify a workflow's executors into the durable primitives to register. - - Args: - workflow: The MAF :class:`Workflow` to host. - - Returns: - A :class:`WorkflowRegistrationPlan` describing the agent executors - (entities), sub-workflow executors (child orchestrations), and the - remaining non-agent executors (activities). - """ - agent_executors: list[AgentExecutor] = [] - activity_executors: list[Executor] = [] - subworkflow_executors: list[WorkflowExecutor] = [] - - for executor in workflow.executors.values(): - if isinstance(executor, AgentExecutor): - agent_executors.append(executor) - elif isinstance(executor, WorkflowExecutor): - subworkflow_executors.append(executor) - else: - activity_executors.append(executor) - - return WorkflowRegistrationPlan( - agent_executors=agent_executors, - activity_executors=activity_executors, - subworkflow_executors=subworkflow_executors, - ) - - -def collect_hosted_workflows(workflow: Workflow) -> Iterator[Workflow]: - """Yield ``workflow`` and every nested sub-workflow, deduped by name. - - A host registers the orchestration primitives for each yielded workflow so a - parent orchestration can invoke its sub-workflows as child orchestrations. - Workflows are deduped by :attr:`Workflow.name`, **compared case-insensitively**: - the *same* sub-workflow instance reused across the tree (or shared by two - top-level workflows) is yielded once, which is the expected fan-out pattern. Two - **different** workflow instances whose names collide (including case-only - differences) are rejected, since both would resolve to one durable orchestration - (``dafx-{name}``) -- whose name the route ownership check compares - case-insensitively -- and would silently shadow each other. The top-level - ``workflow`` is yielded first. - - Args: - workflow: The top-level workflow to walk. - - Yields: - Each distinct workflow in the nesting tree, parent before child. - - Raises: - ValueError: If two different workflow instances in the tree have colliding - (case-insensitive) names. - """ - seen: dict[str, Workflow] = {} - - def _walk(current: Workflow) -> Iterator[Workflow]: - key = current.name.casefold() - existing = seen.get(key) - if existing is not None: - if existing is not current: - raise ValueError( - f"A different workflow named '{current.name}' collides with '{existing.name}'. A " - f"workflow name maps to a single durable orchestration ('dafx-{current.name}'), " - "compared case-insensitively, so names must be unique within a hosted composition. " - "Rename one, or reuse the same Workflow instance if they are meant to be the same " - "sub-workflow." - ) - return - seen[key] = current - yield current - plan = plan_workflow_registration(current) - for sub in plan.subworkflow_executors: - yield from _walk(sub.workflow) - - yield from _walk(workflow) diff --git a/python/packages/durabletask/agent_framework_durabletask/_workflows/runner_context.py b/python/packages/durabletask/agent_framework_durabletask/_workflows/runner_context.py deleted file mode 100644 index b68de253990..00000000000 --- a/python/packages/durabletask/agent_framework_durabletask/_workflows/runner_context.py +++ /dev/null @@ -1,188 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Runner context for activity execution within durable orchestrations. - -This module provides the :class:`CapturingRunnerContext` class that captures -messages and events produced during executor execution within activities. -It is host-agnostic and works on any durable task host. -""" - -from __future__ import annotations - -import asyncio -from copy import copy -from typing import Any - -from agent_framework import ( - CheckpointStorage, - RunnerContext, - WorkflowCheckpoint, - WorkflowEvent, - WorkflowMessage, -) -from agent_framework._workflows._runner_context import YieldOutputClassifier, YieldOutputEventType -from agent_framework._workflows._state import State - -# Keys of the host-metadata mapping the durable host attaches to each executor (as the -# activity's ``host_context`` input, surfaced here via ``set_host_metadata``). These form -# a cross-package contract: the orchestrator writes them and -# ``WorkflowHitlContext.from_context`` in agent-framework-azurefunctions reads them back by -# the same names, so they live here as shared constants to keep producer and consumer from -# drifting silently (a rename would just make HITL URLs stop being built, with no error). -HOST_METADATA_INSTANCE_ID = "instance_id" -HOST_METADATA_WORKFLOW_NAME = "workflow_name" -HOST_METADATA_REQUEST_PATH_PREFIX = "request_path_prefix" - - -class CapturingRunnerContext(RunnerContext): - """A RunnerContext that captures messages and events for durable activities. - - This context captures all messages and events produced during execution - without requiring durable entity storage, allowing the results to be - returned to the orchestrator. - - Checkpointing is not supported — the orchestrator manages state. - """ - - def __init__(self) -> None: - self._messages: dict[str, list[WorkflowMessage]] = {} - self._event_queue: asyncio.Queue[WorkflowEvent] = asyncio.Queue() - self._pending_request_info_events: dict[str, WorkflowEvent[Any]] = {} - self._workflow_id: str | None = None - self._streaming: bool = False - self._yield_output_classifier: YieldOutputClassifier = lambda _executor_id: "output" - self._host_metadata: dict[str, Any] | None = None - - # -- Messaging ------------------------------------------------------------ - - async def send_message(self, message: WorkflowMessage) -> None: - self._messages.setdefault(message.source_id, []) - self._messages[message.source_id].append(message) - - async def drain_messages(self) -> dict[str, list[WorkflowMessage]]: - messages = copy(self._messages) - self._messages.clear() - return messages - - async def has_messages(self) -> bool: - return bool(self._messages) - - # -- Events --------------------------------------------------------------- - - async def add_event(self, event: WorkflowEvent) -> None: - await self._event_queue.put(event) - - async def drain_events(self) -> list[WorkflowEvent]: - events: list[WorkflowEvent] = [] - while True: - try: - events.append(self._event_queue.get_nowait()) - except asyncio.QueueEmpty: - break - return events - - async def has_events(self) -> bool: - return not self._event_queue.empty() - - async def next_event(self) -> WorkflowEvent: - return await self._event_queue.get() - - # -- Checkpointing (not supported) ---------------------------------------- - - def has_checkpointing(self) -> bool: - return False - - def set_runtime_checkpoint_storage(self, storage: CheckpointStorage) -> None: - pass - - def clear_runtime_checkpoint_storage(self) -> None: - pass - - async def create_checkpoint( - self, - workflow_name: str, - graph_signature_hash: str, - state: State, - previous_checkpoint_id: str | None, - iteration_count: int, - metadata: dict[str, Any] | None = None, - ) -> str: - raise NotImplementedError("Checkpointing is not supported in activity context") - - async def build_checkpoint( - self, - workflow_name: str, - graph_signature_hash: str, - state: State, - previous_checkpoint_id: str | None, - iteration_count: int, - metadata: dict[str, Any] | None = None, - ) -> WorkflowCheckpoint: - raise NotImplementedError("Checkpointing is not supported in activity context") - - async def load_checkpoint(self, checkpoint_id: str) -> WorkflowCheckpoint | None: - raise NotImplementedError("Checkpointing is not supported in activity context") - - async def apply_checkpoint(self, checkpoint: WorkflowCheckpoint) -> None: - raise NotImplementedError("Checkpointing is not supported in activity context") - - # -- Workflow configuration ----------------------------------------------- - - def set_workflow_id(self, workflow_id: str) -> None: - self._workflow_id = workflow_id - - def reset_for_new_run(self) -> None: - self._messages.clear() - self._event_queue = asyncio.Queue() - self._pending_request_info_events.clear() - self._streaming = False - - def set_streaming(self, streaming: bool) -> None: - self._streaming = streaming - - def is_streaming(self) -> bool: - return self._streaming - - # -- Host metadata -------------------------------------------------------- - - @property - def host_metadata(self) -> dict[str, Any] | None: - """Orchestration metadata injected by the durable host, or ``None`` in-process. - - The durable orchestrator populates this from its own context (e.g. the - orchestration ``instance_id`` and ``workflow_name``) so an executor can - address the running orchestration -- for example to build a human-in-the-loop - respond URL and notify a reviewer -- without the caller threading those ids by - hand. It is ``None`` when the same executor runs in-process (no durable host), - so host-specific helpers can degrade gracefully. - """ - return self._host_metadata - - def set_host_metadata(self, metadata: dict[str, Any] | None) -> None: - """Set the orchestration metadata for this activity execution.""" - self._host_metadata = metadata - - # -- Yield-output classification ------------------------------------------- - - def set_yield_output_classifier(self, classifier: YieldOutputClassifier) -> None: - """Set the classifier used by ``WorkflowContext.yield_output()``.""" - self._yield_output_classifier = classifier - - def classify_yielded_output(self, executor_id: str) -> YieldOutputEventType | None: - """Classify an executor's yield_output payload as output, intermediate, or hidden.""" - return self._yield_output_classifier(executor_id) - - # -- Request Info Events -------------------------------------------------- - - async def add_request_info_event(self, event: WorkflowEvent[Any]) -> None: - self._pending_request_info_events[event.request_id] = event - await self.add_event(event) - - async def send_request_info_response(self, request_id: str, response: Any) -> None: - raise NotImplementedError( - "send_request_info_response is not supported in activity context. " - "Human-in-the-loop scenarios should be handled at the orchestrator level." - ) - - async def get_pending_request_info_events(self) -> dict[str, WorkflowEvent[Any]]: - return dict(self._pending_request_info_events) diff --git a/python/packages/durabletask/agent_framework_durabletask/_workflows/serialization.py b/python/packages/durabletask/agent_framework_durabletask/_workflows/serialization.py deleted file mode 100644 index cabd2c4251b..00000000000 --- a/python/packages/durabletask/agent_framework_durabletask/_workflows/serialization.py +++ /dev/null @@ -1,369 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Internal serialization helpers for workflow execution. - -These helpers are framework-internal plumbing for moving typed objects across -durable orchestration/activity boundaries. They are **not** part of the public -API and must not be called by application code. - -They wrap the core checkpoint codec (``encode_checkpoint_value`` / -``decode_checkpoint_value`` from ``agent_framework._workflows``), which uses -pickle + base64 to round-trip arbitrary Python objects (dataclasses, Pydantic -models, ``Message``, ...) while leaving JSON-native types (str, int, float, -bool, None) as-is. - -Because that codec can unpickle objects, every value that crosses an external -trust boundary -- HTTP request bodies and HITL responses raised as external -events -- is sanitized by the framework with :func:`strip_pickle_markers` -*before* it can reach these helpers. Application code never has to perform that -sanitization itself: the orchestrator, the activity body, and the HTTP entry -points already do it at the boundary. See -:mod:`agent_framework._workflows._checkpoint_encoding` for the full security model. - -Contents: -- ``serialize_value`` / ``deserialize_value``: internal codec aliases for encode/decode. -- ``reconstruct_to_type``: rebuilds HITL response data (which arrives without type - markers) to a known type. -- ``resolve_type``: resolves 'module:class' type keys to Python types. -- ``strip_pickle_markers``: the framework's trust-boundary defense that neutralizes - attacker-injected pickle/type markers. -""" - -from __future__ import annotations - -import importlib -import logging -from contextlib import suppress -from dataclasses import is_dataclass -from typing import Any, cast - -from agent_framework import WorkflowEvent -from agent_framework._workflows._checkpoint_encoding import ( - _PICKLE_MARKER, # pyright: ignore[reportPrivateUsage] - _TYPE_MARKER, # pyright: ignore[reportPrivateUsage] - decode_checkpoint_value, - encode_checkpoint_value, -) -from agent_framework._workflows._events import WorkflowEventType -from pydantic import BaseModel - -logger = logging.getLogger(__name__) - - -def resolve_type(type_key: str) -> type | None: - """Resolve a 'module:class' type key to its Python type. - - Args: - type_key: Fully qualified type reference in 'module_name:class_name' format. - - Returns: - The resolved type, or None if resolution fails. - """ - try: - module_name, class_name = type_key.split(":", 1) - module = importlib.import_module(module_name) - resolved = getattr(module, class_name, None) - # Only return actual classes. A non-type attribute (function, module member, - # etc.) would raise TypeError in issubclass() inside reconstruct_to_type(). - return resolved if isinstance(resolved, type) else None - except Exception: - logger.debug("Could not resolve type %s", type_key) - return None - - -# ============================================================================ -# Pickle marker sanitization (security) -# ============================================================================ - - -def strip_pickle_markers(data: Any) -> Any: - """Recursively strip pickle/type markers from untrusted data. - - The core checkpoint encoding uses ``__pickled__`` and ``__type__`` markers to - roundtrip arbitrary Python objects via *pickle*. If an attacker crafts an - HTTP payload that contains these markers, the data would flow into - ``pickle.loads()`` and enable **arbitrary code execution**. - - This function walks the incoming data structure and replaces any ``dict`` - that contains either marker key with ``None``, neutralizing the attack - vector while leaving all other data untouched. - - The framework applies this at every external trust boundary -- HTTP request - bodies and HITL responses raised as external events -- before the value can - reach the internal codec (:func:`deserialize_value` / - ``decode_checkpoint_value``). Application code does not need to call it. - """ - if isinstance(data, dict): - if _PICKLE_MARKER in data or _TYPE_MARKER in data: - logger.debug("Stripped pickle/type markers from untrusted input.") - return None - typed_dict = cast(dict[str, Any], data) - return {k: strip_pickle_markers(v) for k, v in typed_dict.items()} - - if isinstance(data, list): - typed_list = cast(list[Any], data) - return [strip_pickle_markers(item) for item in typed_list] - - return data - - -# ============================================================================ -# Sub-workflow envelope markers (trust boundary) -# ============================================================================ - -# A WorkflowExecutor node runs its inner workflow as a durable child orchestration. -# The parent wraps the node's input in this envelope so the child orchestrator can -# tell a trusted sub-orchestration payload (serialized by the parent, post-boundary, -# via call_sub_orchestrator) apart from untrusted top-level client input. -SUBWORKFLOW_INPUT_KEY = "__subworkflow_input__" - -# When a workflow runs as a sub-workflow, its orchestrator returns this envelope -# instead of a bare outputs list, so the parent can recover both the inner outputs -# *and* the inner event timeline (a child orchestration is a separate durable -# instance; its return value is the only deterministic, replay-safe channel back to -# the parent). A top-level run still returns a bare list, so the client output path -# is unchanged. See ``orchestrator._process_subworkflow_result``. -SUBWORKFLOW_RESULT_KEY = "__subworkflow_result__" - -# Alongside the input envelope, the parent passes the child its HITL *address* context -# (the addressable root instance id, the root workflow name, and the accumulated -# request-path prefix). This lets an executor inside a nested child build a respond URL -# that targets the top-level instance with a qualified request id, without the child -# knowing how its parent refers to it. Like SUBWORKFLOW_INPUT_KEY this is trusted -# internal data (only call_sub_orchestrator sets it, post trust boundary), so it must -# be stripped from untrusted top-level input (see strip_subworkflow_markers). -SUBWORKFLOW_ADDRESS_KEY = "__subworkflow_address__" - - -def strip_subworkflow_markers(data: Any) -> Any: - """Remove the reserved sub-workflow envelope keys from untrusted top-level input. - - The orchestrator treats a top-level input dict carrying :data:`SUBWORKFLOW_INPUT_KEY` - as a *trusted* child-orchestration payload and reconstructs it with - :func:`deserialize_value` (pickle) **without** the usual - :func:`strip_pickle_markers` sanitization, because a genuine envelope is only ever - built internally (post trust boundary) by ``call_sub_orchestrator``. If untrusted - client input could carry that key, an attacker could smuggle a pickle payload - straight into ``pickle.loads`` (RCE). The sibling :data:`SUBWORKFLOW_ADDRESS_KEY` - is likewise trusted (it sets the root instance id / workflow name a nested executor - builds respond URLs against); if a top-level caller could supply it, they could - make the app emit URLs pointing at another instance (confused-deputy / info leak). - - Hosts therefore call this on client-supplied workflow input *before* scheduling the - orchestration, so the only way the orchestrator ever sees either key is from a real - internal child dispatch. Only the top-level keys are removed (the only position the - orchestrator interprets them), leaving the rest of the caller's payload untouched. - """ - if not isinstance(data, dict): - return data - typed = cast(dict[str, Any], data) - if SUBWORKFLOW_INPUT_KEY not in typed and SUBWORKFLOW_ADDRESS_KEY not in typed: - return typed - logger.debug("Stripped reserved sub-workflow envelope key(s) from untrusted input.") - cleaned = typed.copy() - cleaned.pop(SUBWORKFLOW_INPUT_KEY, None) - cleaned.pop(SUBWORKFLOW_ADDRESS_KEY, None) - return cleaned - - -# ============================================================================ -# Serialize / Deserialize -# ============================================================================ - - -def serialize_value(value: Any) -> Any: - """Encode a value for JSON-compatible cross-activity communication (internal). - - Framework-internal codec. Delegates to core checkpoint encoding which uses - pickle + base64 for non-JSON-native types (dataclasses, Pydantic models, - Message, etc.). Not part of the public API. - - Args: - value: Any Python value (primitive, dataclass, Pydantic model, Message, etc.) - - Returns: - A JSON-serializable representation with embedded type metadata for reconstruction. - """ - return encode_checkpoint_value(value) - - -def deserialize_value(value: Any) -> Any: - """Decode a value previously encoded with :func:`serialize_value` (internal). - - Framework-internal codec. Delegates to core checkpoint decoding which - unpickles base64-encoded values and verifies type integrity. Not part of the - public API: callers only ever hand it values that the framework produced - itself or that have already passed the :func:`strip_pickle_markers` trust - boundary, so untrusted markers can never reach ``pickle.loads()`` here. - - Args: - value: The serialized data (dict with pickle markers, list, or primitive) - - Returns: - Reconstructed typed object if type metadata found, otherwise original value. - """ - return decode_checkpoint_value(value) - - -def deserialize_workflow_output(output: Any) -> Any: - """Reconstruct the workflow outputs produced by the shared activity. - - Each value an executor yields is encoded with :func:`serialize_value` before - it reaches the orchestrator, so typed objects (dataclasses, Pydantic models, - ``AgentResponse``, ...) are stored as checkpoint-marker dicts. This reverses - that encoding so callers receive the original objects. - - This is the single decode path shared by every host (the in-process - :class:`DurableWorkflowClient` and the Azure Functions status endpoint) so - they never diverge in how a completed workflow's output is reconstructed. - - ``output`` must originate from the workflow's own orchestration result - (trusted durable storage), never from untrusted external input. Markers in - untrusted input must be neutralized with :func:`strip_pickle_markers` first. - - Args: - output: The workflow's orchestration result, already JSON-decoded (a list - of yielded outputs or a single value). - - Returns: - The output with every checkpoint-encoded value reconstructed; primitives - and plain JSON structures pass through unchanged. - """ - return deserialize_value(output) - - -# ============================================================================ -# Workflow Event Serialization (streaming) -# ============================================================================ - - -def _type_key(value_type: type[Any] | None) -> str | None: - """Format a type as a ``'module:qualname'`` key for :func:`resolve_type`.""" - if value_type is None: - return None - return f"{value_type.__module__}:{value_type.__name__}" - - -def serialize_workflow_event(event: WorkflowEvent[Any]) -> dict[str, Any]: - """Serialize a :class:`WorkflowEvent` to a JSON-compatible dict. - - Carries a workflow event from the durable activity, through the orchestration - custom status, to a streaming client. The data payload is encoded with - :func:`serialize_value` so typed objects survive the round trip; - :func:`deserialize_workflow_event` reverses it into a ``WorkflowEvent`` so - callers never handle checkpoint-marker dicts directly. - - Args: - event: The workflow event to serialize. - - Returns: - A JSON-serializable dict with the event ``type`` and the fields needed to - reconstruct it. - """ - serialized: dict[str, Any] = {"type": event.type} - if event.executor_id is not None: - serialized["executor_id"] = event.executor_id - if event.data is not None: - serialized["data"] = serialize_value(event.data) - if event.type == "request_info": - # request_type is omitted: deserialize_workflow_event rebuilds the event via - # WorkflowEvent.request_info, which derives it from the data payload. - serialized["request_id"] = event.request_id - serialized["source_executor_id"] = event.source_executor_id - serialized["response_type"] = _type_key(event.response_type) - return serialized - - -def deserialize_workflow_event(serialized: dict[str, Any]) -> WorkflowEvent[Any]: - """Reconstruct a :class:`WorkflowEvent` from :func:`serialize_workflow_event` output. - - ``serialized`` must originate from the workflow's own orchestration custom - status (trusted durable storage); its encoded payload is decoded with - :func:`deserialize_value`. Never pass untrusted external input here. - - Args: - serialized: A dict previously produced by :func:`serialize_workflow_event`, - optionally augmented with an ``iteration`` key by the orchestrator. - - Returns: - The reconstructed workflow event with its data payload restored. - """ - event_type = cast(WorkflowEventType, serialized["type"]) - payload = deserialize_value(serialized["data"]) if "data" in serialized else None - - if event_type == "request_info": - response_key = serialized.get("response_type") - response_type = resolve_type(response_key) if response_key else None - event: WorkflowEvent[Any] = WorkflowEvent.request_info( - request_id=cast(str, serialized["request_id"]), - source_executor_id=cast(str, serialized["source_executor_id"]), - request_data=payload, - response_type=response_type or object, - ) - else: - event = WorkflowEvent(event_type, data=payload, executor_id=serialized.get("executor_id")) - - iteration = serialized.get("iteration") - if iteration is not None: - event.iteration = iteration - return event - - -# ============================================================================ -# HITL Type Reconstruction -# ============================================================================ - - -def reconstruct_to_type(value: Any, target_type: type) -> Any: - """Reconstruct a value to a known target type. - - Used for HITL responses where external data (without checkpoint type markers) - needs to be reconstructed to a specific type determined by the response_type hint. - - Tries strategies in order: - 1. Return as-is if already the correct type - 2. deserialize_value (for data with any type markers) - 3. Pydantic model_validate (for Pydantic models) - 4. Dataclass constructor (for dataclasses) - - Args: - value: The value to reconstruct (typically a dict from JSON) - target_type: The expected type to reconstruct to - - Returns: - Reconstructed value if possible, otherwise the original value - """ - if value is None: - return None - - with suppress(TypeError): - if isinstance(value, target_type): - return value - - if not isinstance(value, dict): - return value - - # Try decoding if data has pickle markers (from checkpoint encoding). - # NOTE: This function is general-purpose. Callers that handle untrusted - # data (e.g. HITL responses) MUST call strip_pickle_markers() before - # passing data here. See _deserialize_hitl_response in orchestrator.py. - decoded = deserialize_value(value) - if not isinstance(decoded, dict): - return decoded - - # Try Pydantic model validation (for unmarked dicts, e.g., external HITL data) - if issubclass(target_type, BaseModel): - try: - return target_type.model_validate(value) - except Exception: - logger.debug("Could not validate Pydantic model %s", target_type) - return value # type: ignore[return-value] - - # Try dataclass construction (for unmarked dicts, e.g., external HITL data) - if is_dataclass(target_type) and isinstance(target_type, type): # type: ignore - try: - return target_type(**value) - except Exception: - logger.debug("Could not construct dataclass %s", target_type) - - return value # type: ignore[return-value] diff --git a/python/packages/durabletask/agent_framework_durabletask/py.typed b/python/packages/durabletask/agent_framework_durabletask/py.typed deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/python/packages/durabletask/pyproject.toml b/python/packages/durabletask/pyproject.toml deleted file mode 100644 index 38afd243ec8..00000000000 --- a/python/packages/durabletask/pyproject.toml +++ /dev/null @@ -1,110 +0,0 @@ -[project] -name = "agent-framework-durabletask" -description = "Durable Task integration for Microsoft Agent Framework." -authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] -readme = "README.md" -requires-python = ">=3.10" -version = "1.0.0b260730" -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>=1.13.0,<2", - "durabletask>=1.5.0,<2", - "durabletask-azuremanaged>=1.4.0,<2", - "python-dateutil>=2.8.0,<3", -] - -[dependency-groups] -dev = [ - "types-python-dateutil==2.9.0.20260716", -] - -[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' -pythonpath = ["tests/integration_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", - "integration_test: marks tests as integration tests (alternative marker)", - "sample: marks tests as sample tests", - "requires_azure_openai: marks tests that require Azure OpenAI", - "requires_dts: marks tests that require Durable Task Scheduler", - "requires_redis: marks tests that require Redis" -] - -[tool.ruff] -extend = "../../pyproject.toml" - -[tool.coverage.run] -omit = [ - "**/__init__.py" -] - -[tool.pyright] -extends = "../../pyproject.toml" -include = ["agent_framework_durabletask"] - -[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_durabletask"] -exclude_dirs = ["tests"] - -[tool.poe] -executor.type = "uv" -include = "../../shared_tasks.toml" - -[tool.poe.tasks.mypy] -help = "Run MyPy for this package." -cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_durabletask" - -[tool.poe.tasks.test] -help = "Run the default unit test suite for this package." -cmd = 'pytest -m "not integration" --cov=agent_framework_durabletask --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/durabletask/tests/integration_tests/.env.example b/python/packages/durabletask/tests/integration_tests/.env.example deleted file mode 100644 index 1944e8999c4..00000000000 --- a/python/packages/durabletask/tests/integration_tests/.env.example +++ /dev/null @@ -1,13 +0,0 @@ -# Azure OpenAI Configuration -AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com/ -AZURE_OPENAI_MODEL=your-deployment-name -# Optional: Use Azure CLI authentication if not provided -# AZURE_OPENAI_API_KEY=your-api-key - -# Durable Task Scheduler Configuration -ENDPOINT=http://localhost:8080 -TASKHUB=default - -# Redis Configuration (for streaming tests) -REDIS_CONNECTION_STRING=redis://localhost:6379 -REDIS_STREAM_TTL_MINUTES=10 diff --git a/python/packages/durabletask/tests/integration_tests/README.md b/python/packages/durabletask/tests/integration_tests/README.md deleted file mode 100644 index 704458a4923..00000000000 --- a/python/packages/durabletask/tests/integration_tests/README.md +++ /dev/null @@ -1,110 +0,0 @@ -# Sample Integration Tests - -Integration tests that validate the Durable Agent Framework samples by running them against a Durable Task Scheduler (DTS) instance. - -## 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_MODEL` -- `AZURE_OPENAI_API_KEY` (optional if using Azure CLI authentication) -- `ENDPOINT` (default: http://localhost:8080) -- `TASKHUB` (default: default) - -Optional variables (for streaming tests): -- `REDIS_CONNECTION_STRING` (default: redis://localhost:6379) -- `REDIS_STREAM_TTL_MINUTES` (default: 10) - -### 2. Start required services - -**Durable Task Scheduler:** -```bash -docker run -d --name dts-emulator -p 8080:8080 -p 8082:8082 mcr.microsoft.com/dts/dts-emulator:latest -``` -- Port 8080: gRPC endpoint (used by tests) -- Port 8082: Web dashboard (optional, for monitoring) - -**Redis (for streaming tests):** -```bash -docker run -d --name redis -p 6379:6379 redis:latest -``` -- Port 6379: Redis server endpoint - -## Running Tests - -The tests automatically start and stop worker processes for each sample. - -### Run all sample tests -```bash -uv run pytest packages/durabletask/tests/integration_tests -v -``` - -### Run specific sample -```bash -uv run pytest packages/durabletask/tests/integration_tests/test_01_single_agent.py -v -``` - -### Run with verbose output -```bash -uv run pytest packages/durabletask/tests/integration_tests -sv -``` - -## How It Works - -Each test file uses pytest markers to automatically configure and start the worker process: - -```python -pytestmark = [ - pytest.mark.sample("03_single_agent_streaming"), - pytest.mark.integration_test, - pytest.mark.requires_azure_openai, - pytest.mark.requires_dts, - pytest.mark.requires_redis, -] -``` - -## Troubleshooting - -**Tests are skipped:** -Ensure the required environment variables (e.g., `AZURE_OPENAI_ENDPOINT`) are set in your `.env` file. - -**DTS connection failed:** -Check that the DTS emulator container is running: `docker ps | grep dts-emulator` - -**Redis connection failed:** -Check that Redis is running: `docker ps | grep redis` - -**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. - -If you see "DTS emulator is not available": -- Ensure Docker container is running: `docker ps | grep dts-emulator` -- Check port 8080 is not in use by another process -- Restart the container if needed - -### Azure OpenAI Errors - -If you see authentication or deployment errors: -- Verify your `AZURE_OPENAI_ENDPOINT` is correct -- Confirm `AZURE_OPENAI_MODEL` matches your deployment -- If using API key, check `AZURE_OPENAI_API_KEY` is valid -- If using Azure CLI, ensure you're logged in: `az login` - -## CI/CD - -For automated testing in CI/CD pipelines: - -1. Use Docker Compose to start DTS emulator -2. Set environment variables via CI/CD secrets -3. Run tests with appropriate markers: `pytest -m integration_test` diff --git a/python/packages/durabletask/tests/integration_tests/conftest.py b/python/packages/durabletask/tests/integration_tests/conftest.py deleted file mode 100644 index f9590f42827..00000000000 --- a/python/packages/durabletask/tests/integration_tests/conftest.py +++ /dev/null @@ -1,512 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. -"""Pytest configuration and fixtures for durabletask integration tests.""" - -import asyncio -import json -import logging -import os -import socket -import subprocess -import sys -import time -import uuid -from collections.abc import Generator -from pathlib import Path -from typing import Any, Protocol, cast -from urllib.parse import urlparse - -import pytest -import redis.asyncio as aioredis -from dotenv import load_dotenv -from durabletask.azuremanaged.client import DurableTaskSchedulerClient -from durabletask.client import OrchestrationStatus - -from agent_framework_durabletask import DurableAIAgentClient, DurableWorkflowClient - -# Load environment variables from .env file -load_dotenv(Path(__file__).parent / ".env") - -# Configure logging to reduce noise during tests -logging.basicConfig(level=logging.WARNING) - - -class AgentClientFactoryProtocol(Protocol): - """Protocol for the agent client factory fixture.""" - - @classmethod - def create(cls, max_poll_retries: int = 90) -> tuple[DurableTaskSchedulerClient, DurableAIAgentClient]: ... - - -# ============================================================================= -# Environment and Service Checks -# ============================================================================= - - -def _get_dts_endpoint() -> str: - """Get the DTS endpoint from environment or use default.""" - return os.getenv("ENDPOINT", "http://localhost:8080") - - -def _check_dts_available(endpoint: str | None = None) -> bool: - """Check if DTS emulator is available at the given endpoint.""" - try: - resolved_endpoint: str = _get_dts_endpoint() if endpoint is None else endpoint - parsed = urlparse(resolved_endpoint) - host = parsed.hostname or "localhost" - port = parsed.port or 8080 - - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: - sock.settimeout(2) - return sock.connect_ex((host, port)) == 0 - except Exception: - return False - - -def _check_redis_available() -> bool: - """Check if Redis is available at the default connection string.""" - try: - - async def test_connection() -> bool: - redis_url = os.getenv("REDIS_CONNECTION_STRING", "redis://localhost:6379") - try: - client = aioredis.from_url(redis_url, socket_timeout=2) # type: ignore[reportUnknownMemberType] - await client.ping() # type: ignore[reportUnknownMemberType] - await client.aclose() # type: ignore[reportUnknownMemberType] - return True - except Exception: - return False - - return asyncio.run(test_connection()) - except Exception: - return False - - -# ============================================================================= -# Client Factory Functions -# ============================================================================= - - -def create_dts_client(endpoint: str, taskhub: str) -> DurableTaskSchedulerClient: - """Create a DurableTaskSchedulerClient with common configuration. - - Args: - endpoint: The DTS endpoint address - taskhub: The task hub name - - Returns: - A configured DurableTaskSchedulerClient instance - """ - return DurableTaskSchedulerClient( - host_address=endpoint, - secure_channel=False, - taskhub=taskhub, - token_credential=None, - ) - - -def create_agent_client( - endpoint: str, - taskhub: str, - max_poll_retries: int = 90, -) -> tuple[DurableTaskSchedulerClient, DurableAIAgentClient]: - """Create a DurableAIAgentClient with the underlying DTS client. - - Args: - endpoint: The DTS endpoint address - taskhub: The task hub name - max_poll_retries: Max poll retries for the agent client - - Returns: - A tuple of (DurableTaskSchedulerClient, DurableAIAgentClient) - """ - dts_client = create_dts_client(endpoint, taskhub) - agent_client = DurableAIAgentClient(dts_client, max_poll_retries=max_poll_retries) - return dts_client, agent_client - - -# ============================================================================= -# Orchestration Helper Class -# ============================================================================= - - -class OrchestrationHelper: - """Helper class for orchestration-related test operations.""" - - def __init__(self, dts_client: DurableTaskSchedulerClient): - """Initialize the orchestration helper. - - Args: - dts_client: The DurableTaskSchedulerClient instance to use - """ - self.client = dts_client - - def wait_for_orchestration( - self, - instance_id: str, - timeout: float = 60.0, - ) -> Any: - """Wait for an orchestration to complete. - - Args: - instance_id: The orchestration instance ID - timeout: Maximum time to wait in seconds - - Returns: - The final OrchestrationMetadata - - Raises: - TimeoutError: If the orchestration doesn't complete within timeout - RuntimeError: If the orchestration fails - """ - # Use the built-in wait_for_orchestration_completion method - metadata = self.client.wait_for_orchestration_completion( - instance_id=instance_id, - timeout=int(timeout), - ) - - if metadata is None: - raise TimeoutError(f"Orchestration {instance_id} did not complete within {timeout} seconds") - - # Check if failed or terminated - if metadata.runtime_status == OrchestrationStatus.FAILED: - raise RuntimeError(f"Orchestration {instance_id} failed: {metadata.serialized_custom_status}") - if metadata.runtime_status == OrchestrationStatus.TERMINATED: - raise RuntimeError(f"Orchestration {instance_id} was terminated") - - return metadata - - def wait_for_orchestration_with_output( - self, - instance_id: str, - timeout: float = 60.0, - ) -> tuple[Any, Any]: - """Wait for an orchestration to complete and return its output. - - Args: - instance_id: The orchestration instance ID - timeout: Maximum time to wait in seconds - - Returns: - A tuple of (OrchestrationMetadata, output) - - Raises: - TimeoutError: If the orchestration doesn't complete within timeout - RuntimeError: If the orchestration fails - """ - metadata = self.wait_for_orchestration(instance_id, timeout) - - # The output should be available in the metadata - return metadata, metadata.serialized_output - - def get_orchestration_status(self, instance_id: str) -> Any | None: - """Get the current status of an orchestration. - - Args: - instance_id: The orchestration instance ID - - Returns: - The OrchestrationMetadata or None if not found - """ - try: - # Try to wait with a short timeout to get current status - return self.client.wait_for_orchestration_completion( - instance_id=instance_id, - timeout=1, # Very short timeout, just checking status - ) - except Exception: - return None - - def raise_event( - self, - instance_id: str, - event_name: str, - event_data: Any = None, - ) -> None: - """Raise an external event to an orchestration. - - Args: - instance_id: The orchestration instance ID - event_name: The name of the event - event_data: The event data payload - """ - self.client.raise_orchestration_event(instance_id, event_name, data=event_data) - - def wait_for_notification(self, instance_id: str, timeout_seconds: int = 30) -> bool: - """Wait for the orchestration to reach a notification point. - - Polls the orchestration status until it appears to be waiting for approval. - - Args: - instance_id: The orchestration instance ID - timeout_seconds: Maximum time to wait - - Returns: - True if notification detected, False if timeout - """ - start_time = time.time() - while time.time() - start_time < timeout_seconds: - try: - metadata = self.client.get_orchestration_state( - instance_id=instance_id, - ) - - if metadata: - # Check if we're waiting for approval by examining custom status - if metadata.serialized_custom_status: - try: - custom_status = json.loads(metadata.serialized_custom_status) - # Handle both string and dict custom status - status_str = custom_status if isinstance(custom_status, str) else str(custom_status) - if status_str.lower().startswith("requesting human feedback"): - return True - except (json.JSONDecodeError, AttributeError): - # If it's not JSON, treat as plain string - if metadata.serialized_custom_status.lower().startswith("requesting human feedback"): - return True - - # Check for terminal states - if metadata.runtime_status.name == "COMPLETED" or metadata.runtime_status.name == "FAILED": - return False - except Exception: - # Silently ignore transient errors during polling (e.g., network issues, service unavailable). - # The loop will retry until timeout, allowing the service to recover. - pass - - time.sleep(1) - - return False - - -# ============================================================================= -# Pytest Configuration -# ============================================================================= - - -def pytest_configure(config: pytest.Config) -> None: - """Register custom markers.""" - config.addinivalue_line("markers", "integration_test: mark test as integration test") - config.addinivalue_line("markers", "requires_dts: mark test as requiring DTS emulator") - config.addinivalue_line("markers", "requires_azure_openai: mark test as requiring Azure OpenAI") - config.addinivalue_line("markers", "requires_redis: mark test as requiring Redis") - config.addinivalue_line( - "markers", - "sample(path): specify the sample directory name for the test (e.g., @pytest.mark.sample('01_single_agent'))", - ) - - -def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None: - """Skip tests based on markers and environment availability.""" - foundry_vars = ["FOUNDRY_PROJECT_ENDPOINT", "FOUNDRY_MODEL"] - foundry_available = all(os.getenv(var) for var in foundry_vars) - azure_openai_vars = ["AZURE_OPENAI_ENDPOINT", "AZURE_OPENAI_MODEL"] - azure_openai_available = all(os.getenv(var) for var in azure_openai_vars) - skip_foundry = pytest.mark.skip(reason=f"Missing required environment variables: {', '.join(foundry_vars)}") - skip_azure_openai = pytest.mark.skip( - reason=f"Missing required environment variables: {', '.join(azure_openai_vars)}" - ) - - # Check DTS availability - dts_available = _check_dts_available() - skip_dts = pytest.mark.skip(reason=f"DTS emulator is not available at {_get_dts_endpoint()}") - - # Check Redis availability - redis_available = _check_redis_available() - skip_redis = pytest.mark.skip(reason="Redis is not available at redis://localhost:6379") - - for item in items: - if "requires_azure_openai" in item.keywords and not foundry_available: - item.add_marker(skip_foundry) - sample_marker = item.get_closest_marker("sample") - sample_name = sample_marker.args[0] if sample_marker and sample_marker.args else None - if sample_name == "06_multi_agent_orchestration_conditionals" and not azure_openai_available: - item.add_marker(skip_azure_openai) - if "requires_dts" in item.keywords and not dts_available: - item.add_marker(skip_dts) - if "requires_redis" in item.keywords and not redis_available: - item.add_marker(skip_redis) - - -# ============================================================================= -# Pytest Fixtures -# ============================================================================= - - -@pytest.fixture(scope="session") -def dts_endpoint() -> str: - """Get the DTS endpoint from environment or use default.""" - return _get_dts_endpoint() - - -@pytest.fixture(scope="session") -def dts_available(dts_endpoint: str) -> bool: - """Check if DTS emulator is available and responding.""" - if _check_dts_available(dts_endpoint): - return True - pytest.skip(f"DTS emulator is not available at {dts_endpoint}") - return False - - -@pytest.fixture(scope="module") -def check_sample_env(request: pytest.FixtureRequest) -> None: - """Verify the environment variables required by the current sample are set.""" - sample_marker = request.node.get_closest_marker("sample") # type: ignore[union-attr] - if not sample_marker: - pytest.fail("Test class must have @pytest.mark.sample() marker") - - sample_name = cast(str, sample_marker.args[0]) # type: ignore[union-attr] - # Samples that host no AI agents need no model credentials (only the DTS emulator). - no_llm_samples = {"12_subworkflow_hitl"} - if sample_name in no_llm_samples: - return - if sample_name == "06_multi_agent_orchestration_conditionals": - required_vars = ["AZURE_OPENAI_ENDPOINT", "AZURE_OPENAI_MODEL"] - else: - required_vars = ["FOUNDRY_PROJECT_ENDPOINT", "FOUNDRY_MODEL"] - missing = [var for var in required_vars if not os.getenv(var)] - - if missing: - pytest.skip(f"Missing required environment variables: {', '.join(missing)}") - - -@pytest.fixture(scope="module") -def unique_taskhub() -> str: - """Generate a unique task hub name for test isolation.""" - # Use a shorter UUID to avoid naming issues - return f"test-{uuid.uuid4().hex[:8]}" - - -@pytest.fixture(scope="module") -def worker_process( - dts_available: bool, - check_sample_env: None, - dts_endpoint: str, - unique_taskhub: str, - request: pytest.FixtureRequest, -) -> Generator[dict[str, Any], None, None]: - """Start a worker process for the current test module by running the sample worker.py. - - This fixture: - 1. Determines which sample to run from @pytest.mark.sample() - 2. Starts the sample's worker.py as a subprocess - 3. Waits for the worker to be ready - 4. Tears down the worker after tests complete - - Usage: - @pytest.mark.sample("01_single_agent") - class TestSingleAgent: - ... - """ - # Get sample path from marker - sample_marker = request.node.get_closest_marker("sample") # type: ignore[union-attr] - if not sample_marker: - pytest.fail("Test class must have @pytest.mark.sample() marker") - - sample_name: str = cast(str, sample_marker.args[0]) # type: ignore[union-attr] - sample_path: Path = Path(__file__).parents[4] / "samples" / "04-hosting" / "durabletask" / sample_name - worker_file: Path = sample_path / "worker.py" - - if not worker_file.exists(): - pytest.fail(f"Sample worker not found: {worker_file}") - - # Set up environment for worker subprocess - env = os.environ.copy() - env["ENDPOINT"] = dts_endpoint - env["TASKHUB"] = unique_taskhub - - # Start worker subprocess - try: - # On Windows, use CREATE_NEW_PROCESS_GROUP to allow proper termination - # shell=True only on Windows to handle PATH resolution - if sys.platform == "win32": - process = subprocess.Popen( - [sys.executable, str(worker_file)], - cwd=str(sample_path), - creationflags=subprocess.CREATE_NEW_PROCESS_GROUP, - shell=True, - env=env, - text=True, - ) - # On Unix, don't use shell=True to avoid shell wrapper issues - else: - process = subprocess.Popen( - [sys.executable, str(worker_file)], - cwd=str(sample_path), - env=env, - text=True, - ) - except Exception as e: - pytest.fail(f"Failed to start worker subprocess: {e}") - - # Wait for worker to initialize - # The worker needs time to: - # 1. Start Python and import modules - # 2. Create Azure OpenAI clients - # 3. Register agents with the DTS worker - # 4. Connect to DTS and be ready to receive signals - # - # We use a generous wait time because CI environments can be slow, - # and the first test that runs depends on the worker being fully ready. - time.sleep(8) - - # Check if process is still running - if process.poll() is not None: - stderr_output = process.stderr.read() if process.stderr else "" - pytest.fail(f"Worker process exited prematurely. stderr: {stderr_output}") - - # Provide worker info to tests - worker_info = { - "process": process, - "endpoint": dts_endpoint, - "taskhub": unique_taskhub, - } - - try: - yield worker_info - finally: - # Cleanup: terminate worker subprocess - try: - process.terminate() - try: - process.wait(timeout=5) - except subprocess.TimeoutExpired: - process.kill() - process.wait() - except Exception as e: - logging.warning(f"Error during worker process cleanup: {e}") - - -@pytest.fixture(scope="module") -def orchestration_helper(worker_process: dict[str, Any]) -> OrchestrationHelper: - """Create an OrchestrationHelper for the current test module.""" - dts_client = create_dts_client(worker_process["endpoint"], worker_process["taskhub"]) - return OrchestrationHelper(dts_client) - - -@pytest.fixture(scope="module") -def agent_client_factory(worker_process: dict[str, Any]) -> type[AgentClientFactoryProtocol]: - """Return a factory class for creating agent clients. - - Usage in tests: - def test_example(self, agent_client_factory): - dts_client, agent_client = agent_client_factory.create(max_poll_retries=90) - """ - - class AgentClientFactory: - """Factory for creating DTS and Agent client pairs.""" - - endpoint = worker_process["endpoint"] - taskhub = worker_process["taskhub"] - - @classmethod - def create(cls, max_poll_retries: int = 90) -> tuple[DurableTaskSchedulerClient, DurableAIAgentClient]: - """Create a DTS client and Agent client pair.""" - return create_agent_client(cls.endpoint, cls.taskhub, max_poll_retries) - - return AgentClientFactory - - -@pytest.fixture(scope="module") -def workflow_client(worker_process: dict[str, Any]) -> DurableWorkflowClient: - """Create a DurableWorkflowClient bound to the current sample worker's task hub.""" - dts_client = create_dts_client(worker_process["endpoint"], worker_process["taskhub"]) - return DurableWorkflowClient(dts_client) diff --git a/python/packages/durabletask/tests/integration_tests/test_01_dt_single_agent.py b/python/packages/durabletask/tests/integration_tests/test_01_dt_single_agent.py deleted file mode 100644 index 98171fe774b..00000000000 --- a/python/packages/durabletask/tests/integration_tests/test_01_dt_single_agent.py +++ /dev/null @@ -1,97 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Integration tests for single agent functionality. - -Tests basic agent operations including: -- Agent registration and retrieval -- Single agent interactions -- Conversation continuity across multiple messages -- Multi-threaded agent usage -- Empty thread ID handling -""" - -from typing import Any, Protocol - -import pytest - -from agent_framework_durabletask import DurableAIAgentClient - - -class AgentClientFactoryProtocol(Protocol): - """Protocol for the agent client factory fixture.""" - - @classmethod - def create(cls, max_poll_retries: int = 90) -> tuple[Any, DurableAIAgentClient]: ... - - -# Module-level markers - applied to all tests in this module -pytestmark = [ - pytest.mark.flaky, - pytest.mark.integration, - pytest.mark.sample("01_single_agent"), - pytest.mark.integration_test, - pytest.mark.requires_azure_openai, - pytest.mark.requires_dts, -] - - -class TestSingleAgent: - """Test suite for single agent functionality.""" - - @pytest.fixture(autouse=True) - def setup(self, agent_client_factory: type[AgentClientFactoryProtocol]) -> None: - """Setup test fixtures.""" - # Create agent client using the factory fixture - _, self.agent_client = agent_client_factory.create() - - def test_agent_registration(self) -> None: - """Test that the Joker agent is registered and accessible.""" - agent = self.agent_client.get_agent("Joker") - assert agent is not None - assert agent.name == "Joker" - - def test_single_interaction(self): - """Test a single interaction with the agent.""" - agent = self.agent_client.get_agent("Joker") - session = agent.create_session() - - response = agent.run("Tell me a short joke about programming.", session=session) - - assert response is not None - assert response.text is not None - assert len(response.text) > 0 - - def test_conversation_continuity(self): - """Test that conversation context is maintained across turns.""" - agent = self.agent_client.get_agent("Joker") - session = agent.create_session() - - # First turn: Ask for a joke about a specific topic - response1 = agent.run("Tell me a joke about cats.", session=session) - assert response1 is not None - assert len(response1.text) > 0 - - # Second turn: Ask a follow-up that requires context - response2 = agent.run("Can you make it funnier?", session=session) - assert response2 is not None - assert len(response2.text) > 0 - - # The agent should understand "it" refers to the previous joke - - def test_multiple_sessions(self): - """Test that different sessions maintain separate contexts.""" - agent = self.agent_client.get_agent("Joker") - - # Create two separate sessions - session1 = agent.create_session() - session2 = agent.create_session() - - assert session1.durable_session_id != session2.durable_session_id - - # Send different messages to each session - response1 = agent.run("Tell me a joke about dogs.", session=session1) - response2 = agent.run("Tell me a joke about birds.", session=session2) - - assert response1 is not None - assert response2 is not None - assert response1.text != response2.text diff --git a/python/packages/durabletask/tests/integration_tests/test_02_dt_multi_agent.py b/python/packages/durabletask/tests/integration_tests/test_02_dt_multi_agent.py deleted file mode 100644 index 17f4b741ca1..00000000000 --- a/python/packages/durabletask/tests/integration_tests/test_02_dt_multi_agent.py +++ /dev/null @@ -1,116 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Integration tests for multi-agent functionality. - -Tests operations with multiple specialized agents: -- Multiple agent registration -- Agent-specific tool usage -- Independent thread management per agent -- Concurrent agent operations -- Agent isolation and tool routing -""" - -from typing import Any, Protocol - -import pytest - -from agent_framework_durabletask import DurableAIAgentClient - - -class AgentClientFactoryProtocol(Protocol): - """Protocol for the agent client factory fixture.""" - - @classmethod - def create(cls, max_poll_retries: int = 90) -> tuple[Any, DurableAIAgentClient]: ... - - -# Agent names from the 02_multi_agent sample -WEATHER_AGENT_NAME: str = "WeatherAgent" -MATH_AGENT_NAME: str = "MathAgent" - -# Module-level markers - applied to all tests in this module -pytestmark = [ - pytest.mark.flaky, - pytest.mark.integration, - pytest.mark.sample("02_multi_agent"), - pytest.mark.integration_test, - pytest.mark.requires_azure_openai, - pytest.mark.requires_dts, -] - - -class TestMultiAgent: - """Test suite for multi-agent functionality.""" - - @pytest.fixture(autouse=True) - def setup(self, agent_client_factory: type[AgentClientFactoryProtocol]) -> None: - """Setup test fixtures.""" - # Create agent client using the factory fixture - _, self.agent_client = agent_client_factory.create() - - def test_multiple_agents_registered(self) -> None: - """Test that both agents are registered and accessible.""" - weather_agent = self.agent_client.get_agent(WEATHER_AGENT_NAME) - math_agent = self.agent_client.get_agent(MATH_AGENT_NAME) - - assert weather_agent is not None - assert weather_agent.name == WEATHER_AGENT_NAME - assert math_agent is not None - assert math_agent.name == MATH_AGENT_NAME - - @pytest.mark.skip(reason="Flaky in CI: times out / crashes the xdist runner; temporarily disabled.") - def test_weather_agent_with_tool(self): - """Test weather agent with weather tool execution.""" - agent = self.agent_client.get_agent(WEATHER_AGENT_NAME) - session = agent.create_session() - - response = agent.run("What's the weather in Seattle?", session=session) - - assert response is not None - assert response.text is not None - # Should contain weather information from the tool - assert len(response.text) > 0 - - # Verify that the get_weather tool was actually invoked - tool_calls = [ - content for msg in response.messages for content in msg.contents if content.type == "function_call" - ] - assert len(tool_calls) > 0, "Expected at least one tool call" - assert any(call.name == "get_weather" for call in tool_calls), "Expected get_weather tool to be called" - - @pytest.mark.skip(reason="Flaky in CI: times out / crashes the xdist runner; temporarily disabled.") - def test_math_agent_with_tool(self): - """Test math agent with calculation tool execution.""" - agent = self.agent_client.get_agent(MATH_AGENT_NAME) - session = agent.create_session() - - response = agent.run("Calculate a 20% tip on a $50 bill.", session=session) - - assert response is not None - assert response.text is not None - # Should contain calculation results from the tool - assert len(response.text) > 0 - - # Verify that the calculate_tip tool was actually invoked - tool_calls = [ - content for msg in response.messages for content in msg.contents if content.type == "function_call" - ] - assert len(tool_calls) > 0, "Expected at least one tool call" - assert any(call.name == "calculate_tip" for call in tool_calls), "Expected calculate_tip tool to be called" - - @pytest.mark.skip( - reason="Flaky in CI: times out waiting for live Azure responses; temporarily disabled and tracked in #6777." - ) - def test_multiple_calls_to_same_agent(self): - """Test multiple sequential calls to the same agent.""" - agent = self.agent_client.get_agent(WEATHER_AGENT_NAME) - session = agent.create_session() - - # Multiple weather queries - response1 = agent.run("What's the weather in Chicago?", session=session) - response2 = agent.run("And what about Los Angeles?", session=session) - - assert response1 is not None - assert response2 is not None - assert len(response1.text) > 0 - assert len(response2.text) > 0 diff --git a/python/packages/durabletask/tests/integration_tests/test_03_dt_single_agent_streaming.py b/python/packages/durabletask/tests/integration_tests/test_03_dt_single_agent_streaming.py deleted file mode 100644 index f3af85f4bec..00000000000 --- a/python/packages/durabletask/tests/integration_tests/test_03_dt_single_agent_streaming.py +++ /dev/null @@ -1,236 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -""" -Integration Tests for Reliable Streaming Sample - -Tests the reliable streaming sample using Redis Streams for persistent message delivery. - -The worker process is automatically started by the test fixture. - -Prerequisites: -- Azure OpenAI credentials configured (see packages/durabletask/tests/integration_tests/.env.example) -- DTS emulator running (docker run -d -p 8080:8080 mcr.microsoft.com/durabletask/emulator:latest) -- Redis running (docker run -d --name redis -p 6379:6379 redis:latest) - -Usage: - uv run pytest packages/durabletask/tests/integration_tests/test_03_single_agent_streaming.py -v -""" - -import asyncio -import os -import sys -import time -from datetime import timedelta -from pathlib import Path -from typing import Any, Protocol - -import pytest -import redis.asyncio as aioredis - -from agent_framework_durabletask import DurableAIAgentClient - - -class AgentClientFactoryProtocol(Protocol): - """Protocol for the agent client factory fixture.""" - - @classmethod - def create(cls, max_poll_retries: int = 90) -> tuple[Any, DurableAIAgentClient]: ... - - -# Add sample directory to path to import RedisStreamResponseHandler -SAMPLE_DIR = Path(__file__).parents[4] / "samples" / "04-hosting" / "durabletask" / "03_single_agent_streaming" -sys.path.insert(0, str(SAMPLE_DIR)) - -from redis_stream_response_handler import ( # type: ignore[reportMissingImports] # pyrefly: ignore[missing-import] # ty: ignore[unresolved-import] # noqa: E402 - RedisStreamResponseHandler, -) - -# Module-level markers - applied to all tests in this file -pytestmark = [ - pytest.mark.flaky, - pytest.mark.integration, - pytest.mark.sample("03_single_agent_streaming"), - pytest.mark.integration_test, - pytest.mark.requires_azure_openai, - pytest.mark.requires_dts, - pytest.mark.requires_redis, -] - - -class TestSampleReliableStreaming: - """Tests for 03_single_agent_streaming sample.""" - - @pytest.fixture(autouse=True) - def setup(self, agent_client_factory: type[AgentClientFactoryProtocol], orchestration_helper) -> None: - """Setup test fixtures.""" - # Create agent client using the factory fixture - _, self.agent_client = agent_client_factory.create() - self.helper = orchestration_helper - - # Redis configuration - self.redis_connection_string = os.environ.get("REDIS_CONNECTION_STRING", "redis://localhost:6379") - self.redis_stream_ttl_minutes = int(os.environ.get("REDIS_STREAM_TTL_MINUTES", "10")) - - async def _get_stream_handler(self) -> RedisStreamResponseHandler: # type: ignore[reportMissingTypeStubs] - """Create a new Redis stream handler for each request.""" - redis_client = aioredis.from_url( # type: ignore[reportUnknownMemberType] - self.redis_connection_string, - encoding="utf-8", - decode_responses=False, - ) - return RedisStreamResponseHandler( # type: ignore[reportUnknownMemberType] - redis_client=redis_client, - stream_ttl=timedelta(minutes=self.redis_stream_ttl_minutes), - ) - - async def _stream_from_redis( - self, - session_key: str, - cursor: str | None = None, - timeout: float = 30.0, - ) -> tuple[str, bool, str]: - """ - Stream responses from Redis using the sample's RedisStreamResponseHandler. - - Args: - session_key: The conversation/thread ID to stream from - cursor: Optional cursor to resume from - timeout: Maximum time to wait for stream completion - - Returns: - Tuple of (accumulated text, completion status, last entry_id) - """ - accumulated_text = "" - is_complete = False - last_entry_id = cursor if cursor else "0-0" - start_time = time.time() - - async with await self._get_stream_handler() as stream_handler: # type: ignore[reportUnknownMemberType] - try: - async for chunk in stream_handler.read_stream(session_key, cursor): # type: ignore[reportUnknownMemberType] - if time.time() - start_time > timeout: - break - - last_entry_id = chunk.entry_id # type: ignore[reportUnknownMemberType] - - if chunk.error: # type: ignore[reportUnknownMemberType] - # Stream not found or timeout - this is expected if agent hasn't written yet - # Don't raise an error, just return what we have - break - - if chunk.is_done: # type: ignore[reportUnknownMemberType] - is_complete = True - break - - if chunk.text: # type: ignore[reportUnknownMemberType] - accumulated_text += chunk.text # type: ignore[reportUnknownMemberType] - - except Exception as ex: - # For test purposes, we catch exceptions and return what we have - if "timed out" not in str(ex).lower(): - raise - - return accumulated_text, is_complete, last_entry_id # type: ignore[reportReturnType] - - def test_agent_run_and_stream(self) -> None: - """Test agent execution with Redis streaming.""" - # Get the TravelPlanner agent - travel_planner = self.agent_client.get_agent("TravelPlanner") - assert travel_planner is not None - assert travel_planner.name == "TravelPlanner" - - # Create a new session - session = travel_planner.create_session() - assert session.durable_session_id is not None - assert session.durable_session_id.key is not None - session_key = str(session.durable_session_id.key) - - # Start agent run with wait_for_response=False for non-blocking execution - travel_planner.run( - "Plan a 1-day trip to Seattle in 1 sentence", session=session, options={"wait_for_response": False} - ) - - # Poll Redis stream with retries to handle race conditions - # The agent may take a few seconds to process and start writing to Redis - # We use cursor-based resumption to continue reading from where we left off - max_retries = 20 - retry_count = 0 - accumulated_text = "" - is_complete = False - cursor: str | None = None - - while retry_count < max_retries and not is_complete: - text, is_complete, last_cursor = asyncio.run( - self._stream_from_redis(session_key, cursor=cursor, timeout=10.0) - ) - accumulated_text += text - cursor = last_cursor # Resume from last position on next read - - if is_complete: - # Stream completed successfully - break - - if len(accumulated_text) > 0: - # Got content but not completion marker yet - keep reading without delay - # The agent may still be streaming or about to write completion marker - continue - - # No content yet - wait before retrying - time.sleep(2) - retry_count += 1 - - # Verify we got content - assert len(accumulated_text) > 0, ( - f"Expected text content but got empty string for session_key: {session_key} after {retry_count} retries" - ) - assert "seattle" in accumulated_text.lower(), f"Expected 'seattle' in response but got: {accumulated_text}" - assert is_complete, "Expected stream to be complete" - - def test_stream_with_cursor_resumption(self) -> None: - """Test streaming with cursor-based resumption.""" - # Get the TravelPlanner agent - travel_planner = self.agent_client.get_agent("TravelPlanner") - session = travel_planner.create_session() - assert session.durable_session_id is not None - assert session.durable_session_id.key is not None - session_key = str(session.durable_session_id.key) - - # Start agent run - travel_planner.run("What's the weather like?", session=session, options={"wait_for_response": False}) - - # Wait for agent to start writing - time.sleep(3) - - # Read partial stream to get a cursor - async def get_partial_stream() -> tuple[str, str]: - async with await self._get_stream_handler() as stream_handler: # type: ignore[reportUnknownMemberType] - accumulated_text = "" - last_entry_id = "0-0" - chunk_count = 0 - - # Read just first 2 chunks - async for chunk in stream_handler.read_stream(session_key): # type: ignore[reportUnknownMemberType] - last_entry_id = chunk.entry_id # type: ignore[reportUnknownMemberType] - if chunk.text: # type: ignore[reportUnknownMemberType] - accumulated_text += chunk.text # type: ignore[reportUnknownMemberType] - chunk_count += 1 - if chunk_count >= 2: - break - - return accumulated_text, last_entry_id # type: ignore[reportReturnType] - - partial_text, cursor = asyncio.run(get_partial_stream()) - - # Resume from cursor - remaining_text, _, _ = asyncio.run(self._stream_from_redis(session_key, cursor=cursor)) - - # Verify we got some initial content - assert len(partial_text) > 0 - - # Combined text should be coherent - full_text = partial_text + remaining_text - assert len(full_text) > 0 - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) diff --git a/python/packages/durabletask/tests/integration_tests/test_04_dt_single_agent_orchestration_chaining.py b/python/packages/durabletask/tests/integration_tests/test_04_dt_single_agent_orchestration_chaining.py deleted file mode 100644 index 2b885bb0746..00000000000 --- a/python/packages/durabletask/tests/integration_tests/test_04_dt_single_agent_orchestration_chaining.py +++ /dev/null @@ -1,111 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Integration tests for single agent orchestration with chaining. - -Tests orchestration patterns with sequential agent calls: -- Orchestration registration and execution -- Sequential agent calls on same thread -- Conversation continuity in orchestrations -- Thread context preservation -""" - -import json -import logging -from typing import Any, Protocol - -import pytest -from durabletask.client import OrchestrationStatus - -from agent_framework_durabletask import DurableAIAgentClient - - -class AgentClientFactoryProtocol(Protocol): - """Protocol for the agent client factory fixture.""" - - @classmethod - def create(cls, max_poll_retries: int = 90) -> tuple[Any, DurableAIAgentClient]: ... - - -# Agent name from the 04_single_agent_orchestration_chaining sample -WRITER_AGENT_NAME: str = "WriterAgent" - -# Configure logging -logging.basicConfig(level=logging.WARNING) - -# Module-level markers - applied to all tests in this module -pytestmark = [ - pytest.mark.flaky, - pytest.mark.integration, - pytest.mark.sample("04_single_agent_orchestration_chaining"), - pytest.mark.integration_test, - pytest.mark.requires_azure_openai, - pytest.mark.requires_dts, -] - - -class TestSingleAgentOrchestrationChaining: - """Test suite for single agent orchestration with chaining.""" - - @pytest.fixture(autouse=True) - def setup(self, agent_client_factory: type[AgentClientFactoryProtocol], orchestration_helper) -> None: - """Setup test fixtures.""" - # Create agent client using the factory fixture - self.dts_client, self.agent_client = agent_client_factory.create() - self.orch_helper = orchestration_helper - - def test_agent_registered(self): - """Test that the Writer agent is registered.""" - agent = self.agent_client.get_agent(WRITER_AGENT_NAME) - assert agent is not None - assert agent.name == WRITER_AGENT_NAME - - def test_chaining_context_preserved(self): - """Test that context is preserved across agent runs in orchestration.""" - # Start the orchestration - instance_id = self.dts_client.schedule_new_orchestration( - orchestrator="single_agent_chaining_orchestration", - input="", - ) - - # Wait for completion with output - metadata, output = self.orch_helper.wait_for_orchestration_with_output( - instance_id=instance_id, - timeout=120.0, - ) - - assert metadata is not None - assert output is not None - - # The final output should be a refined sentence - final_text = json.loads(output) - - # Should be a meaningful sentence (not empty or error message) - assert len(final_text) > 10 - assert not final_text.startswith("Error") - - def test_multiple_orchestration_instances(self): - """Test that multiple orchestration instances can run independently.""" - # Start two orchestrations - instance_id_1 = self.dts_client.schedule_new_orchestration( - orchestrator="single_agent_chaining_orchestration", - input="", - ) - instance_id_2 = self.dts_client.schedule_new_orchestration( - orchestrator="single_agent_chaining_orchestration", - input="", - ) - - assert instance_id_1 != instance_id_2 - - # Both should complete - metadata_1 = self.orch_helper.wait_for_orchestration( - instance_id=instance_id_1, - timeout=120.0, - ) - metadata_2 = self.orch_helper.wait_for_orchestration( - instance_id=instance_id_2, - timeout=120.0, - ) - - assert metadata_1.runtime_status == OrchestrationStatus.COMPLETED - assert metadata_2.runtime_status == OrchestrationStatus.COMPLETED diff --git a/python/packages/durabletask/tests/integration_tests/test_05_dt_multi_agent_orchestration_concurrency.py b/python/packages/durabletask/tests/integration_tests/test_05_dt_multi_agent_orchestration_concurrency.py deleted file mode 100644 index 383f0998cf9..00000000000 --- a/python/packages/durabletask/tests/integration_tests/test_05_dt_multi_agent_orchestration_concurrency.py +++ /dev/null @@ -1,87 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Integration tests for multi-agent orchestration with concurrency. - -Tests concurrent execution patterns: -- Parallel agent execution -- Concurrent orchestration tasks -- Independent thread management in parallel -- Result aggregation from concurrent calls -""" - -import json -import logging -from typing import Any, Protocol - -import pytest -from durabletask.client import OrchestrationStatus - -from agent_framework_durabletask import DurableAIAgentClient - - -class AgentClientFactoryProtocol(Protocol): - """Protocol for the agent client factory fixture.""" - - @classmethod - def create(cls, max_poll_retries: int = 90) -> tuple[Any, DurableAIAgentClient]: ... - - -# Agent names from the 05_multi_agent_orchestration_concurrency sample -PHYSICIST_AGENT_NAME: str = "PhysicistAgent" -CHEMIST_AGENT_NAME: str = "ChemistAgent" - -# Configure logging -logging.basicConfig(level=logging.WARNING) - -# Module-level markers -pytestmark = [ - pytest.mark.flaky, - pytest.mark.integration, - pytest.mark.sample("05_multi_agent_orchestration_concurrency"), - pytest.mark.integration_test, - pytest.mark.requires_dts, -] - - -class TestMultiAgentOrchestrationConcurrency: - """Test suite for multi-agent orchestration with concurrency.""" - - @pytest.fixture(autouse=True) - def setup(self, agent_client_factory: type[AgentClientFactoryProtocol], orchestration_helper) -> None: - """Setup test fixtures.""" - # Create agent client using the factory fixture - self.dts_client, self.agent_client = agent_client_factory.create() - self.orch_helper = orchestration_helper - - def test_agents_registered(self): - """Test that both agents are registered.""" - physicist = self.agent_client.get_agent(PHYSICIST_AGENT_NAME) - chemist = self.agent_client.get_agent(CHEMIST_AGENT_NAME) - - assert physicist is not None - assert physicist.name == PHYSICIST_AGENT_NAME - assert chemist is not None - assert chemist.name == CHEMIST_AGENT_NAME - - def test_different_prompts(self): - """Test concurrent orchestration with different prompts.""" - prompts = [ - "What is temperature?", - "Explain molecules.", - ] - - for prompt in prompts: - instance_id = self.dts_client.schedule_new_orchestration( - orchestrator="multi_agent_concurrent_orchestration", - input=prompt, - ) - - metadata, output = self.orch_helper.wait_for_orchestration_with_output( - instance_id=instance_id, - timeout=120.0, - ) - - assert metadata.runtime_status == OrchestrationStatus.COMPLETED - result = json.loads(output) - assert "physicist" in result - assert "chemist" in result diff --git a/python/packages/durabletask/tests/integration_tests/test_06_dt_multi_agent_orchestration_conditionals.py b/python/packages/durabletask/tests/integration_tests/test_06_dt_multi_agent_orchestration_conditionals.py deleted file mode 100644 index 61f1d26e440..00000000000 --- a/python/packages/durabletask/tests/integration_tests/test_06_dt_multi_agent_orchestration_conditionals.py +++ /dev/null @@ -1,86 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Integration tests for multi-agent orchestration with conditionals. - -Tests conditional orchestration patterns: -- Conditional branching in orchestrations -- Agent-based decision making -- Activity function execution -- Structured output handling -- Conditional routing based on agent responses -""" - -import logging -from typing import Any, Protocol - -import pytest -from durabletask.client import OrchestrationStatus - -from agent_framework_durabletask import DurableAIAgentClient - - -class AgentClientFactoryProtocol(Protocol): - """Protocol for the agent client factory fixture.""" - - @classmethod - def create(cls, max_poll_retries: int = 90) -> tuple[Any, DurableAIAgentClient]: ... - - -# Agent names from the 06_multi_agent_orchestration_conditionals sample -SPAM_AGENT_NAME: str = "SpamDetectionAgent" -EMAIL_AGENT_NAME: str = "EmailAssistantAgent" - -# Configure logging -logging.basicConfig(level=logging.WARNING) - -# Module-level markers -pytestmark = [ - pytest.mark.flaky, - pytest.mark.integration, - pytest.mark.sample("06_multi_agent_orchestration_conditionals"), - pytest.mark.integration_test, - pytest.mark.requires_dts, -] - - -class TestMultiAgentOrchestrationConditionals: - """Test suite for multi-agent orchestration with conditionals.""" - - @pytest.fixture(autouse=True) - def setup(self, agent_client_factory: type[AgentClientFactoryProtocol], orchestration_helper) -> None: - """Setup test fixtures.""" - # Create agent client using the factory fixture - self.dts_client, self.agent_client = agent_client_factory.create() - self.orch_helper = orchestration_helper - - def test_agents_registered(self): - """Test that both agents are registered.""" - spam_agent = self.agent_client.get_agent(SPAM_AGENT_NAME) - email_agent = self.agent_client.get_agent(EMAIL_AGENT_NAME) - - assert spam_agent is not None - assert spam_agent.name == SPAM_AGENT_NAME - assert email_agent is not None - assert email_agent.name == EMAIL_AGENT_NAME - - @pytest.mark.skip(reason="Flaky in CI: times out / crashes the xdist runner; temporarily disabled.") - def test_conditional_branching(self): - """Test that conditional branching works correctly.""" - # Test with obvious spam - spam_payload = { - "email_id": "spam-001", - "email_content": "Buy cheap medications online! No prescription needed! Limited time offer!", - } - - spam_instance_id = self.dts_client.schedule_new_orchestration( - orchestrator="spam_detection_orchestration", - input=spam_payload, - ) - - # Both should complete successfully (different branches) - spam_metadata = self.orch_helper.wait_for_orchestration( - instance_id=spam_instance_id, - timeout=120.0, - ) - - assert spam_metadata.runtime_status == OrchestrationStatus.COMPLETED diff --git a/python/packages/durabletask/tests/integration_tests/test_07_dt_single_agent_orchestration_hitl.py b/python/packages/durabletask/tests/integration_tests/test_07_dt_single_agent_orchestration_hitl.py deleted file mode 100644 index 8c90d07e98d..00000000000 --- a/python/packages/durabletask/tests/integration_tests/test_07_dt_single_agent_orchestration_hitl.py +++ /dev/null @@ -1,174 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Integration tests for single agent orchestration with human-in-the-loop. - -Tests human-in-the-loop (HITL) patterns: -- External event waiting and handling -- Timeout handling in orchestrations -- Iterative refinement with human feedback -- Activity function integration -- Approval workflow patterns -""" - -import logging -from typing import Any, Protocol - -import pytest -from durabletask.client import OrchestrationStatus - -from agent_framework_durabletask import DurableAIAgentClient - - -class AgentClientFactoryProtocol(Protocol): - """Protocol for the agent client factory fixture.""" - - @classmethod - def create(cls, max_poll_retries: int = 90) -> tuple[Any, DurableAIAgentClient]: ... - - -# Constants from the 07_single_agent_orchestration_hitl sample -WRITER_AGENT_NAME: str = "WriterAgent" -HUMAN_APPROVAL_EVENT: str = "HumanApproval" - -# Configure logging -logging.basicConfig(level=logging.WARNING) - -# Module-level markers -pytestmark = [ - pytest.mark.flaky, - pytest.mark.integration, - pytest.mark.sample("07_single_agent_orchestration_hitl"), - pytest.mark.integration_test, - pytest.mark.requires_dts, -] - - -class TestSingleAgentOrchestrationHITL: - """Test suite for single agent orchestration with human-in-the-loop.""" - - @pytest.fixture(autouse=True) - def setup(self, agent_client_factory: type[AgentClientFactoryProtocol], orchestration_helper) -> None: - """Setup test fixtures.""" - # Create agent client using the factory fixture - self.dts_client, self.agent_client = agent_client_factory.create() - self.orch_helper = orchestration_helper - - def test_agent_registered(self): - """Test that the Writer agent is registered.""" - agent = self.agent_client.get_agent(WRITER_AGENT_NAME) - assert agent is not None - assert agent.name == WRITER_AGENT_NAME - - def test_hitl_orchestration_with_approval(self): - """Test HITL orchestration with immediate approval.""" - payload = { - "topic": "The benefits of continuous learning", - "max_review_attempts": 3, - "approval_timeout_seconds": 60, - } - - # Start the orchestration - instance_id = self.dts_client.schedule_new_orchestration( - orchestrator="content_generation_hitl_orchestration", - input=payload, - ) - - assert instance_id is not None - - # Wait for orchestration to reach notification point - notification_received = self.orch_helper.wait_for_notification(instance_id, timeout_seconds=90) - assert notification_received, "Failed to receive notification from orchestration" - - # Send approval event - approval_data = {"approved": True, "feedback": ""} - self.orch_helper.raise_event( - instance_id=instance_id, - event_name=HUMAN_APPROVAL_EVENT, - event_data=approval_data, - ) - - # Wait for completion - metadata = self.orch_helper.wait_for_orchestration( - instance_id=instance_id, - timeout=90.0, - ) - - assert metadata is not None - assert metadata.runtime_status == OrchestrationStatus.COMPLETED - - def test_hitl_orchestration_with_rejection_and_feedback(self): - """Test HITL orchestration with rejection and iterative refinement.""" - payload = { - "topic": "Artificial Intelligence in healthcare", - "max_review_attempts": 3, - "approval_timeout_seconds": 60, - } - - # Start the orchestration - instance_id = self.dts_client.schedule_new_orchestration( - orchestrator="content_generation_hitl_orchestration", - input=payload, - ) - - # Wait for orchestration to reach notification point - notification_received = self.orch_helper.wait_for_notification(instance_id, timeout_seconds=90) - assert notification_received, "Failed to receive notification from orchestration" - - # First rejection with feedback - rejection_data = { - "approved": False, - "feedback": "Please make it more concise and add specific examples.", - } - self.orch_helper.raise_event( - instance_id=instance_id, - event_name=HUMAN_APPROVAL_EVENT, - event_data=rejection_data, - ) - - # Wait for orchestration to refine and reach notification point again - notification_received = self.orch_helper.wait_for_notification(instance_id, timeout_seconds=90) - assert notification_received, "Failed to receive notification after refinement" - - # Second approval - approval_data = {"approved": True, "feedback": ""} - self.orch_helper.raise_event( - instance_id=instance_id, - event_name=HUMAN_APPROVAL_EVENT, - event_data=approval_data, - ) - - # Wait for completion - metadata = self.orch_helper.wait_for_orchestration( - instance_id=instance_id, - timeout=90.0, - ) - - assert metadata is not None - assert metadata.runtime_status == OrchestrationStatus.COMPLETED - - def test_hitl_orchestration_timeout(self): - """Test HITL orchestration timeout behavior.""" - payload = { - "topic": "Cloud computing fundamentals", - "max_review_attempts": 1, - "approval_timeout_seconds": 0.1, # Short timeout for testing - } - - # Start the orchestration - instance_id = self.dts_client.schedule_new_orchestration( - orchestrator="content_generation_hitl_orchestration", - input=payload, - ) - - # Don't send any approval - let it timeout - # The orchestration should fail due to timeout - try: - metadata = self.orch_helper.wait_for_orchestration( - instance_id=instance_id, - timeout=90.0, - ) - # If it completes, it should be failed status due to timeout - assert metadata.runtime_status == OrchestrationStatus.FAILED - except (RuntimeError, TimeoutError): - # Expected - orchestration should timeout and fail - pass diff --git a/python/packages/durabletask/tests/integration_tests/test_08_dt_workflow.py b/python/packages/durabletask/tests/integration_tests/test_08_dt_workflow.py deleted file mode 100644 index 2d55986f13d..00000000000 --- a/python/packages/durabletask/tests/integration_tests/test_08_dt_workflow.py +++ /dev/null @@ -1,90 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Integration tests for the standalone durabletask workflow sample (08_workflow). - -Exercises the standalone (non-Azure-Functions) workflow path: -- ``DurableAIAgentWorker.configure_workflow`` auto-registers the agent entities, - non-agent executor activities, and the workflow orchestrator. -- A client starts the workflow by scheduling its ``dafx-{workflow_name}`` orchestration. -- Conditional routing sends spam to a non-agent handler and legitimate email - through a second agent and a sender executor. -""" - -import logging -from typing import Any, Protocol - -import pytest -from durabletask.client import OrchestrationStatus - -from agent_framework_durabletask import DurableAIAgentClient, workflow_orchestrator_name - -# Must match the workflow name in samples/04-hosting/durabletask/08_workflow/worker.py -WORKFLOW_NAME = "email_triage" - -logging.basicConfig(level=logging.WARNING) - - -class AgentClientFactoryProtocol(Protocol): - """Protocol for the agent client factory fixture.""" - - @classmethod - def create(cls, max_poll_retries: int = 90) -> tuple[Any, DurableAIAgentClient]: ... - - -# Module-level markers -pytestmark = [ - pytest.mark.flaky, - pytest.mark.integration, - pytest.mark.sample("08_workflow"), - pytest.mark.integration_test, - pytest.mark.requires_dts, -] - - -class TestStandaloneWorkflow: - """Standalone (non-Azure-Functions) workflow execution on a durabletask worker.""" - - @pytest.fixture(autouse=True) - def setup(self, agent_client_factory: type[AgentClientFactoryProtocol], orchestration_helper) -> None: - """Provide a DTS client and orchestration helper for each test.""" - self.dts_client, self.agent_client = agent_client_factory.create() - self.orch_helper = orchestration_helper - - def test_legitimate_email_drafts_response(self) -> None: - """A legitimate email routes through the email agent and is 'sent'.""" - instance_id = self.dts_client.schedule_new_orchestration( - orchestrator=workflow_orchestrator_name(WORKFLOW_NAME), - input=( - "Hi team, just a reminder about our sprint planning meeting tomorrow at 10 AM. " - "Please review the agenda in Jira." - ), - ) - - metadata, output = self.orch_helper.wait_for_orchestration_with_output( - instance_id=instance_id, - timeout=180.0, - ) - - assert metadata.runtime_status == OrchestrationStatus.COMPLETED - assert output is not None - assert "Email sent" in str(output) - - def test_spam_email_handled(self) -> None: - """A spam email routes to the non-agent spam handler.""" - instance_id = self.dts_client.schedule_new_orchestration( - orchestrator=workflow_orchestrator_name(WORKFLOW_NAME), - input="URGENT! You've won $1,000,000! Click here now to claim your prize! Limited time offer!", - ) - - metadata, output = self.orch_helper.wait_for_orchestration_with_output( - instance_id=instance_id, - timeout=180.0, - ) - - assert metadata.runtime_status == OrchestrationStatus.COMPLETED - assert output is not None - assert "spam" in str(output).lower() - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) diff --git a/python/packages/durabletask/tests/integration_tests/test_09_dt_workflow_hitl.py b/python/packages/durabletask/tests/integration_tests/test_09_dt_workflow_hitl.py deleted file mode 100644 index 63ce9343df8..00000000000 --- a/python/packages/durabletask/tests/integration_tests/test_09_dt_workflow_hitl.py +++ /dev/null @@ -1,112 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Integration tests for the standalone durabletask HITL workflow sample (09_workflow_hitl). - -Exercises the human-in-the-loop workflow path on a standalone durabletask worker: -- The ``InputRouter`` start executor receives a typed ``ContentSubmission`` that the - shared engine reconstructs from the client's JSON payload (no manual parsing). -- An analysis agent produces a recommendation, then the workflow pauses for human - approval via ``request_info``. -- The client retrieves the pending request, replies with ``send_hitl_response``, and - the workflow resumes to an approved/rejected outcome read via ``await_workflow_output``. -""" - -import logging -import time -from typing import Any - -import pytest - -from agent_framework_durabletask import DurableWorkflowClient - -logging.basicConfig(level=logging.WARNING) - -# Must match the workflow name in samples/04-hosting/durabletask/09_workflow_hitl/worker.py -WORKFLOW_NAME = "content_moderation" - -# Module-level markers -pytestmark = [ - pytest.mark.flaky, - pytest.mark.integration, - pytest.mark.sample("09_workflow_hitl"), - pytest.mark.integration_test, - pytest.mark.requires_dts, - pytest.mark.requires_azure_openai, -] - - -def _wait_for_hitl_request( - client: DurableWorkflowClient, instance_id: str, timeout_seconds: int = 90 -) -> list[dict[str, Any]]: - """Poll until the workflow records at least one pending HITL request.""" - deadline = time.time() + timeout_seconds - while time.time() < deadline: - pending = client.get_pending_hitl_requests(instance_id, workflow_name=WORKFLOW_NAME) - if pending: - return pending - time.sleep(2) - raise AssertionError(f"Timed out waiting for a HITL request on instance {instance_id}") - - -class TestStandaloneWorkflowHITL: - """Human-in-the-loop workflow execution on a standalone durabletask worker.""" - - @pytest.fixture(autouse=True) - def setup(self, workflow_client: DurableWorkflowClient) -> None: - """Bind the DurableWorkflowClient for the current sample worker.""" - self.client = workflow_client - - def _run_case(self, submission: dict[str, Any], *, approve: bool) -> Any: - """Start a moderation case, answer the HITL pause, and return the final output.""" - instance_id = self.client.start_workflow(input=submission, workflow_name=WORKFLOW_NAME) - - pending = _wait_for_hitl_request(self.client, instance_id) - request = pending[0] - assert request["request_id"] - assert request["source_executor_id"] - - self.client.send_hitl_response( - instance_id, - request["request_id"], - {"approved": approve, "reviewer_notes": "Looks good." if approve else "Violates content policy."}, - workflow_name=WORKFLOW_NAME, - ) - - return self.client.await_workflow_output(instance_id, workflow_name=WORKFLOW_NAME, timeout_seconds=180) - - def test_hitl_workflow_approval(self) -> None: - """Appropriate content is approved after the reviewer says yes.""" - output = self._run_case( - { - "content_id": "article-001", - "title": "Introduction to AI in Healthcare", - "body": ( - "Artificial intelligence is improving healthcare by enabling faster diagnosis, " - "personalized treatment plans, and better patient outcomes." - ), - "author": "Dr. Jane Smith", - }, - approve=True, - ) - - assert output is not None - assert "APPROVED" in str(output).upper() - - def test_hitl_workflow_rejection(self) -> None: - """Spammy content is rejected after the reviewer says no.""" - output = self._run_case( - { - "content_id": "article-002", - "title": "Get Rich Quick", - "body": "Click here NOW to make $10,000 overnight! GUARANTEED! Limited time offer!", - "author": "Definitely Not Spam", - }, - approve=False, - ) - - assert output is not None - assert "REJECTED" in str(output).upper() - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) diff --git a/python/packages/durabletask/tests/integration_tests/test_11_dt_subworkflow.py b/python/packages/durabletask/tests/integration_tests/test_11_dt_subworkflow.py deleted file mode 100644 index 2578fd4c2a5..00000000000 --- a/python/packages/durabletask/tests/integration_tests/test_11_dt_subworkflow.py +++ /dev/null @@ -1,72 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Integration tests for the composed sub-workflow sample (11_subworkflow). - -Exercises workflow *composition* on a standalone durabletask worker: -- An outer ``review_pipeline`` embeds an inner ``sentiment_analysis`` workflow via a - ``WorkflowExecutor`` node (``sentiment_sub``). -- ``DurableAIAgentWorker.configure_workflow`` walks the composition and registers a - durable orchestration for each workflow; the inner workflow runs as a child - orchestration when the outer reaches the ``WorkflowExecutor`` node. -- The inner workflow's output (a sentiment summary) is forwarded to the outer - ``reporter`` executor, which produces the final result. - -The inner workflow hosts an AI agent, so these tests require model credentials. -""" - -import logging -from typing import Any - -import pytest - -from agent_framework_durabletask import DurableWorkflowClient - -logging.basicConfig(level=logging.WARNING) - -# Must match the outer workflow name in samples/04-hosting/durabletask/11_subworkflow/worker.py -WORKFLOW_NAME = "review_pipeline" - -# Module-level markers -pytestmark = [ - pytest.mark.flaky, - pytest.mark.integration, - pytest.mark.sample("11_subworkflow"), - pytest.mark.integration_test, - pytest.mark.requires_dts, - pytest.mark.requires_azure_openai, -] - - -class TestSubworkflowComposition: - """Composed (outer + inner) workflow execution on a standalone durabletask worker.""" - - @pytest.fixture(autouse=True) - def setup(self, workflow_client: DurableWorkflowClient) -> None: - """Bind the DurableWorkflowClient for the current sample worker.""" - self.client = workflow_client - - def _run(self, review: str) -> Any: - """Run the composed workflow with a review and return its final output.""" - instance_id = self.client.start_workflow(input=review, workflow_name=WORKFLOW_NAME) - return self.client.await_workflow_output(instance_id, workflow_name=WORKFLOW_NAME, timeout_seconds=180) - - def test_positive_review_runs_through_subworkflow(self) -> None: - """A positive review flows through the embedded sentiment sub-workflow to a report.""" - output = self._run( - "Absolutely love this espresso machine - it heats up fast and the coffee is consistently great." - ) - - assert output is not None - # The outer reporter wraps the inner sub-workflow's forwarded sentiment summary. - assert "sentiment" in str(output).lower() - - def test_negative_review_runs_through_subworkflow(self) -> None: - """A negative review also completes the composed pipeline end-to-end.""" - output = self._run("Disappointed. The device stopped working after two weeks and support never replied.") - - assert output is not None - assert "sentiment" in str(output).lower() - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) diff --git a/python/packages/durabletask/tests/integration_tests/test_12_dt_subworkflow_hitl.py b/python/packages/durabletask/tests/integration_tests/test_12_dt_subworkflow_hitl.py deleted file mode 100644 index 673be16bbee..00000000000 --- a/python/packages/durabletask/tests/integration_tests/test_12_dt_subworkflow_hitl.py +++ /dev/null @@ -1,152 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Integration tests for the composed sub-workflow HITL sample (12_subworkflow_hitl). - -Exercises human-in-the-loop **inside a nested sub-workflow** on a standalone -durabletask worker: -- An outer ``moderation_pipeline`` embeds an inner ``human_review`` workflow via a - ``WorkflowExecutor`` node (``review_sub``); on the durable host the inner workflow - runs as a child orchestration. -- The inner ``review_gate`` pauses via ``request_info``. The pending request surfaces - at the top-level instance with a **qualified** id ``review_sub~0~{requestId}`` (the - ``~{ordinal}~`` hop addresses the specific child the node dispatched). -- The client responds with that qualified id against the *top-level* instance and the - host routes it to the owning child orchestration, resuming to an approved/rejected - outcome. - -This sample hosts **no AI agents**, so it needs only the DTS emulator (no model -credentials), which makes it a deterministic end-to-end check of the nested-HITL -addressing. -""" - -import logging -import time -from typing import Any - -import pytest - -from agent_framework_durabletask import DurableWorkflowClient -from agent_framework_durabletask._workflows.naming import SUBWORKFLOW_REQUEST_SEPARATOR - -logging.basicConfig(level=logging.WARNING) - -# Must match the outer workflow name in samples/04-hosting/durabletask/12_subworkflow_hitl/worker.py -WORKFLOW_NAME = "moderation_pipeline" -# The WorkflowExecutor node id that embeds the inner HITL workflow. -SUBWORKFLOW_NODE_ID = "review_sub" - -# Module-level markers. No requires_azure_openai: the sample hosts no agents. -pytestmark = [ - pytest.mark.flaky, - pytest.mark.integration, - pytest.mark.sample("12_subworkflow_hitl"), - pytest.mark.integration_test, - pytest.mark.requires_dts, -] - - -def _wait_for_hitl_request( - client: DurableWorkflowClient, instance_id: str, timeout_seconds: int = 90 -) -> list[dict[str, Any]]: - """Poll until the workflow (or a nested sub-workflow) records a pending HITL request.""" - deadline = time.time() + timeout_seconds - while time.time() < deadline: - pending = client.get_pending_hitl_requests(instance_id, workflow_name=WORKFLOW_NAME) - if pending: - return pending - time.sleep(2) - raise AssertionError(f"Timed out waiting for a nested HITL request on instance {instance_id}") - - -class TestSubworkflowHITL: - """Nested (sub-workflow) human-in-the-loop on a standalone durabletask worker.""" - - @pytest.fixture(autouse=True) - def setup(self, workflow_client: DurableWorkflowClient) -> None: - """Bind the DurableWorkflowClient for the current sample worker.""" - self.client = workflow_client - - def _run_case(self, submission: dict[str, Any], *, approve: bool) -> tuple[dict[str, Any], Any]: - """Start a moderation case, answer the nested HITL pause, return (request, output).""" - instance_id = self.client.start_workflow(input=submission, workflow_name=WORKFLOW_NAME) - - pending = _wait_for_hitl_request(self.client, instance_id) - request = pending[0] - - self.client.send_hitl_response( - instance_id, - request["request_id"], - {"approved": approve, "reviewer_notes": "Looks good." if approve else "Violates content policy."}, - workflow_name=WORKFLOW_NAME, - ) - - output = self.client.await_workflow_output(instance_id, workflow_name=WORKFLOW_NAME, timeout_seconds=180) - return request, output - - def test_nested_request_id_is_qualified_with_ordinal(self) -> None: - """The nested pending request surfaces with a ``review_sub~0~{id}`` qualified id.""" - instance_id = self.client.start_workflow( - input={ - "content_id": "article-100", - "title": "Quarterly Roadmap", - "body": "A summary of the upcoming features planned for the next quarter.", - }, - workflow_name=WORKFLOW_NAME, - ) - - pending = _wait_for_hitl_request(self.client, instance_id) - - assert len(pending) == 1 - request = pending[0] - # The qualifier carries the node id and the child's ordinal (0 for the single - # dispatch), then the inner bare request id: ``review_sub~0~{requestId}``. - expected_prefix = f"{SUBWORKFLOW_NODE_ID}{SUBWORKFLOW_REQUEST_SEPARATOR}0{SUBWORKFLOW_REQUEST_SEPARATOR}" - assert request["request_id"].startswith(expected_prefix), request["request_id"] - # The bare inner id is non-empty after the qualifier. - assert request["request_id"][len(expected_prefix) :] - # The originating executor is the inner workflow's review gate. - assert request["source_executor_id"] == "review_gate" - - # Drain the pause so the worker does not leave the instance hanging. - self.client.send_hitl_response( - instance_id, - request["request_id"], - {"approved": True, "reviewer_notes": "ok"}, - workflow_name=WORKFLOW_NAME, - ) - self.client.await_workflow_output(instance_id, workflow_name=WORKFLOW_NAME, timeout_seconds=180) - - def test_nested_hitl_approval(self) -> None: - """Responding 'approved' to the nested request resumes the outer workflow to APPROVED.""" - _request, output = self._run_case( - { - "content_id": "article-001", - "title": "Introduction to AI in Healthcare", - "body": ( - "Artificial intelligence is improving healthcare by enabling faster diagnosis, " - "personalized treatment plans, and better patient outcomes." - ), - }, - approve=True, - ) - - assert output is not None - assert "APPROVED" in str(output).upper() - - def test_nested_hitl_rejection(self) -> None: - """Responding 'rejected' to the nested request resumes the outer workflow to REJECTED.""" - _request, output = self._run_case( - { - "content_id": "article-002", - "title": "Get Rich Quick", - "body": "Click here NOW to make $10,000 overnight! GUARANTEED! Limited time offer!", - }, - approve=False, - ) - - assert output is not None - assert "REJECTED" in str(output).upper() - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) diff --git a/python/packages/durabletask/tests/test_agent_session_id.py b/python/packages/durabletask/tests/test_agent_session_id.py deleted file mode 100644 index 3902acd22ff..00000000000 --- a/python/packages/durabletask/tests/test_agent_session_id.py +++ /dev/null @@ -1,310 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Unit tests for AgentSessionId and DurableAgentSession.""" - -from typing import Any - -import pytest -from agent_framework import AgentSession - -from agent_framework_durabletask._models import AgentSessionId, DurableAgentSession - - -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_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_parse_with_agent_name_override(self) -> None: - """Test parsing @name@key format with agent_name parameter overrides the name.""" - session_id = AgentSessionId.parse("@OriginalAgent@test-key-123", agent_name="OverriddenAgent") - - assert session_id.name == "OverriddenAgent" - assert session_id.key == "test-key-123" - - def test_parse_without_agent_name_uses_parsed_name(self) -> None: - """Test parsing @name@key format without agent_name uses name from string.""" - session_id = AgentSessionId.parse("@ParsedAgent@test-key-123") - - assert session_id.name == "ParsedAgent" - assert session_id.key == "test-key-123" - - def test_parse_plain_string_with_agent_name(self) -> None: - """Test parsing plain string with agent_name uses entire string as key.""" - session_id = AgentSessionId.parse("simple-thread-123", agent_name="TestAgent") - - assert session_id.name == "TestAgent" - assert session_id.key == "simple-thread-123" - - def test_parse_plain_string_without_agent_name_raises(self) -> None: - """Test parsing plain string without agent_name raises ValueError.""" - with pytest.raises(ValueError) as exc_info: - AgentSessionId.parse("simple-thread-123") - - assert "Invalid agent session ID format" in str(exc_info.value) - - -class TestDurableAgentSession: - """Test suite for DurableAgentSession.""" - - def test_init_with_durable_session_id(self) -> None: - """Test DurableAgentSession initialization with durable session ID.""" - session_id = AgentSessionId(name="TestAgent", key="test-key") - session = DurableAgentSession(durable_session_id=session_id) - - assert session.durable_session_id is not None - assert session.durable_session_id == session_id - - def test_init_without_durable_session_id(self) -> None: - """Test DurableAgentSession initialization without durable session ID.""" - session = DurableAgentSession() - - assert session.durable_session_id is None - - def test_durable_session_id_setter(self) -> None: - """Test setting a durable session ID to an existing session.""" - session = DurableAgentSession() - assert session.durable_session_id is None - - session_id = AgentSessionId(name="TestAgent", key="test-key") - session.durable_session_id = session_id - - assert session.durable_session_id is not None - assert session.durable_session_id == session_id - assert session.durable_session_id.name == "TestAgent" - - def test_from_session_id(self) -> None: - """Test creating DurableAgentSession from session ID.""" - session_id = AgentSessionId(name="TestAgent", key="test-key") - session = DurableAgentSession(durable_session_id=session_id) - - assert isinstance(session, DurableAgentSession) - assert session.durable_session_id is not None - assert session.durable_session_id == session_id - assert session.durable_session_id.name == "TestAgent" - assert session.durable_session_id.key == "test-key" - - def test_init_with_service_session_id(self) -> None: - """Test creating DurableAgentSession with explicit service session ID.""" - session_id = AgentSessionId(name="TestAgent", key="test-key") - session = DurableAgentSession(durable_session_id=session_id, service_session_id="service-123") - - assert session.durable_session_id is not None - assert session.durable_session_id == session_id - assert session.service_session_id == "service-123" - - def test_to_dict_with_durable_session_id(self) -> None: - """Test serialization includes durable session ID.""" - session_id = AgentSessionId(name="TestAgent", key="test-key") - session = DurableAgentSession(durable_session_id=session_id) - - serialized = session.to_dict() - - assert isinstance(serialized, dict) - assert "durable_session_id" in serialized - assert serialized["durable_session_id"] == "@TestAgent@test-key" - - def test_to_dict_without_durable_session_id(self) -> None: - """Test serialization without durable session ID.""" - session = DurableAgentSession() - - serialized = session.to_dict() - - assert isinstance(serialized, dict) - assert "durable_session_id" not in serialized - - def test_from_dict_with_durable_session_id(self) -> None: - """Test deserialization restores durable session ID.""" - serialized: dict[str, Any] = { - "type": "session", - "session_id": "session-123", - "service_session_id": "service-123", - "state": {}, - "durable_session_id": "@TestAgent@test-key", - } - - session = DurableAgentSession.from_dict(serialized) - - assert isinstance(session, DurableAgentSession) - assert session.durable_session_id is not None - assert session.durable_session_id.name == "TestAgent" - assert session.durable_session_id.key == "test-key" - assert session.service_session_id == "service-123" - - def test_from_dict_without_durable_session_id(self) -> None: - """Test deserialization without durable session ID.""" - serialized: dict[str, Any] = { - "type": "session", - "session_id": "session-456", - "service_session_id": "service-456", - "state": {}, - } - - session = DurableAgentSession.from_dict(serialized) - - assert isinstance(session, DurableAgentSession) - assert session.durable_session_id is None - assert session.session_id == "session-456" - - def test_round_trip_serialization(self) -> None: - """Test round-trip serialization preserves durable session ID.""" - session_id = AgentSessionId(name="TestAgent", key="test-key-789") - original = DurableAgentSession(durable_session_id=session_id) - - serialized = original.to_dict() - restored = DurableAgentSession.from_dict(serialized) - - assert isinstance(restored, DurableAgentSession) - assert restored.durable_session_id is not None - assert restored.durable_session_id.name == session_id.name - assert restored.durable_session_id.key == session_id.key - - def test_from_dict_invalid_durable_session_id_type(self) -> None: - """Test deserialization with invalid durable session ID type raises error.""" - serialized = { - "type": "session", - "session_id": "session-123", - "state": {}, - "durable_session_id": 12345, # Invalid type - } - - with pytest.raises(ValueError, match="durable_session_id must be a string"): - DurableAgentSession.from_dict(serialized) - - -class TestAgentSessionCompatibility: - """Test suite for compatibility between AgentSession and DurableAgentSession.""" - - def test_agent_session_to_dict(self) -> None: - """Test that base AgentSession can be serialized.""" - session = AgentSession() - - serialized = session.to_dict() - - assert isinstance(serialized, dict) - assert "session_id" in serialized - - def test_agent_session_from_dict(self) -> None: - """Test that base AgentSession can be deserialized.""" - session = AgentSession() - serialized = session.to_dict() - - restored = AgentSession.from_dict(serialized) - - assert isinstance(restored, AgentSession) - assert restored.session_id == session.session_id - - def test_durable_session_is_agent_session(self) -> None: - """Test that DurableAgentSession is an AgentSession.""" - session = DurableAgentSession() - - assert isinstance(session, AgentSession) - assert isinstance(session, DurableAgentSession) - - -class TestModelIntegration: - """Test suite for integration between models.""" - - def test_session_id_string_format(self) -> None: - """Test that AgentSessionId string format is consistent.""" - session_id = AgentSessionId.with_random_key("AgentEntity") - session_id_str = str(session_id) - - assert session_id_str.startswith("@AgentEntity@") - - def test_session_with_durable_id_preserves_on_serialization(self) -> None: - """Test that session with durable session ID preserves it through serialization.""" - session_id = AgentSessionId(name="TestAgent", key="preserved-key") - session = DurableAgentSession.from_session_id(session_id) - - # Serialize and deserialize - serialized = session.to_dict() - restored = DurableAgentSession.from_dict(serialized) - - # Durable session ID should be preserved - assert restored.durable_session_id is not None - assert restored.durable_session_id.name == "TestAgent" - assert restored.durable_session_id.key == "preserved-key" - - -if __name__ == "__main__": - pytest.main([__file__, "-v", "--tb=short"]) diff --git a/python/packages/durabletask/tests/test_async_bridge.py b/python/packages/durabletask/tests/test_async_bridge.py deleted file mode 100644 index af519e90150..00000000000 --- a/python/packages/durabletask/tests/test_async_bridge.py +++ /dev/null @@ -1,75 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Unit tests for the persistent async bridge used by durable handlers.""" - -from __future__ import annotations - -import asyncio -import threading -from collections.abc import Iterator -from unittest.mock import Mock - -import pytest - -import agent_framework_durabletask._async_bridge as async_bridge - - -@pytest.fixture(autouse=True) -def _restore_bridge_globals() -> Iterator[None]: - old_loop = async_bridge._loop - old_thread = async_bridge._thread - old_lock = async_bridge._lock - - async_bridge._loop = None - async_bridge._thread = None - async_bridge._lock = threading.Lock() - - yield - - new_loop = async_bridge._loop - new_thread = async_bridge._thread - if new_loop is not None and not new_loop.is_closed(): - new_loop.call_soon_threadsafe(new_loop.stop) - if new_thread is not None and new_thread.is_alive(): - new_thread.join(timeout=1) - if new_loop is not None and not new_loop.is_closed(): - new_loop.close() - - async_bridge._loop = old_loop - async_bridge._thread = old_thread - async_bridge._lock = old_lock - - -def test_ensure_loop_reuses_existing_live_loop() -> None: - loop = asyncio.new_event_loop() - thread = Mock() - thread.is_alive.return_value = True - async_bridge._loop = loop - async_bridge._thread = thread - - assert async_bridge._ensure_loop() is loop - - -def test_ensure_loop_replaces_orphaned_loop() -> None: - orphaned_loop = asyncio.new_event_loop() - dead_thread = Mock() - dead_thread.is_alive.return_value = False - async_bridge._loop = orphaned_loop - async_bridge._thread = dead_thread - - new_loop = async_bridge._ensure_loop() - - assert new_loop is not orphaned_loop - assert orphaned_loop.is_closed() is True - assert async_bridge._thread is not None - assert async_bridge._thread.is_alive() is True - - -def test_run_agent_coroutine_executes_on_shared_loop() -> None: - async def _compute() -> str: - await asyncio.sleep(0) - return "done" - - assert async_bridge.run_agent_coroutine(_compute()) == "done" - assert async_bridge._loop is not None - assert async_bridge._thread is not None diff --git a/python/packages/durabletask/tests/test_client.py b/python/packages/durabletask/tests/test_client.py deleted file mode 100644 index 7dc883edc7e..00000000000 --- a/python/packages/durabletask/tests/test_client.py +++ /dev/null @@ -1,133 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Unit tests for DurableAIAgentClient. - -Focuses on critical client workflows: agent retrieval, protocol compliance, and integration. -Run with: pytest tests/test_client.py -v -""" - -from unittest.mock import Mock - -import pytest -from agent_framework import SupportsAgentRun - -from agent_framework_durabletask import DurableAgentSession, DurableAIAgentClient -from agent_framework_durabletask._constants import DEFAULT_MAX_POLL_RETRIES, DEFAULT_POLL_INTERVAL_SECONDS -from agent_framework_durabletask._shim import DurableAIAgent - - -@pytest.fixture -def mock_grpc_client() -> Mock: - """Create a mock TaskHubGrpcClient for testing.""" - return Mock() - - -@pytest.fixture -def agent_client(mock_grpc_client: Mock) -> DurableAIAgentClient: - """Create a DurableAIAgentClient with mock gRPC client.""" - return DurableAIAgentClient(mock_grpc_client) - - -@pytest.fixture -def agent_client_with_custom_polling(mock_grpc_client: Mock) -> DurableAIAgentClient: - """Create a DurableAIAgentClient with custom polling parameters.""" - return DurableAIAgentClient( - mock_grpc_client, - max_poll_retries=15, - poll_interval_seconds=0.5, - ) - - -class TestDurableAIAgentClientGetAgent: - """Test core workflow: retrieving agents from the client.""" - - def test_get_agent_returns_durable_agent_shim(self, agent_client: DurableAIAgentClient) -> None: - """Verify get_agent returns a DurableAIAgent instance.""" - agent = agent_client.get_agent("assistant") - - assert isinstance(agent, DurableAIAgent) - assert isinstance(agent, SupportsAgentRun) # pyrefly: ignore[unsafe-overlap] - - def test_get_agent_shim_has_correct_name(self, agent_client: DurableAIAgentClient) -> None: - """Verify retrieved agent has the correct name.""" - agent = agent_client.get_agent("my_agent") - - assert agent.name == "my_agent" - - def test_get_agent_multiple_times_returns_new_instances(self, agent_client: DurableAIAgentClient) -> None: - """Verify multiple get_agent calls return independent instances.""" - agent1 = agent_client.get_agent("assistant") - agent2 = agent_client.get_agent("assistant") - - assert agent1 is not agent2 # Different object instances - - def test_get_agent_different_agents(self, agent_client: DurableAIAgentClient) -> None: - """Verify client can retrieve multiple different agents.""" - agent1 = agent_client.get_agent("agent1") - agent2 = agent_client.get_agent("agent2") - - assert agent1.name == "agent1" - assert agent2.name == "agent2" - - -class TestDurableAIAgentClientIntegration: - """Test integration scenarios between client and agent shim.""" - - def test_client_agent_has_working_run_method(self, agent_client: DurableAIAgentClient) -> None: - """Verify agent from client has callable run method (even if not yet implemented).""" - agent = agent_client.get_agent("assistant") - - assert hasattr(agent, "run") - assert callable(agent.run) - - def test_client_agent_can_create_sessions(self, agent_client: DurableAIAgentClient) -> None: - """Verify agent from client can create DurableAgentSession instances.""" - agent = agent_client.get_agent("assistant") - - session = agent.create_session() - - assert isinstance(session, DurableAgentSession) - - -class TestDurableAIAgentClientPollingConfiguration: - """Test polling configuration parameters for DurableAIAgentClient.""" - - def test_client_uses_default_polling_parameters(self, agent_client: DurableAIAgentClient) -> None: - """Verify client initializes with default polling parameters.""" - assert agent_client.max_poll_retries == DEFAULT_MAX_POLL_RETRIES - assert agent_client.poll_interval_seconds == DEFAULT_POLL_INTERVAL_SECONDS - - def test_client_accepts_custom_polling_parameters( - self, agent_client_with_custom_polling: DurableAIAgentClient - ) -> None: - """Verify client accepts and stores custom polling parameters.""" - assert agent_client_with_custom_polling.max_poll_retries == 15 - assert agent_client_with_custom_polling.poll_interval_seconds == 0.5 - - def test_client_validates_max_poll_retries(self, mock_grpc_client: Mock) -> None: - """Verify client validates and normalizes max_poll_retries.""" - # Test with zero - should enforce minimum of 1 - client = DurableAIAgentClient(mock_grpc_client, max_poll_retries=0) - assert client.max_poll_retries == 1 - - # Test with negative - should enforce minimum of 1 - client = DurableAIAgentClient(mock_grpc_client, max_poll_retries=-5) - assert client.max_poll_retries == 1 - - def test_client_validates_poll_interval_seconds(self, mock_grpc_client: Mock) -> None: - """Verify client validates and normalizes poll_interval_seconds.""" - # Test with zero - should use default - client = DurableAIAgentClient(mock_grpc_client, poll_interval_seconds=0) - assert client.poll_interval_seconds == DEFAULT_POLL_INTERVAL_SECONDS - - # Test with negative - should use default - client = DurableAIAgentClient(mock_grpc_client, poll_interval_seconds=-0.5) - assert client.poll_interval_seconds == DEFAULT_POLL_INTERVAL_SECONDS - - # Test with valid float - client = DurableAIAgentClient(mock_grpc_client, poll_interval_seconds=2.5) - assert client.poll_interval_seconds == 2.5 - - -if __name__ == "__main__": - pytest.main([__file__, "-v", "--tb=short"]) diff --git a/python/packages/durabletask/tests/test_durable_agent_state.py b/python/packages/durabletask/tests/test_durable_agent_state.py deleted file mode 100644 index d3a36c9a7e4..00000000000 --- a/python/packages/durabletask/tests/test_durable_agent_state.py +++ /dev/null @@ -1,525 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Unit tests for DurableAgentState and related classes.""" - -import json -from datetime import datetime - -import pytest -from agent_framework import Content, Message, UsageDetails - -from agent_framework_durabletask._durable_agent_state import ( - DurableAgentState, - DurableAgentStateContent, - DurableAgentStateFunctionCallContent, - DurableAgentStateMessage, - DurableAgentStateRequest, - DurableAgentStateTextContent, - DurableAgentStateUnknownContent, - DurableAgentStateUsage, -) -from agent_framework_durabletask._models import RunRequest - - -class TestDurableAgentStateRequestOrchestrationId: - """Test suite for DurableAgentStateRequest orchestration_id field.""" - - def test_request_with_orchestration_id(self) -> None: - """Test creating a request with an orchestration_id.""" - request = DurableAgentStateRequest( - correlation_id="corr-123", - created_at=datetime.now(), - messages=[ - DurableAgentStateMessage( - role="user", - contents=[DurableAgentStateTextContent(text="test")], - ) - ], - orchestration_id="orch-456", - ) - - assert request.orchestration_id == "orch-456" - - def test_request_to_dict_includes_orchestration_id(self) -> None: - """Test that to_dict includes orchestrationId when set.""" - request = DurableAgentStateRequest( - correlation_id="corr-123", - created_at=datetime.now(), - messages=[ - DurableAgentStateMessage( - role="user", - contents=[DurableAgentStateTextContent(text="test")], - ) - ], - orchestration_id="orch-789", - ) - - data = request.to_dict() - - assert "orchestrationId" in data - assert data["orchestrationId"] == "orch-789" - - def test_request_to_dict_excludes_orchestration_id_when_none(self) -> None: - """Test that to_dict excludes orchestrationId when not set.""" - request = DurableAgentStateRequest( - correlation_id="corr-123", - created_at=datetime.now(), - messages=[ - DurableAgentStateMessage( - role="user", - contents=[DurableAgentStateTextContent(text="test")], - ) - ], - ) - - data = request.to_dict() - - assert "orchestrationId" not in data - - def test_request_from_dict_with_orchestration_id(self) -> None: - """Test from_dict correctly parses orchestrationId.""" - data = { - "$type": "request", - "correlationId": "corr-123", - "createdAt": "2024-01-01T00:00:00Z", - "messages": [{"role": "user", "contents": [{"$type": "text", "text": "test"}]}], - "orchestrationId": "orch-from-dict", - } - - request = DurableAgentStateRequest.from_dict(data) - - assert request.orchestration_id == "orch-from-dict" - - def test_request_from_run_request_with_orchestration_id(self) -> None: - """Test from_run_request correctly transfers orchestration_id.""" - run_request = RunRequest( - message="test message", - correlation_id="corr-run", - orchestration_id="orch-from-run-request", - ) - - durable_request = DurableAgentStateRequest.from_run_request(run_request) - - assert durable_request.orchestration_id == "orch-from-run-request" - - def test_request_from_run_request_without_orchestration_id(self) -> None: - """Test from_run_request correctly handles missing orchestration_id.""" - run_request = RunRequest( - message="test message", - correlation_id="corr-run", - ) - - durable_request = DurableAgentStateRequest.from_run_request(run_request) - - assert durable_request.orchestration_id is None - - -class TestDurableAgentStateMessageCreatedAt: - """Test suite for DurableAgentStateMessage created_at field handling.""" - - def test_message_from_run_request_without_created_at_preserves_none(self) -> None: - """Test from_run_request handles auto-populated created_at from RunRequest. - - When a RunRequest is created with None for created_at, RunRequest defaults it to - current UTC time. The resulting DurableAgentStateMessage should have this timestamp. - """ - run_request = RunRequest( - message="test message", - correlation_id="corr-run", - created_at=None, # RunRequest will default this to current time - ) - - durable_message = DurableAgentStateMessage.from_run_request(run_request) - - # RunRequest auto-populates created_at, so it should not be None - assert durable_message.created_at is not None - - def test_message_from_run_request_with_created_at_parses_correctly(self) -> None: - """Test from_run_request correctly parses a valid created_at timestamp.""" - run_request = RunRequest( - message="test message", - correlation_id="corr-run", - created_at=datetime(2024, 1, 15, 10, 30, 0), - ) - - durable_message = DurableAgentStateMessage.from_run_request(run_request) - - assert durable_message.created_at is not None - assert durable_message.created_at.year == 2024 - assert durable_message.created_at.month == 1 - assert durable_message.created_at.day == 15 - - -class TestDurableAgentState: - """Test suite for DurableAgentState.""" - - def test_schema_version(self) -> None: - """Test that schema version is set correctly.""" - state = DurableAgentState() - assert state.schema_version == "1.1.0" - - def test_to_dict_serialization(self) -> None: - """Test that to_dict produces correct structure.""" - state = DurableAgentState() - data = state.to_dict() - - assert "schemaVersion" in data - assert "data" in data - assert data["schemaVersion"] == "1.1.0" - assert "conversationHistory" in data["data"] - - def test_from_dict_deserialization(self) -> None: - """Test that from_dict restores state correctly.""" - original_data = { - "schemaVersion": "1.1.0", - "data": { - "conversationHistory": [ - { - "$type": "request", - "correlationId": "test-123", - "createdAt": "2024-01-01T00:00:00Z", - "messages": [ - { - "role": "user", - "contents": [{"$type": "text", "text": "Hello"}], - } - ], - } - ] - }, - } - - state = DurableAgentState.from_dict(original_data) - - assert state.schema_version == "1.1.0" - assert len(state.data.conversation_history) == 1 - assert isinstance(state.data.conversation_history[0], DurableAgentStateRequest) - - def test_round_trip_serialization(self) -> None: - """Test that round-trip serialization preserves data.""" - state = DurableAgentState() - state.data.conversation_history.append( - DurableAgentStateRequest( - correlation_id="test-456", - created_at=datetime.now(), - messages=[ - DurableAgentStateMessage( - role="user", - contents=[DurableAgentStateTextContent(text="Test message")], - ) - ], - ) - ) - - data = state.to_dict() - restored = DurableAgentState.from_dict(data) - - assert restored.schema_version == state.schema_version - assert len(restored.data.conversation_history) == len(state.data.conversation_history) - assert restored.data.conversation_history[0].correlation_id == "test-456" - - def test_function_call_round_trip_preserves_string_arguments(self) -> None: - """Function call arguments should remain strings across durable state replay.""" - original = Message( - role="assistant", - contents=[ - Content.from_function_call( - call_id="call-123", - name="get_weather", - arguments='{"location":"Chicago"}', - ) - ], - ) - - durable_message = DurableAgentStateMessage.from_chat_message(original) - restored = durable_message.to_chat_message() - - assert restored.contents[0].type == "function_call" - assert restored.contents[0].arguments == '{"location": "Chicago"}' - - def test_function_call_content_supports_legacy_mapping_arguments(self) -> None: - """Existing persisted mapping arguments should still restore successfully.""" - content = DurableAgentStateFunctionCallContent( - call_id="call-123", - name="get_weather", - arguments={"location": "Chicago"}, - ) - - restored = content.to_ai_content() - - assert restored.type == "function_call" - assert restored.arguments == '{"location": "Chicago"}' - - -class TestDurableAgentStateUsage: - """Test suite for DurableAgentStateUsage.""" - - def test_usage_init_with_defaults(self) -> None: - """Test creating usage with default values.""" - usage = DurableAgentStateUsage() - - assert usage.input_token_count is None - assert usage.output_token_count is None - assert usage.total_token_count is None - assert usage.extensionData is None - - def test_usage_init_with_values(self) -> None: - """Test creating usage with specific values.""" - usage = DurableAgentStateUsage( - input_token_count=100, - output_token_count=200, - total_token_count=300, - extensionData={"custom_field": "value"}, - ) - - assert usage.input_token_count == 100 - assert usage.output_token_count == 200 - assert usage.total_token_count == 300 - assert usage.extensionData == {"custom_field": "value"} - - def test_usage_to_dict(self) -> None: - """Test that to_dict produces correct structure.""" - usage = DurableAgentStateUsage( - input_token_count=50, - output_token_count=75, - total_token_count=125, - ) - - data = usage.to_dict() - - assert data["inputTokenCount"] == 50 - assert data["outputTokenCount"] == 75 - assert data["totalTokenCount"] == 125 - - def test_usage_to_dict_with_extension_data(self) -> None: - """Test that to_dict includes extensionData when present.""" - usage = DurableAgentStateUsage( - input_token_count=10, - output_token_count=20, - total_token_count=30, - extensionData={"provider_specific": 123}, - ) - - data = usage.to_dict() - - assert "extensionData" in data - assert data["extensionData"] == {"provider_specific": 123} - - def test_usage_from_dict(self) -> None: - """Test that from_dict restores usage correctly.""" - data = { - "inputTokenCount": 100, - "outputTokenCount": 200, - "totalTokenCount": 300, - "extensionData": {"extra": "data"}, - } - - usage = DurableAgentStateUsage.from_dict(data) - - assert usage.input_token_count == 100 - assert usage.output_token_count == 200 - assert usage.total_token_count == 300 - assert usage.extensionData == {"extra": "data"} - - def test_usage_from_usage_details(self) -> None: - """Test creating DurableAgentStateUsage from UsageDetails.""" - usage_details: UsageDetails = { - "input_token_count": 150, - "output_token_count": 250, - "total_token_count": 400, - } - - usage = DurableAgentStateUsage.from_usage(usage_details) - - assert usage is not None - assert usage.input_token_count == 150 - assert usage.output_token_count == 250 - assert usage.total_token_count == 400 - - def test_usage_from_usage_details_with_extension_fields(self) -> None: - """Test that non-standard fields are captured in extensionData.""" - usage_details: UsageDetails = { - "input_token_count": 100, - "output_token_count": 200, - "total_token_count": 300, - } - # Add provider-specific fields (UsageDetails is a TypedDict but allows extra keys) - usage_details["prompt_tokens"] = 100 # type: ignore[typeddict-unknown-key] - usage_details["completion_tokens"] = 200 # type: ignore[typeddict-unknown-key] - - usage = DurableAgentStateUsage.from_usage(usage_details) - - assert usage is not None - assert usage.extensionData is not None - assert usage.extensionData["prompt_tokens"] == 100 - assert usage.extensionData["completion_tokens"] == 200 - - def test_usage_from_usage_none(self) -> None: - """Test that from_usage returns None for None input.""" - usage = DurableAgentStateUsage.from_usage(None) - - assert usage is None - - def test_usage_to_usage_details(self) -> None: - """Test converting back to UsageDetails.""" - usage = DurableAgentStateUsage( - input_token_count=100, - output_token_count=200, - total_token_count=300, - ) - - details = usage.to_usage_details() - - assert details.get("input_token_count") == 100 - assert details.get("output_token_count") == 200 - assert details.get("total_token_count") == 300 - - def test_usage_to_usage_details_with_extension_data(self) -> None: - """Test that extensionData is merged into UsageDetails.""" - usage = DurableAgentStateUsage( - input_token_count=50, - output_token_count=75, - total_token_count=125, - extensionData={"prompt_tokens": 50, "completion_tokens": 75}, - ) - - details = usage.to_usage_details() - - assert details.get("input_token_count") == 50 - assert details.get("output_token_count") == 75 - assert details.get("total_token_count") == 125 - # Extension data should be merged into the result - assert details.get("prompt_tokens") == 50 - assert details.get("completion_tokens") == 75 - - def test_usage_round_trip(self) -> None: - """Test round-trip conversion from UsageDetails to DurableAgentStateUsage and back.""" - original: UsageDetails = { - "input_token_count": 100, - "output_token_count": 200, - "total_token_count": 300, - } - - usage = DurableAgentStateUsage.from_usage(original) - assert usage is not None - restored = usage.to_usage_details() - - assert restored.get("input_token_count") == original.get("input_token_count") - assert restored.get("output_token_count") == original.get("output_token_count") - assert restored.get("total_token_count") == original.get("total_token_count") - - -class TestDurableAgentStateUnknownContent: - """Test suite for DurableAgentStateUnknownContent serialization.""" - - def test_unknown_content_from_content_object_produces_serializable_dict(self) -> None: - """Test that from_unknown_content serializes Content objects to dicts.""" - content = Content.from_mcp_server_tool_call( - call_id="call-1", - tool_name="search", - server_name="learn-mcp", - arguments={"query": "azure functions"}, - ) - - unknown = DurableAgentStateUnknownContent.from_unknown_content(content) - result = unknown.to_dict() - - # The content field should be a dict, not a Content object - assert isinstance(result["content"], dict) - assert result["content"]["type"] == "mcp_server_tool_call" - - def test_unknown_content_to_dict_is_json_serializable(self) -> None: - """Test that to_dict output can be passed to json.dumps without error.""" - content = Content.from_mcp_server_tool_result( - call_id="call-1", - output="Azure Functions documentation...", - ) - - unknown = DurableAgentStateUnknownContent.from_unknown_content(content) - result = unknown.to_dict() - - # This must not raise TypeError - serialized = json.dumps(result) - assert serialized is not None - - def test_unknown_content_round_trip_preserves_content(self) -> None: - """Test that Content objects survive serialization and deserialization.""" - original = Content.from_mcp_server_tool_call( - call_id="call-1", - tool_name="fetch", - server_name="learn-mcp", - arguments={"url": "https://example.com"}, - ) - - unknown = DurableAgentStateUnknownContent.from_unknown_content(original) - restored = unknown.to_ai_content() - - assert restored.type == "mcp_server_tool_call" - assert restored.tool_name == "fetch" - assert restored.server_name == "learn-mcp" - - def test_unknown_content_from_plain_dict_unchanged(self) -> None: - """Test that non-Content values are stored as-is.""" - plain = {"some": "data"} - - unknown = DurableAgentStateUnknownContent.from_unknown_content(plain) - - assert unknown.content == {"some": "data"} - - def test_unknown_content_to_ai_content_fallback_on_invalid_type_dict(self) -> None: - """Test that to_ai_content falls back when dict has 'type' but is not valid Content.""" - invalid = {"type": "bogus_not_a_real_content_type", "extra": "stuff"} - unknown = DurableAgentStateUnknownContent(content=invalid) - - result = unknown.to_ai_content() - - assert result.type == "unknown" - assert result.additional_properties == {"content": invalid} - - def test_from_ai_content_unknown_type_produces_serializable_state(self) -> None: - """Test that unknown content types in message conversion produce JSON-serializable state.""" - content = Content.from_mcp_server_tool_call( - call_id="call-1", - tool_name="search", - server_name="learn-mcp", - arguments={"query": "create function app"}, - ) - - durable_content = DurableAgentStateContent.from_ai_content(content) - data = durable_content.to_dict() - - # Must be fully JSON-serializable - serialized = json.dumps(data) - assert serialized is not None - - def test_state_with_mcp_content_is_json_serializable(self) -> None: - """Test that full DurableAgentState with MCP content can be serialized to JSON. - - This reproduces the scenario from issue #4719 where agent state containing - MCP tool content could not be serialized by Azure Durable Functions. - """ - state = DurableAgentState() - mcp_content = Content.from_mcp_server_tool_call( - call_id="call-1", - tool_name="search", - server_name="learn-mcp", - arguments={"query": "azure functions"}, - ) - message = DurableAgentStateMessage.from_chat_message(Message(role="assistant", contents=[mcp_content])) - state.data.conversation_history.append( - DurableAgentStateRequest( - correlation_id="test-mcp", - created_at=datetime.now(), - messages=[message], - ) - ) - - state_dict = state.to_dict() - - # This simulates what Azure Durable Functions does with entity state - serialized = json.dumps(state_dict) - assert serialized is not None - - -if __name__ == "__main__": - pytest.main([__file__, "-v", "--tb=short"]) diff --git a/python/packages/durabletask/tests/test_durable_entities.py b/python/packages/durabletask/tests/test_durable_entities.py deleted file mode 100644 index 97d3e6e10c9..00000000000 --- a/python/packages/durabletask/tests/test_durable_entities.py +++ /dev/null @@ -1,768 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Unit tests for AgentEntity. - -Run with: pytest tests/test_entities.py -v -""" - -from collections.abc import AsyncIterator -from datetime import datetime -from typing import Any, TypeVar -from unittest.mock import AsyncMock, Mock - -import pytest -from agent_framework import AgentResponse, AgentResponseUpdate, Content, Message, ResponseStream -from pydantic import BaseModel - -from agent_framework_durabletask import ( - AgentEntity, - AgentEntityStateProviderMixin, - DurableAgentState, - DurableAgentStateData, - DurableAgentStateMessage, - DurableAgentStateRequest, - DurableAgentStateResponse, - DurableAgentStateTextContent, - DurableAgentStateTextReasoningContent, - RunRequest, -) -from agent_framework_durabletask._entities import DurableTaskEntityStateProvider - -StateT = TypeVar("StateT") - - -class MockEntityContext: - """Minimal durabletask EntityContext shim for tests.""" - - def __init__(self, initial_state: Any = None) -> None: - self._state = initial_state - - def get_state( - self, - intended_type: type[StateT] | None = None, - default: StateT | None = None, - ) -> Any: - del intended_type - if self._state is None: - return default - return self._state - - def set_state(self, new_state: Any) -> None: - self._state = new_state - - -class _InMemoryStateProvider(AgentEntityStateProviderMixin): - """Test-only state provider for AgentEntity.""" - - def __init__(self, *, thread_id: str, initial_state: dict[str, Any] | None = None) -> None: - self._thread_id = thread_id - self._state_dict: dict[str, Any] = initial_state or {} - - def _get_state_dict(self) -> dict[str, Any]: - return self._state_dict - - def _set_state_dict(self, state: dict[str, Any]) -> None: - self._state_dict = state - - def _get_thread_id_from_entity(self) -> str: - return self._thread_id - - -def _make_entity(agent: Any, callback: Any = None, *, thread_id: str = "test-thread") -> AgentEntity: - return AgentEntity(agent, callback=callback, state_provider=_InMemoryStateProvider(thread_id=thread_id)) - - -def _role_value(chat_message: DurableAgentStateMessage) -> str: - """Helper to extract the string role from a Message.""" - 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) -> AgentResponse: - """Create an AgentResponse with a single assistant message.""" - message = ( - Message(role="assistant", contents=[text]) if text is not None else Message(role="assistant", contents=[""]) - ) - return AgentResponse(messages=[message], created_at="2024-01-01T00:00:00Z") - - -def _create_mock_run(response: AgentResponse | None = None, side_effect: Exception | None = None): - """Create a mock run function that handles stream parameter correctly. - - The durabletask entity code tries run(stream=True) first, then falls back to run(stream=False). - This helper creates a mock that raises TypeError for streaming (to trigger fallback) and - returns the response or raises the side_effect for non-streaming. - """ - - async def mock_run(*args, stream=False, **kwargs): - if stream: - # Simulate "streaming not supported" to trigger fallback - raise TypeError("streaming not supported") - if side_effect: - raise side_effect - return response - - return mock_run - - -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: AgentResponseUpdate, - context: Any, - ) -> None: - await self.stream_mock(update, context) - - async def on_agent_response(self, response: AgentResponse, 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 = _make_entity(mock_agent) - - assert entity.agent == mock_agent - assert len(entity.state.data.conversation_history) == 0 - assert entity.state.data.extension_data is None - assert entity.state.schema_version == DurableAgentState.SCHEMA_VERSION - - def test_init_stores_agent_reference(self) -> None: - """Test that the agent reference is stored correctly.""" - mock_agent = Mock() - mock_agent.name = "TestAgent" - - entity = _make_entity(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 = _make_entity(agent1) - entity2 = _make_entity(agent2) - - assert entity1.agent.__class__.__name__ == "AzureOpenAIAgent" - assert entity2.agent.__class__.__name__ == "CustomAgent" - - -class TestDurableTaskEntityStateProvider: - """Tests for DurableTaskEntityStateProvider wrapper behavior and persistence wiring.""" - - def _make_durabletask_entity_provider( - self, - agent: Any, - *, - initial_state: dict[str, Any] | None = None, - ) -> tuple[DurableTaskEntityStateProvider, MockEntityContext]: - """Create a DurableTaskEntityStateProvider wired to an in-memory durabletask context.""" - entity = DurableTaskEntityStateProvider() - ctx = MockEntityContext(initial_state) - # DurableEntity provides this hook; required for get_state/set_state to work in unit tests. - entity._initialize_entity_context(ctx) # type: ignore[attr-defined, arg-type] # ty: ignore[invalid-argument-type] - return entity, ctx - - def test_reset_persists_cleared_state(self) -> None: - mock_agent = Mock() - - existing_state = { - "schemaVersion": "1.0.0", - "data": { - "conversationHistory": [ - { - "$type": "request", - "correlationId": "corr-existing-1", - "createdAt": "2024-01-01T00:00:00Z", - "messages": [{"role": "user", "contents": [{"$type": "text", "text": "msg1"}]}], - } - ] - }, - } - - entity, ctx = self._make_durabletask_entity_provider(mock_agent, initial_state=existing_state) - - entity.reset() - - persisted = ctx.get_state(dict, default={}) - assert isinstance(persisted, dict) - assert persisted["data"]["conversationHistory"] == [] - - -class TestAgentEntityRunAgent: - """Test suite for the run_agent operation.""" - - async def test_run_executes_agent(self) -> None: - """Test that run executes the agent.""" - mock_agent = Mock() - mock_response = _agent_response("Test response") - - # Mock run() to return response for non-streaming, raise for streaming (to test fallback) - async def mock_run(*args, stream=False, **kwargs): - if stream: - raise TypeError("streaming not supported") - return mock_response - - mock_agent.run = mock_run - - entity = _make_entity(mock_agent) - - result = await entity.run({ - "message": "Test message", - "correlationId": "corr-entity-1", - }) - - # Verify result - assert isinstance(result, AgentResponse) - assert result.text == "Test response" - - async def test_run_agent_streaming_callbacks_invoked(self) -> None: - """Ensure streaming updates trigger callbacks when using run(stream=True).""" - updates = [ - AgentResponseUpdate(contents=[Content.from_text(text="Hello")]), - AgentResponseUpdate(contents=[Content.from_text(text=" world")]), - ] - - async def update_generator() -> AsyncIterator[AgentResponseUpdate]: - for update in updates: - yield update - - mock_agent = Mock() - mock_agent.name = "StreamingAgent" - - # Mock run() to return ResponseStream when stream=True - def mock_run(*args, stream=False, **kwargs): - if stream: - return ResponseStream( - update_generator(), - finalizer=AgentResponse.from_updates, - ) - raise AssertionError("run(stream=False) should not be called when streaming succeeds") - - mock_agent.run = mock_run - - callback = RecordingCallback() - entity = _make_entity(mock_agent, callback=callback, thread_id="session-1") - - result = await entity.run( - { - "message": "Tell me something", - "correlationId": "corr-stream-1", - }, - ) - - assert isinstance(result, AgentResponse) - assert "Hello" in result.text - assert callback.stream_mock.await_count == len(updates) - assert callback.response_mock.await_count == 1 - - # 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" - agent_response = _agent_response("Final response") - mock_agent.run = _create_mock_run(response=agent_response) - - callback = RecordingCallback() - entity = _make_entity(mock_agent, callback=callback, thread_id="session-2") - - result = await entity.run( - { - "message": "Hi", - "correlationId": "corr-final-1", - }, - ) - - assert isinstance(result, AgentResponse) - assert result.text == "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 = _create_mock_run(response=mock_response) - - entity = _make_entity(mock_agent) - - await entity.run({"message": "User message", "correlationId": "corr-entity-2"}) - - # Should have 2 entries: user message + assistant response - user_history = entity.state.data.conversation_history[0].messages - assistant_history = entity.state.data.conversation_history[1].messages - - assert len(user_history) == 1 - - user_msg = user_history[0] - assert _role_value(user_msg) == "user" - assert user_msg.text == "User message" - - assistant_msg = assistant_history[0] - 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 = _create_mock_run(response=_agent_response("Response")) - - entity = _make_entity(mock_agent) - - assert len(entity.state.data.conversation_history) == 0 - - await entity.run({"message": "Message 1", "correlationId": "corr-entity-3a"}) - assert len(entity.state.data.conversation_history) == 2 - - await entity.run({"message": "Message 2", "correlationId": "corr-entity-3b"}) - assert len(entity.state.data.conversation_history) == 4 - - await entity.run({"message": "Message 3", "correlationId": "corr-entity-3c"}) - assert len(entity.state.data.conversation_history) == 6 - - async def test_run_requires_entity_thread_id(self) -> None: - """Test that AgentEntity.run rejects missing entity thread identifiers.""" - mock_agent = Mock() - mock_agent.run = _create_mock_run(response=_agent_response("Response")) - - entity = _make_entity(mock_agent, thread_id="") - - with pytest.raises(ValueError, match="thread_id"): - await entity.run({"message": "Message", "correlationId": "corr-entity-5"}) - - async def test_run_agent_multiple_conversations(self) -> None: - """Test that run_agent maintains history across multiple messages.""" - mock_agent = Mock() - mock_agent.run = _create_mock_run(response=_agent_response("Response")) - - entity = _make_entity(mock_agent) - - # Send multiple messages - await entity.run({"message": "Message 1", "correlationId": "corr-entity-8a"}) - await entity.run({"message": "Message 2", "correlationId": "corr-entity-8b"}) - await entity.run({"message": "Message 3", "correlationId": "corr-entity-8c"}) - - history = entity.state.data.conversation_history - assert len(history) == 6 - assert entity.state.message_count == 6 - - async def test_run_filters_reasoning_content_from_replayed_history(self) -> None: - """Replayed durable history should not include reasoning-only content items.""" - captured_messages: list[Message] = [] - - async def mock_run(*args, stream=False, **kwargs): - if stream: - raise TypeError("streaming not supported") - captured_messages.extend(kwargs["messages"]) - return _agent_response("Response") - - mock_agent = Mock() - mock_agent.run = mock_run - - entity = _make_entity(mock_agent) - entity.state.data = DurableAgentStateData( - conversation_history=[ - DurableAgentStateRequest( - correlation_id="corr-entity-prev-request", - created_at=datetime.now(), - messages=[ - DurableAgentStateMessage( - role="user", - contents=[DurableAgentStateTextContent(text="Hi")], - ) - ], - ), - DurableAgentStateResponse( - correlation_id="corr-entity-prev-response", - created_at=datetime.now(), - messages=[ - DurableAgentStateMessage( - role="assistant", - contents=[ - DurableAgentStateTextReasoningContent(text="Let me think."), - DurableAgentStateTextContent(text="Hello there."), - ], - ) - ], - ), - ] - ) - - await entity.run({"message": "What next?", "correlationId": "corr-entity-replay"}) - - assert captured_messages - assert all(content.type != "reasoning" for message in captured_messages for content in message.contents) - assert [message.text for message in captured_messages] == ["Hi", "Hello there.", "What next?"] - - -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 = _make_entity(mock_agent) - - # Add some history with proper DurableAgentStateEntry objects - entity.state.data.conversation_history = [ - DurableAgentStateRequest( - correlation_id="test-1", - created_at=datetime.now(), - messages=[ - DurableAgentStateMessage( - role="user", - contents=[DurableAgentStateTextContent(text="msg1")], - ) - ], - ), - ] - - entity.reset() - - assert entity.state.data.conversation_history == [] - - def test_reset_with_extension_data(self) -> None: - """Test that reset works when entity has extension data.""" - mock_agent = Mock() - entity = _make_entity(mock_agent) - - # Set up some initial state with conversation history - entity.state.data = DurableAgentStateData(conversation_history=[], extension_data={"some_key": "some_value"}) - - entity.reset() - - assert len(entity.state.data.conversation_history) == 0 - - def test_reset_clears_message_count(self) -> None: - """Test that reset clears the message count.""" - mock_agent = Mock() - entity = _make_entity(mock_agent) - - entity.reset() - - assert len(entity.state.data.conversation_history) == 0 - - async def test_reset_after_conversation(self) -> None: - """Test reset after a full conversation.""" - mock_agent = Mock() - mock_agent.run = _create_mock_run(response=_agent_response("Response")) - - entity = _make_entity(mock_agent) - - # Have a conversation - await entity.run({"message": "Message 1", "correlationId": "corr-entity-10a"}) - await entity.run({"message": "Message 2", "correlationId": "corr-entity-10b"}) - - # Verify state before reset - assert entity.state.message_count == 4 - assert len(entity.state.data.conversation_history) == 4 - - # Reset - entity.reset() - - # Verify state after reset - assert entity.state.message_count == 0 - assert len(entity.state.data.conversation_history) == 0 - - -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 = _create_mock_run(side_effect=Exception("Agent failed")) - - entity = _make_entity(mock_agent) - - result = await entity.run({"message": "Message", "correlationId": "corr-entity-error-1"}) - - assert isinstance(result, AgentResponse) - assert len(result.messages) == 1 - content = result.messages[0].contents[0] - assert isinstance(content, Content) - assert "Agent failed" in (content.message or "") - assert content.error_code == "Exception" - - async def test_run_agent_handles_value_error(self) -> None: - """Test that run_agent handles ValueError instances.""" - mock_agent = Mock() - mock_agent.run = _create_mock_run(side_effect=ValueError("Invalid input")) - - entity = _make_entity(mock_agent) - - result = await entity.run({"message": "Message", "correlationId": "corr-entity-error-2"}) - - assert isinstance(result, AgentResponse) - assert len(result.messages) == 1 - content = result.messages[0].contents[0] - assert isinstance(content, Content) - assert content.error_code == "ValueError" - assert "Invalid input" in str(content.message) - - async def test_run_agent_handles_timeout_error(self) -> None: - """Test that run_agent handles TimeoutError instances.""" - mock_agent = Mock() - mock_agent.run = _create_mock_run(side_effect=TimeoutError("Request timeout")) - - entity = _make_entity(mock_agent) - - result = await entity.run({"message": "Message", "correlationId": "corr-entity-error-3"}) - - assert isinstance(result, AgentResponse) - assert len(result.messages) == 1 - content = result.messages[0].contents[0] - assert isinstance(content, Content) - assert content.error_code == "TimeoutError" - - 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 = _create_mock_run(side_effect=Exception("Error")) - - entity = _make_entity(mock_agent) - - result = await entity.run( - {"message": "Test message", "correlationId": "corr-entity-error-4"}, - ) - - # Even on error, message info should be preserved - assert isinstance(result, AgentResponse) - assert len(result.messages) == 1 - content = result.messages[0].contents[0] - assert isinstance(content, Content) - - -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 = _create_mock_run(response=_agent_response("Response")) - - entity = _make_entity(mock_agent) - - await entity.run({"message": "Message", "correlationId": "corr-entity-history-1"}) - - # Check both user and assistant messages have timestamps - for entry in entity.state.data.conversation_history: - timestamp = entry.created_at - assert timestamp is not None - # Verify timestamp is in ISO format - datetime.fromisoformat(str(timestamp)) - - async def test_conversation_history_ordering(self) -> None: - """Test that conversation history maintains the correct order.""" - mock_agent = Mock() - - entity = _make_entity(mock_agent) - - # Send multiple messages with different responses - mock_agent.run = _create_mock_run(response=_agent_response("Response 1")) - await entity.run( - {"message": "Message 1", "correlationId": "corr-entity-history-2a"}, - ) - - mock_agent.run = _create_mock_run(response=_agent_response("Response 2")) - await entity.run( - {"message": "Message 2", "correlationId": "corr-entity-history-2b"}, - ) - - mock_agent.run = _create_mock_run(response=_agent_response("Response 3")) - await entity.run( - {"message": "Message 3", "correlationId": "corr-entity-history-2c"}, - ) - - # Verify order - history = entity.state.data.conversation_history - # Each conversation turn creates 2 entries: request and response - assert history[0].messages[0].text == "Message 1" # Request 1 - assert history[1].messages[0].text == "Response 1" # Response 1 - assert history[2].messages[0].text == "Message 2" # Request 2 - assert history[3].messages[0].text == "Response 2" # Response 2 - assert history[4].messages[0].text == "Message 3" # Request 3 - assert history[5].messages[0].text == "Response 3" # 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 = _create_mock_run(response=_agent_response("Response")) - - entity = _make_entity(mock_agent) - - await entity.run( - {"message": "Message 1", "correlationId": "corr-entity-history-3a"}, - ) - await entity.run( - {"message": "Message 2", "correlationId": "corr-entity-history-3b"}, - ) - - # Check role alternation - history = entity.state.data.conversation_history - # Each conversation turn creates 2 entries: request and response - assert history[0].messages[0].role == "user" # Request 1 - assert history[1].messages[0].role == "assistant" # Response 1 - assert history[2].messages[0].role == "user" # Request 2 - assert history[3].messages[0].role == "assistant" # Response 2 - - -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 = _create_mock_run(response=_agent_response("Response")) - - entity = _make_entity(mock_agent) - - request = RunRequest( - message="Test message", - role="user", - enable_tool_calls=True, - correlation_id="corr-runreq-1", - ) - - result = await entity.run(request) - - assert isinstance(result, AgentResponse) - assert result.text == "Response" - - async def test_run_agent_with_dict_request(self) -> None: - """Test run_agent with a dictionary request.""" - mock_agent = Mock() - mock_agent.run = _create_mock_run(response=_agent_response("Response")) - - entity = _make_entity(mock_agent) - - request_dict = { - "message": "Test message", - "role": "system", - "enable_tool_calls": False, - "correlationId": "corr-runreq-2", - } - - result = await entity.run(request_dict) - - assert isinstance(result, AgentResponse) - assert result.text == "Response" - - 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 = _create_mock_run(response=_agent_response("Response")) - - entity = _make_entity(mock_agent) - - with pytest.raises(ValueError): - await entity.run("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 = _create_mock_run(response=_agent_response("Response")) - - entity = _make_entity(mock_agent) - - # Send as system role - request = RunRequest( - message="System message", - role="system", - correlation_id="corr-runreq-3", - ) - - await entity.run(request) - - # Check that system role was stored - history = entity.state.data.conversation_history - assert history[0].messages[0].role == "system" - assert history[0].messages[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 = _create_mock_run(response=_agent_response('{"answer": 42}')) - - entity = _make_entity(mock_agent) - - request = RunRequest( - message="What is the answer?", - response_format=EntityStructuredResponse, - correlation_id="corr-runreq-4", - ) - - result = await entity.run(request) - - assert isinstance(result, AgentResponse) - assert result.text == '{"answer": 42}' - assert result.value is None - - async def test_run_agent_disable_tool_calls(self) -> None: - """Test run_agent with tool calls disabled.""" - mock_agent = Mock() - mock_agent.run = _create_mock_run(response=_agent_response("Response")) - - entity = _make_entity(mock_agent) - - request = RunRequest(message="Test", enable_tool_calls=False, correlation_id="corr-runreq-5") - - result = await entity.run(request) - - assert isinstance(result, AgentResponse) - # Agent should have been called (tool disabling is framework-dependent) - assert result.text == "Response" - - -if __name__ == "__main__": - pytest.main([__file__, "-v", "--tb=short"]) diff --git a/python/packages/durabletask/tests/test_durabletask_workflow_initial_input.py b/python/packages/durabletask/tests/test_durabletask_workflow_initial_input.py deleted file mode 100644 index a948d646160..00000000000 --- a/python/packages/durabletask/tests/test_durabletask_workflow_initial_input.py +++ /dev/null @@ -1,112 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Behavioral tests for Durable Task workflow initial input.""" - -from __future__ import annotations - -import json -from datetime import datetime, timezone -from typing import Any - -from agent_framework import Executor, Workflow, WorkflowBuilder, WorkflowContext, handler - -from agent_framework_durabletask import execute_workflow_activity, run_workflow_orchestrator - - -class _InlineWorkflowHost: - """Run activity tasks inline while exercising the public orchestration surface.""" - - def __init__(self, workflow: Workflow) -> None: - self.workflow = workflow - - @property - def instance_id(self) -> str: - return "test-instance" - - @property - def is_replaying(self) -> bool: - return False - - @property - def supports_event_streaming(self) -> bool: - return False - - @property - def current_utc_datetime(self) -> datetime: - return datetime.now(timezone.utc) - - def prepare_agent_task(self, executor_id: str, message: str, orchestration_instance_id: str) -> Any: - raise AssertionError("This test workflow has no agent executors") - - def prepare_activity_task(self, activity_name: str, input_json: str) -> str: - activity_input = json.loads(input_json) - executor = self.workflow.executors[activity_input["executor_id"]] - return execute_workflow_activity(executor, input_json, self.workflow) - - def call_sub_orchestrator(self, name: str, input: Any, instance_id: str | None = None) -> Any: - raise AssertionError("This test workflow has no sub-workflows") - - def task_all(self, tasks: list[Any]) -> list[Any]: - return tasks - - def task_any(self, tasks: list[Any]) -> Any: - raise AssertionError("This test workflow does not wait for competing tasks") - - def wait_for_external_event(self, name: str) -> Any: - raise AssertionError("This test workflow has no external events") - - def create_timer(self, fire_at: datetime) -> Any: - raise AssertionError("This test workflow has no timers") - - def set_custom_status(self, status: Any) -> None: - pass - - def new_uuid(self) -> str: - return "test-uuid" - - def cancel_task(self, task: Any) -> None: - pass - - def get_task_result(self, task: Any) -> Any: - return task - - -class _NullableUnionStart(Executor): - def __init__(self) -> None: - super().__init__(id="start") - - @handler(input=str | dict | None, workflow_output=str) - async def run(self, message: str | dict[str, Any] | None, ctx: WorkflowContext[Any, str]) -> None: - await ctx.yield_output("neutralized" if message is None else "accepted") - - -def _run_inline(workflow: Workflow, initial_input: Any) -> list[Any] | dict[str, Any]: - host = _InlineWorkflowHost(workflow) - orchestration = run_workflow_orchestrator(host, workflow, initial_input) - yielded = next(orchestration) - - while True: - try: - yielded = orchestration.send(yielded) - except StopIteration as completed: - return completed.value - - -def test_reserved_marker_shaped_initial_input_is_neutralized() -> None: - """Framework-reserved serialization metadata is not delivered as application input.""" - executor = _NullableUnionStart() - workflow = WorkflowBuilder(start_executor=executor, output_from=[executor]).build() - initial_input = { - "__pickled__": "not-checkpoint-data", - "__type__": "builtins:int", - } - - assert _run_inline(workflow, initial_input) == ["neutralized"] - - -def test_regular_union_initial_input_is_preserved() -> None: - """Ordinary JSON input keeps the union-typed workflow's existing behavior.""" - executor = _NullableUnionStart() - workflow = WorkflowBuilder(start_executor=executor, output_from=[executor]).build() - - assert _run_inline(workflow, {"message": "hello"}) == ["accepted"] diff --git a/python/packages/durabletask/tests/test_executors.py b/python/packages/durabletask/tests/test_executors.py deleted file mode 100644 index 8cc9900037e..00000000000 --- a/python/packages/durabletask/tests/test_executors.py +++ /dev/null @@ -1,582 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Unit tests for DurableAgentExecutor implementations. - -Focuses on critical behavioral flows for executor strategies. -Run with: pytest tests/test_executors.py -v -""" - -import time -from typing import Any -from unittest.mock import Mock - -import pytest -from agent_framework import AgentResponse -from durabletask.entities import EntityInstanceId -from durabletask.task import Task -from pydantic import BaseModel - -from agent_framework_durabletask import DurableAgentSession -from agent_framework_durabletask._constants import DEFAULT_MAX_POLL_RETRIES, DEFAULT_POLL_INTERVAL_SECONDS -from agent_framework_durabletask._executors import ( - ClientAgentExecutor, - DurableAgentTask, - OrchestrationAgentExecutor, -) -from agent_framework_durabletask._models import AgentSessionId, RunRequest - - -# Fixtures -@pytest.fixture -def mock_client() -> Mock: - """Provide a mock client for ClientAgentExecutor tests.""" - client = Mock() - client.signal_entity = Mock() - client.get_entity = Mock(return_value=None) - return client - - -@pytest.fixture -def mock_entity_task() -> Mock: - """Provide a mock entity task.""" - task = Mock(spec=Task) - task.is_complete = False - task.is_failed = False - return task - - -@pytest.fixture -def mock_orchestration_context(mock_entity_task: Mock) -> Mock: - """Provide a mock orchestration context with call_entity configured.""" - context = Mock() - context.call_entity = Mock(return_value=mock_entity_task) - context.new_uuid = Mock(return_value="test-uuid-1234") - return context - - -@pytest.fixture -def sample_run_request() -> RunRequest: - """Provide a sample RunRequest for tests.""" - return RunRequest(message="test message", correlation_id="test-123") - - -@pytest.fixture -def client_executor(mock_client: Mock) -> ClientAgentExecutor: - """Provide a ClientAgentExecutor with minimal polling for fast tests.""" - return ClientAgentExecutor(mock_client, max_poll_retries=1, poll_interval_seconds=0.01) - - -@pytest.fixture -def orchestration_executor(mock_orchestration_context: Mock) -> OrchestrationAgentExecutor: - """Provide an OrchestrationAgentExecutor.""" - return OrchestrationAgentExecutor(mock_orchestration_context) - - -@pytest.fixture -def successful_agent_response() -> dict[str, Any]: - """Provide a successful agent response dictionary.""" - return { - "messages": [{"role": "assistant", "contents": [{"type": "text", "text": "Hello!"}]}], - "created_at": "2025-12-30T10:00:00Z", - } - - -@pytest.fixture -def configure_successful_entity_task(mock_entity_task: Mock) -> Any: - """Provide a helper to configure mock_entity_task with a successful response.""" - - def _configure(response: dict[str, Any]) -> Mock: - mock_entity_task.is_failed = False - mock_entity_task.is_complete = False - mock_entity_task.get_result = Mock(return_value=response) - return mock_entity_task - - return _configure - - -@pytest.fixture -def configure_failed_entity_task(mock_entity_task: Mock) -> Any: - """Provide a helper to configure mock_entity_task with a failure.""" - - def _configure(exception: Exception) -> Mock: - mock_entity_task.is_failed = True - mock_entity_task.is_complete = True - mock_entity_task.get_exception = Mock(return_value=exception) - return mock_entity_task - - return _configure - - -class TestExecutorSessionCreation: - """Test that executors properly create DurableAgentSession with parameters.""" - - def test_client_executor_creates_durable_session(self, mock_client: Mock) -> None: - """Verify ClientAgentExecutor creates DurableAgentSession instances.""" - executor = ClientAgentExecutor(mock_client) - - session = executor.get_new_session("test_agent") - - assert isinstance(session, DurableAgentSession) - - def test_client_executor_forwards_kwargs_to_session(self, mock_client: Mock) -> None: - """Verify ClientAgentExecutor forwards kwargs to DurableAgentSession creation.""" - executor = ClientAgentExecutor(mock_client) - - session = executor.get_new_session("test_agent", service_session_id="client-123") - - assert isinstance(session, DurableAgentSession) - assert session.service_session_id == "client-123" - - def test_orchestration_executor_creates_durable_session( - self, orchestration_executor: OrchestrationAgentExecutor - ) -> None: - """Verify OrchestrationAgentExecutor creates DurableAgentSession instances.""" - session = orchestration_executor.get_new_session("test_agent") - - assert isinstance(session, DurableAgentSession) - - def test_orchestration_executor_forwards_kwargs_to_session( - self, orchestration_executor: OrchestrationAgentExecutor - ) -> None: - """Verify OrchestrationAgentExecutor forwards kwargs to DurableAgentSession creation.""" - session = orchestration_executor.get_new_session("test_agent", service_session_id="orch-456") - - assert isinstance(session, DurableAgentSession) - assert session.service_session_id == "orch-456" - - -class TestClientAgentExecutorRun: - """Test that ClientAgentExecutor.run_durable_agent works as implemented.""" - - def test_client_executor_run_returns_response( - self, client_executor: ClientAgentExecutor, sample_run_request: RunRequest - ) -> None: - """Verify ClientAgentExecutor.run_durable_agent returns AgentResponse (synchronous).""" - result = client_executor.run_durable_agent("test_agent", sample_run_request) - - # Verify it returns an AgentResponse (synchronous, not a coroutine) - assert isinstance(result, AgentResponse) - assert result is not None - - -class TestClientAgentExecutorPollingConfiguration: - """Test polling configuration parameters for ClientAgentExecutor.""" - - def test_executor_uses_default_polling_parameters(self, mock_client: Mock) -> None: - """Verify executor initializes with default polling parameters.""" - executor = ClientAgentExecutor(mock_client) - - assert executor.max_poll_retries == DEFAULT_MAX_POLL_RETRIES - assert executor.poll_interval_seconds == DEFAULT_POLL_INTERVAL_SECONDS - - def test_executor_accepts_custom_polling_parameters(self, mock_client: Mock) -> None: - """Verify executor accepts and stores custom polling parameters.""" - executor = ClientAgentExecutor(mock_client, max_poll_retries=20, poll_interval_seconds=0.5) - - assert executor.max_poll_retries == 20 - assert executor.poll_interval_seconds == 0.5 - - def test_executor_respects_custom_max_poll_retries(self, mock_client: Mock, sample_run_request: RunRequest) -> None: - """Verify executor respects custom max_poll_retries during polling.""" - # Create executor with only 2 retries - executor = ClientAgentExecutor(mock_client, max_poll_retries=2, poll_interval_seconds=0.01) - - # Run the agent - result = executor.run_durable_agent("test_agent", sample_run_request) - - # Verify it returns AgentResponse (should timeout after 2 attempts) - assert isinstance(result, AgentResponse) - - # Verify get_entity was called 2 times (max_poll_retries) - assert mock_client.get_entity.call_count == 2 - - def test_executor_respects_custom_poll_interval( - self, - mock_client: Mock, - sample_run_request: RunRequest, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - """Verify executor respects custom poll_interval_seconds during polling.""" - # Create executor with very short interval - executor = ClientAgentExecutor(mock_client, max_poll_retries=3, poll_interval_seconds=0.01) - - sleep_calls: list[float] = [] - - def fake_sleep(seconds: float) -> None: - sleep_calls.append(seconds) - - # Use deterministic assertions instead of wall-clock timing to avoid CI flakiness. - monkeypatch.setattr("agent_framework_durabletask._executors.time.sleep", fake_sleep) - - result = executor.run_durable_agent("test_agent", sample_run_request) - - assert len(sleep_calls) == 3 - assert sleep_calls == pytest.approx([0.01, 0.01, 0.01]) - assert mock_client.get_entity.call_count == 3 - assert isinstance(result, AgentResponse) - - -class TestClientAgentExecutorFireAndForget: - """Test fire-and-forget mode (wait_for_response=False) for ClientAgentExecutor.""" - - def test_fire_and_forget_returns_immediately(self, mock_client: Mock) -> None: - """Verify wait_for_response=False returns immediately without polling.""" - executor = ClientAgentExecutor(mock_client, max_poll_retries=10, poll_interval_seconds=0.1) - - # Create a request with wait_for_response=False - request = RunRequest(message="test message", correlation_id="test-123", wait_for_response=False) - - # Measure time taken - start = time.time() - result = executor.run_durable_agent("test_agent", request) - elapsed = time.time() - start - - # Should return immediately without polling (elapsed time should be very small) - assert elapsed < 0.1 # Much faster than any polling would take - - # Should return an AgentResponse - assert isinstance(result, AgentResponse) - - # Should have signaled the entity but not polled - assert mock_client.signal_entity.call_count == 1 - assert mock_client.get_entity.call_count == 0 # No polling occurred - - def test_fire_and_forget_returns_empty_response(self, mock_client: Mock) -> None: - """Verify wait_for_response=False returns an acceptance message with correlation ID.""" - executor = ClientAgentExecutor(mock_client) - - request = RunRequest(message="test message", correlation_id="test-456", wait_for_response=False) - - result = executor.run_durable_agent("test_agent", request) - - # Verify it contains an acceptance message - assert isinstance(result, AgentResponse) - assert len(result.messages) == 1 - assert result.messages[0].role == "system" - # Check message contains key information - message_text = result.messages[0].text - assert "accepted" in message_text.lower() - assert "test-456" in message_text # Contains correlation ID - assert "background" in message_text.lower() - - -class TestOrchestrationAgentExecutorFireAndForget: - """Test fire-and-forget mode for OrchestrationAgentExecutor.""" - - def test_orchestration_fire_and_forget_calls_signal_entity(self, mock_orchestration_context: Mock) -> None: - """Verify wait_for_response=False calls signal_entity instead of call_entity.""" - executor = OrchestrationAgentExecutor(mock_orchestration_context) - mock_orchestration_context.signal_entity = Mock() - - request = RunRequest(message="test", correlation_id="test-123", wait_for_response=False) - - result = executor.run_durable_agent("test_agent", request) - - # Verify signal_entity was called and call_entity was not - assert mock_orchestration_context.signal_entity.call_count == 1 - assert mock_orchestration_context.call_entity.call_count == 0 - - # Should still return a DurableAgentTask - assert isinstance(result, DurableAgentTask) - - def test_orchestration_fire_and_forget_returns_completed_task(self, mock_orchestration_context: Mock) -> None: - """Verify wait_for_response=False returns pre-completed DurableAgentTask.""" - executor = OrchestrationAgentExecutor(mock_orchestration_context) - mock_orchestration_context.signal_entity = Mock() - - request = RunRequest(message="test", correlation_id="test-456", wait_for_response=False) - - result = executor.run_durable_agent("test_agent", request) - - # Task should be immediately complete - assert isinstance(result, DurableAgentTask) - assert result.is_complete - - def test_orchestration_fire_and_forget_returns_acceptance_response(self, mock_orchestration_context: Mock) -> None: - """Verify wait_for_response=False returns acceptance response.""" - executor = OrchestrationAgentExecutor(mock_orchestration_context) - mock_orchestration_context.signal_entity = Mock() - - request = RunRequest(message="test", correlation_id="test-789", wait_for_response=False) - - result = executor.run_durable_agent("test_agent", request) - - # Get the result - response = result.get_result() - assert isinstance(response, AgentResponse) - assert len(response.messages) == 1 - assert response.messages[0].role == "system" - assert "test-789" in response.messages[0].text - - def test_orchestration_blocking_mode_calls_call_entity(self, mock_orchestration_context: Mock) -> None: - """Verify wait_for_response=True uses call_entity as before.""" - executor = OrchestrationAgentExecutor(mock_orchestration_context) - mock_orchestration_context.signal_entity = Mock() - - request = RunRequest(message="test", correlation_id="test-abc", wait_for_response=True) - - result = executor.run_durable_agent("test_agent", request) - - # Verify call_entity was called and signal_entity was not - assert mock_orchestration_context.call_entity.call_count == 1 - assert mock_orchestration_context.signal_entity.call_count == 0 - - # Should return a DurableAgentTask - assert isinstance(result, DurableAgentTask) - - -class TestOrchestrationAgentExecutorRun: - """Test OrchestrationAgentExecutor.run_durable_agent implementation.""" - - def test_orchestration_executor_run_returns_durable_agent_task( - self, orchestration_executor: OrchestrationAgentExecutor, sample_run_request: RunRequest - ) -> None: - """Verify OrchestrationAgentExecutor.run_durable_agent returns DurableAgentTask.""" - result = orchestration_executor.run_durable_agent("test_agent", sample_run_request) - - assert isinstance(result, DurableAgentTask) - - def test_orchestration_executor_calls_entity_with_correct_parameters( - self, - mock_orchestration_context: Mock, - orchestration_executor: OrchestrationAgentExecutor, - sample_run_request: RunRequest, - ) -> None: - """Verify call_entity is invoked with correct entity ID and request.""" - orchestration_executor.run_durable_agent("test_agent", sample_run_request) - - # Verify call_entity was called once - assert mock_orchestration_context.call_entity.call_count == 1 - - # Get the call arguments - call_args = mock_orchestration_context.call_entity.call_args - entity_id_arg = call_args[0][0] - operation_arg = call_args[0][1] - request_dict_arg = call_args[0][2] - - # Verify entity ID - assert isinstance(entity_id_arg, EntityInstanceId) - assert entity_id_arg.entity == "dafx-test_agent" - - # Verify operation name - assert operation_arg == "run" - - # Verify request dict - assert request_dict_arg == sample_run_request.to_dict() - - def test_orchestration_executor_uses_session_durable_id( - self, - mock_orchestration_context: Mock, - orchestration_executor: OrchestrationAgentExecutor, - sample_run_request: RunRequest, - ) -> None: - """Verify executor uses session's durable session ID when provided.""" - # Create session with specific durable session ID - session_id = AgentSessionId(name="test_agent", key="specific-key-123") - session = DurableAgentSession.from_session_id(session_id) - - result = orchestration_executor.run_durable_agent("test_agent", sample_run_request, session=session) - - # Verify call_entity was called with the specific key - call_args = mock_orchestration_context.call_entity.call_args - entity_id_arg = call_args[0][0] - - assert entity_id_arg.key == "specific-key-123" - assert isinstance(result, DurableAgentTask) - - -class TestDurableAgentTask: - """Test DurableAgentTask completion and response transformation.""" - - def test_durable_agent_task_transforms_successful_result( - self, configure_successful_entity_task: Any, successful_agent_response: dict[str, Any] - ) -> None: - """Verify DurableAgentTask converts successful entity result to AgentResponse.""" - mock_entity_task = configure_successful_entity_task(successful_agent_response) - - task = DurableAgentTask(entity_task=mock_entity_task, response_format=None, correlation_id="test-123") - - # Simulate child task completion - task.on_child_completed(mock_entity_task) - - assert task.is_complete - result = task.get_result() - assert isinstance(result, AgentResponse) - assert len(result.messages) == 1 - assert result.messages[0].role == "assistant" - - def test_durable_agent_task_propagates_failure(self, configure_failed_entity_task: Any) -> None: - """Verify DurableAgentTask propagates task failures.""" - mock_entity_task = configure_failed_entity_task(ValueError("Entity error")) - - task = DurableAgentTask(entity_task=mock_entity_task, response_format=None, correlation_id="test-123") - - # Simulate child task completion with failure - task.on_child_completed(mock_entity_task) - - assert task.is_complete - assert task.is_failed - # The exception is wrapped in TaskFailedError by the durabletask library - exception = task.get_exception() - assert exception is not None - - def test_durable_agent_task_validates_response_format(self, configure_successful_entity_task: Any) -> None: - """Verify DurableAgentTask validates response format when provided.""" - response = { - "messages": [{"role": "assistant", "contents": [{"type": "text", "text": '{"answer": "42"}'}]}], - "created_at": "2025-12-30T10:00:00Z", - } - mock_entity_task = configure_successful_entity_task(response) - - class TestResponse(BaseModel): - answer: str - - task = DurableAgentTask(entity_task=mock_entity_task, response_format=TestResponse, correlation_id="test-123") - - # Simulate child task completion - task.on_child_completed(mock_entity_task) - - assert task.is_complete - result = task.get_result() - assert isinstance(result, AgentResponse) - - def test_durable_agent_task_ignores_duplicate_completion( - self, configure_successful_entity_task: Any, successful_agent_response: dict[str, Any] - ) -> None: - """Verify DurableAgentTask ignores duplicate completion calls.""" - mock_entity_task = configure_successful_entity_task(successful_agent_response) - - task = DurableAgentTask(entity_task=mock_entity_task, response_format=None, correlation_id="test-123") - - # Simulate child task completion twice - task.on_child_completed(mock_entity_task) - first_result = task.get_result() - - task.on_child_completed(mock_entity_task) - second_result = task.get_result() - - # Should be the same result, get_result should only be called once - assert first_result is second_result - assert mock_entity_task.get_result.call_count == 1 - - def test_durable_agent_task_fails_on_malformed_response(self, configure_successful_entity_task: Any) -> None: - """Verify DurableAgentTask fails when entity returns malformed response data.""" - # Use data that will cause AgentResponse.from_dict to fail - # Using a list instead of dict, or other invalid structure - mock_entity_task = configure_successful_entity_task("invalid string response") - - task = DurableAgentTask(entity_task=mock_entity_task, response_format=None, correlation_id="test-123") - - # Simulate child task completion with malformed data - task.on_child_completed(mock_entity_task) - - assert task.is_complete - assert task.is_failed - - def test_durable_agent_task_fails_on_invalid_response_format(self, configure_successful_entity_task: Any) -> None: - """Verify DurableAgentTask fails when response doesn't match required format.""" - response = { - "messages": [{"role": "assistant", "contents": [{"type": "text", "text": '{"wrong": "field"}'}]}], - "created_at": "2025-12-30T10:00:00Z", - } - mock_entity_task = configure_successful_entity_task(response) - - class StrictResponse(BaseModel): - required_field: str - - task = DurableAgentTask(entity_task=mock_entity_task, response_format=StrictResponse, correlation_id="test-123") - - # Simulate child task completion with wrong format - task.on_child_completed(mock_entity_task) - - assert task.is_complete - assert task.is_failed - - def test_durable_agent_task_handles_empty_response(self, configure_successful_entity_task: Any) -> None: - """Verify DurableAgentTask handles response with empty messages list.""" - response: dict[str, str | list[Any]] = { - "messages": [], - "created_at": "2025-12-30T10:00:00Z", - } - mock_entity_task = configure_successful_entity_task(response) - - task = DurableAgentTask(entity_task=mock_entity_task, response_format=None, correlation_id="test-123") - - # Simulate child task completion - task.on_child_completed(mock_entity_task) - - assert task.is_complete - result = task.get_result() - assert isinstance(result, AgentResponse) - assert len(result.messages) == 0 - - def test_durable_agent_task_handles_multiple_messages(self, configure_successful_entity_task: Any) -> None: - """Verify DurableAgentTask correctly processes response with multiple messages.""" - response = { - "messages": [ - {"role": "assistant", "contents": [{"type": "text", "text": "First message"}]}, - {"role": "assistant", "contents": [{"type": "text", "text": "Second message"}]}, - ], - "created_at": "2025-12-30T10:00:00Z", - } - mock_entity_task = configure_successful_entity_task(response) - - task = DurableAgentTask(entity_task=mock_entity_task, response_format=None, correlation_id="test-123") - - # Simulate child task completion - task.on_child_completed(mock_entity_task) - - assert task.is_complete - result = task.get_result() - assert isinstance(result, AgentResponse) - assert len(result.messages) == 2 - assert result.messages[0].role == "assistant" - assert result.messages[1].role == "assistant" - - def test_durable_agent_task_is_not_complete_initially(self, mock_entity_task: Mock) -> None: - """Verify DurableAgentTask is not complete when first created.""" - task = DurableAgentTask(entity_task=mock_entity_task, response_format=None, correlation_id="test-123") - - assert not task.is_complete - assert not task.is_failed - - def test_durable_agent_task_completes_with_complex_response_format( - self, configure_successful_entity_task: Any - ) -> None: - """Verify DurableAgentTask validates complex nested response formats correctly.""" - response = { - "messages": [ - { - "role": "assistant", - "contents": [ - { - "type": "text", - "text": '{"name": "test", "count": 42, "items": ["a", "b", "c"]}', - } - ], - } - ], - "created_at": "2025-12-30T10:00:00Z", - } - mock_entity_task = configure_successful_entity_task(response) - - class ComplexResponse(BaseModel): - name: str - count: int - items: list[str] - - task = DurableAgentTask( - entity_task=mock_entity_task, response_format=ComplexResponse, correlation_id="test-123" - ) - - # Simulate child task completion - task.on_child_completed(mock_entity_task) - - assert task.is_complete - assert not task.is_failed - result = task.get_result() - assert isinstance(result, AgentResponse) - - -if __name__ == "__main__": - pytest.main([__file__, "-v", "--tb=short"]) diff --git a/python/packages/durabletask/tests/test_models.py b/python/packages/durabletask/tests/test_models.py deleted file mode 100644 index 40097fd43df..00000000000 --- a/python/packages/durabletask/tests/test_models.py +++ /dev/null @@ -1,309 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Unit tests for data models (RunRequest).""" - -import pytest -from pydantic import BaseModel - -from agent_framework_durabletask._models import RunRequest - - -class ModuleStructuredResponse(BaseModel): - value: int - - -class TestRunRequest: - """Test suite for RunRequest.""" - - def test_init_with_defaults(self) -> None: - """Test RunRequest initialization with defaults.""" - request = RunRequest(message="Hello", correlation_id="corr-001") - - assert request.message == "Hello" - assert request.correlation_id == "corr-001" - assert request.role == "user" - assert request.response_format is None - assert request.enable_tool_calls is True - assert request.wait_for_response is True - - def test_init_with_all_fields(self) -> None: - """Test RunRequest initialization with all fields.""" - schema = ModuleStructuredResponse - request = RunRequest( - message="Hello", - correlation_id="corr-002", - role="system", - response_format=schema, - enable_tool_calls=False, - wait_for_response=False, - ) - - assert request.message == "Hello" - assert request.correlation_id == "corr-002" - assert request.role == "system" - assert request.response_format is schema - assert request.enable_tool_calls is False - assert request.wait_for_response is False - - def test_init_coerces_string_role(self) -> None: - """Ensure string role values are coerced into Role instances.""" - request = RunRequest(message="Hello", correlation_id="corr-003", role="system") # type: ignore[arg-type] - - assert request.role == "system" - - def test_to_dict_with_defaults(self) -> None: - """Test to_dict with default values.""" - request = RunRequest(message="Test message", correlation_id="corr-004") - data = request.to_dict() - - assert data["message"] == "Test message" - assert data["enable_tool_calls"] is True - assert data["wait_for_response"] is True - assert data["role"] == "user" - assert data["correlationId"] == "corr-004" - assert "response_format" not in data or data["response_format"] is None - assert "thread_id" not in data - - def test_to_dict_with_all_fields(self) -> None: - """Test to_dict with all fields.""" - schema = ModuleStructuredResponse - request = RunRequest( - message="Hello", - correlation_id="corr-005", - role="assistant", - response_format=schema, - enable_tool_calls=False, - wait_for_response=False, - ) - data = request.to_dict() - - assert data["message"] == "Hello" - assert data["correlationId"] == "corr-005" - 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["wait_for_response"] is False - assert "thread_id" not in data - - def test_from_dict_with_defaults(self) -> None: - """Test from_dict with minimal data.""" - data = {"message": "Hello", "correlationId": "corr-006"} - request = RunRequest.from_dict(data) - - assert request.message == "Hello" - assert request.correlation_id == "corr-006" - assert request.role == "user" - assert request.enable_tool_calls is True - assert request.wait_for_response is True - - def test_from_dict_ignores_thread_id_field(self) -> None: - """Ensure legacy thread_id input does not break RunRequest parsing.""" - request = RunRequest.from_dict({"message": "Hello", "correlationId": "corr-007", "thread_id": "ignored"}) - - assert request.message == "Hello" - - def test_from_dict_with_all_fields(self) -> None: - """Test from_dict with all fields.""" - data = { - "message": "Test", - "correlationId": "corr-008", - "role": "system", - "response_format": { - "__response_schema_type__": "pydantic_model", - "module": ModuleStructuredResponse.__module__, - "qualname": ModuleStructuredResponse.__qualname__, - }, - "enable_tool_calls": False, - } - request = RunRequest.from_dict(data) - - assert request.message == "Test" - assert request.correlation_id == "corr-008" - assert request.role == "system" - assert request.response_format is ModuleStructuredResponse - assert request.enable_tool_calls is False - - def test_from_dict_unknown_role_preserves_value(self) -> None: - """Test from_dict keeps custom roles intact.""" - data = {"message": "Test", "correlationId": "corr-009", "role": "reviewer"} - request = RunRequest.from_dict(data) - - assert request.role == "reviewer" - assert request.role != "user" - - def test_from_dict_empty_message(self) -> None: - """Test from_dict with empty message.""" - request = RunRequest.from_dict({"correlationId": "corr-010"}) - - assert request.message == "" - assert request.correlation_id == "corr-010" - assert request.role == "user" - - def test_from_dict_missing_correlation_id_raises(self) -> None: - """Test from_dict raises when correlationId is missing.""" - with pytest.raises(ValueError, match="correlationId is required"): - RunRequest.from_dict({"message": "Test"}) - - def test_round_trip_dict_conversion(self) -> None: - """Test round-trip to_dict and from_dict.""" - original = RunRequest( - message="Test message", - correlation_id="corr-011", - 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.correlation_id == original.correlation_id - assert restored.role == original.role - assert restored.response_format is ModuleStructuredResponse - assert restored.enable_tool_calls == original.enable_tool_calls - - def test_round_trip_with_pydantic_response_format(self) -> None: - """Ensure Pydantic response formats serialize and deserialize properly.""" - original = RunRequest( - message="Structured", - correlation_id="corr-012", - 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_round_trip_with_options(self) -> None: - """Ensure options are preserved and response_format is deserialized.""" - original = RunRequest( - message="Test", - correlation_id="corr-opts-1", - response_format=ModuleStructuredResponse, - enable_tool_calls=False, - options={ - "response_format": ModuleStructuredResponse, - "enable_tool_calls": False, - "custom": "value", - }, - ) - - data = original.to_dict() - assert data["options"]["custom"] == "value" - - restored = RunRequest.from_dict(data) - assert restored.options is not None - assert restored.options["custom"] == "value" - assert restored.options["response_format"] is ModuleStructuredResponse - - def test_init_with_correlationId(self) -> None: - """Test RunRequest initialization with correlationId.""" - request = RunRequest(message="Test message", correlation_id="corr-123") - - assert request.message == "Test message" - assert request.correlation_id == "corr-123" - - def test_to_dict_with_correlationId(self) -> None: - """Test to_dict includes correlationId.""" - request = RunRequest(message="Test", correlation_id="corr-456") - data = request.to_dict() - - assert data["message"] == "Test" - assert data["correlationId"] == "corr-456" - - def test_from_dict_with_correlationId(self) -> None: - """Test from_dict with correlationId.""" - data = {"message": "Test", "correlationId": "corr-789"} - request = RunRequest.from_dict(data) - - assert request.message == "Test" - assert request.correlation_id == "corr-789" - - def test_round_trip_with_correlationId(self) -> None: - """Test round-trip to_dict and from_dict with correlationId.""" - original = RunRequest( - message="Test message", - role="system", - correlation_id="corr-124", - ) - - 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 - - def test_init_with_orchestration_id(self) -> None: - """Test RunRequest initialization with orchestration_id.""" - request = RunRequest( - message="Test message", - correlation_id="corr-125", - orchestration_id="orch-123", - ) - - assert request.message == "Test message" - assert request.orchestration_id == "orch-123" - - def test_to_dict_with_orchestration_id(self) -> None: - """Test to_dict includes orchestrationId.""" - request = RunRequest( - message="Test", - correlation_id="corr-126", - orchestration_id="orch-456", - ) - data = request.to_dict() - - assert data["message"] == "Test" - assert data["orchestrationId"] == "orch-456" - - def test_to_dict_excludes_orchestration_id_when_none(self) -> None: - """Test to_dict excludes orchestrationId when not set.""" - request = RunRequest( - message="Test", - correlation_id="corr-127", - ) - data = request.to_dict() - - assert "orchestrationId" not in data - - def test_from_dict_with_orchestration_id(self) -> None: - """Test from_dict with orchestrationId.""" - data = { - "message": "Test", - "correlationId": "corr-128", - "orchestrationId": "orch-789", - } - request = RunRequest.from_dict(data) - - assert request.message == "Test" - assert request.orchestration_id == "orch-789" - - def test_round_trip_with_orchestration_id(self) -> None: - """Test round-trip to_dict and from_dict with orchestration_id.""" - original = RunRequest( - message="Test message", - role="system", - correlation_id="corr-129", - orchestration_id="orch-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.orchestration_id == original.orchestration_id - - -if __name__ == "__main__": - pytest.main([__file__, "-v", "--tb=short"]) diff --git a/python/packages/durabletask/tests/test_orchestration_context.py b/python/packages/durabletask/tests/test_orchestration_context.py deleted file mode 100644 index 97d5bd1386b..00000000000 --- a/python/packages/durabletask/tests/test_orchestration_context.py +++ /dev/null @@ -1,87 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Unit tests for DurableAIAgentOrchestrationContext. - -Focuses on critical orchestration workflows: agent retrieval and integration. -Run with: pytest tests/test_orchestration_context.py -v -""" - -from unittest.mock import Mock - -import pytest -from agent_framework import SupportsAgentRun - -from agent_framework_durabletask import DurableAgentSession -from agent_framework_durabletask._orchestration_context import DurableAIAgentOrchestrationContext -from agent_framework_durabletask._shim import DurableAIAgent - - -@pytest.fixture -def mock_orchestration_context() -> Mock: - """Create a mock OrchestrationContext for testing.""" - return Mock() - - -@pytest.fixture -def agent_context(mock_orchestration_context: Mock) -> DurableAIAgentOrchestrationContext: - """Create a DurableAIAgentOrchestrationContext with mock context.""" - return DurableAIAgentOrchestrationContext(mock_orchestration_context) - - -class TestDurableAIAgentOrchestrationContextGetAgent: - """Test core workflow: retrieving agents from orchestration context.""" - - def test_get_agent_returns_durable_agent_shim(self, agent_context: DurableAIAgentOrchestrationContext) -> None: - """Verify get_agent returns a DurableAIAgent instance.""" - agent = agent_context.get_agent("assistant") - - assert isinstance(agent, DurableAIAgent) - assert isinstance(agent, SupportsAgentRun) # pyrefly: ignore[unsafe-overlap] - - def test_get_agent_shim_has_correct_name(self, agent_context: DurableAIAgentOrchestrationContext) -> None: - """Verify retrieved agent has the correct name.""" - agent = agent_context.get_agent("my_agent") - - assert agent.name == "my_agent" - - def test_get_agent_multiple_times_returns_new_instances( - self, agent_context: DurableAIAgentOrchestrationContext - ) -> None: - """Verify multiple get_agent calls return independent instances.""" - agent1 = agent_context.get_agent("assistant") - agent2 = agent_context.get_agent("assistant") - - assert agent1 is not agent2 # Different object instances - - def test_get_agent_different_agents(self, agent_context: DurableAIAgentOrchestrationContext) -> None: - """Verify context can retrieve multiple different agents.""" - agent1 = agent_context.get_agent("agent1") - agent2 = agent_context.get_agent("agent2") - - assert agent1.name == "agent1" - assert agent2.name == "agent2" - - -class TestDurableAIAgentOrchestrationContextIntegration: - """Test integration scenarios between orchestration context and agent shim.""" - - def test_orchestration_agent_has_working_run_method( - self, agent_context: DurableAIAgentOrchestrationContext - ) -> None: - """Verify agent from context has callable run method (even if not yet implemented).""" - agent = agent_context.get_agent("assistant") - - assert hasattr(agent, "run") - assert callable(agent.run) - - def test_orchestration_agent_can_create_sessions(self, agent_context: DurableAIAgentOrchestrationContext) -> None: - """Verify agent from context can create DurableAgentSession instances.""" - agent = agent_context.get_agent("assistant") - - session = agent.create_session() - - assert isinstance(session, DurableAgentSession) - - -if __name__ == "__main__": - pytest.main([__file__, "-v", "--tb=short"]) diff --git a/python/packages/durabletask/tests/test_shim.py b/python/packages/durabletask/tests/test_shim.py deleted file mode 100644 index de343f30aab..00000000000 --- a/python/packages/durabletask/tests/test_shim.py +++ /dev/null @@ -1,228 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Unit tests for DurableAIAgent shim and DurableAgentProvider. - -Focuses on critical message normalization, delegation, and protocol compliance. -Run with: pytest tests/test_shim.py -v -""" - -from typing import Any, cast -from unittest.mock import Mock - -import pytest -from agent_framework import Message, SupportsAgentRun -from pydantic import BaseModel - -from agent_framework_durabletask import DurableAgentSession -from agent_framework_durabletask._executors import DurableAgentExecutor -from agent_framework_durabletask._models import RunRequest -from agent_framework_durabletask._shim import DurableAgentProvider, DurableAIAgent - - -class ResponseFormatModel(BaseModel): - """Test Pydantic model for response format testing.""" - - result: str - - -@pytest.fixture -def mock_executor() -> Mock: - """Create a mock executor for testing.""" - mock = Mock(spec=DurableAgentExecutor) - mock.run_durable_agent = Mock(return_value=None) - mock.get_new_session = Mock(return_value=DurableAgentSession()) - - # Mock get_run_request to create actual RunRequest objects - def create_run_request( - message: str, - options: dict[str, Any] | None = None, - ) -> RunRequest: - import uuid - - opts = dict(options) if options else {} - response_format = opts.pop("response_format", None) - enable_tool_calls = cast("bool", opts.pop("enable_tool_calls", True)) - wait_for_response = cast("bool", opts.pop("wait_for_response", True)) - return RunRequest( - message=message, - correlation_id=str(uuid.uuid4()), - response_format=response_format, - enable_tool_calls=enable_tool_calls, - wait_for_response=wait_for_response, - options=opts, - ) - - mock.get_run_request = Mock(side_effect=create_run_request) - return mock - - -@pytest.fixture -def test_agent(mock_executor: Mock) -> DurableAIAgent[Any]: - """Create a test agent with mock executor.""" - return DurableAIAgent(mock_executor, "test_agent") - - -class TestDurableAIAgentMessageNormalization: - """Test that DurableAIAgent properly normalizes various message input types.""" - - def test_run_accepts_string_message(self, test_agent: DurableAIAgent[Any], mock_executor: Mock) -> None: - """Verify run accepts and normalizes string messages.""" - test_agent.run("Hello, world!") - - mock_executor.run_durable_agent.assert_called_once() - # Verify agent_name and run_request were passed correctly as kwargs - _, kwargs = mock_executor.run_durable_agent.call_args - assert kwargs["agent_name"] == "test_agent" - assert kwargs["run_request"].message == "Hello, world!" - - def test_run_accepts_chat_message(self, test_agent: DurableAIAgent[Any], mock_executor: Mock) -> None: - """Verify run accepts and normalizes Message objects.""" - chat_msg = Message(role="user", contents=["Test message"]) - test_agent.run(chat_msg) - - mock_executor.run_durable_agent.assert_called_once() - _, kwargs = mock_executor.run_durable_agent.call_args - assert kwargs["run_request"].message == "Test message" - - def test_run_accepts_list_of_strings(self, test_agent: DurableAIAgent[Any], mock_executor: Mock) -> None: - """Verify run accepts and joins list of strings.""" - test_agent.run(["First message", "Second message"]) - - mock_executor.run_durable_agent.assert_called_once() - _, kwargs = mock_executor.run_durable_agent.call_args - assert kwargs["run_request"].message == "First message\nSecond message" - - def test_run_accepts_list_of_chat_messages(self, test_agent: DurableAIAgent[Any], mock_executor: Mock) -> None: - """Verify run accepts and joins list of Message objects.""" - messages = [ - Message(role="user", contents=["Message 1"]), - Message(role="assistant", contents=["Message 2"]), - ] - test_agent.run(messages) - - mock_executor.run_durable_agent.assert_called_once() - _, kwargs = mock_executor.run_durable_agent.call_args - assert kwargs["run_request"].message == "Message 1\nMessage 2" - - def test_run_handles_none_message(self, test_agent: DurableAIAgent[Any], mock_executor: Mock) -> None: - """Verify run handles None message gracefully.""" - test_agent.run(None) - - mock_executor.run_durable_agent.assert_called_once() - _, kwargs = mock_executor.run_durable_agent.call_args - assert kwargs["run_request"].message == "" - - def test_run_handles_empty_list(self, test_agent: DurableAIAgent[Any], mock_executor: Mock) -> None: - """Verify run handles empty list gracefully.""" - test_agent.run([]) - - mock_executor.run_durable_agent.assert_called_once() - _, kwargs = mock_executor.run_durable_agent.call_args - assert kwargs["run_request"].message == "" - - -class TestDurableAIAgentParameterFlow: - """Test that parameters flow correctly through the shim to executor.""" - - def test_run_forwards_session_parameter(self, test_agent: DurableAIAgent[Any], mock_executor: Mock) -> None: - """Verify run forwards session parameter to executor.""" - session = DurableAgentSession(service_session_id="test-session") - test_agent.run("message", session=session) - - mock_executor.run_durable_agent.assert_called_once() - _, kwargs = mock_executor.run_durable_agent.call_args - assert kwargs["session"] == session - - def test_run_forwards_response_format(self, test_agent: DurableAIAgent[Any], mock_executor: Mock) -> None: - """Verify run forwards response_format parameter to executor.""" - test_agent.run("message", options={"response_format": ResponseFormatModel}) - - mock_executor.run_durable_agent.assert_called_once() - _, kwargs = mock_executor.run_durable_agent.call_args - assert kwargs["run_request"].response_format == ResponseFormatModel - - -class TestDurableAISupportsAgentRunCompliance: - """Test that DurableAIAgent implements SupportsAgentRun correctly.""" - - def test_agent_implements_protocol(self, test_agent: DurableAIAgent[Any]) -> None: - """Verify DurableAIAgent implements SupportsAgentRun.""" - assert isinstance(test_agent, SupportsAgentRun) # pyrefly: ignore[unsafe-overlap] - - def test_agent_has_required_properties(self, test_agent: DurableAIAgent[Any]) -> None: - """Verify DurableAIAgent has all required SupportsAgentRun properties.""" - assert hasattr(test_agent, "id") - assert hasattr(test_agent, "name") - assert hasattr(test_agent, "display_name") - assert hasattr(test_agent, "description") - - def test_agent_id_defaults_to_name(self, mock_executor: Mock) -> None: - """Verify agent id defaults to name when not provided.""" - agent: DurableAIAgent[Any] = DurableAIAgent(mock_executor, "my_agent") - - assert agent.id == "my_agent" - assert agent.name == "my_agent" - - def test_agent_id_can_be_customized(self, mock_executor: Mock) -> None: - """Verify agent id can be set independently from name.""" - agent: DurableAIAgent[Any] = DurableAIAgent(mock_executor, "my_agent", agent_id="custom-id") - - assert agent.id == "custom-id" - assert agent.name == "my_agent" - - -class TestDurableAIAgentSessionManagement: - """Test session creation and management.""" - - def test_create_session_delegates_to_executor(self, test_agent: DurableAIAgent[Any], mock_executor: Mock) -> None: - """Verify create_session delegates to executor.""" - mock_session = DurableAgentSession() - mock_executor.get_new_session.return_value = mock_session - - session = test_agent.create_session() - - mock_executor.get_new_session.assert_called_once_with("test_agent") - assert session == mock_session - - def test_get_session_forwards_service_session_id( - self, test_agent: DurableAIAgent[Any], mock_executor: Mock - ) -> None: - """Verify get_session forwards service_session_id and session_id to executor.""" - mock_session = DurableAgentSession(service_session_id="svc-123") - mock_executor.get_new_session.return_value = mock_session - - session = test_agent.get_session("svc-123", session_id="local-456") - - mock_executor.get_new_session.assert_called_once_with( - "test_agent", service_session_id="svc-123", session_id="local-456" - ) - assert session.service_session_id == "svc-123" - - def test_get_session_without_session_id(self, test_agent: DurableAIAgent[Any], mock_executor: Mock) -> None: - """Verify get_session works with only service_session_id (session_id defaults to None).""" - mock_session = DurableAgentSession(service_session_id="svc-789") - mock_executor.get_new_session.return_value = mock_session - - session = test_agent.get_session("svc-789") - - mock_executor.get_new_session.assert_called_once_with( - "test_agent", service_session_id="svc-789", session_id=None - ) - assert session.service_session_id == "svc-789" - - -class TestDurableAgentProviderInterface: - """Test that DurableAgentProvider defines the correct interface.""" - - def test_provider_cannot_be_instantiated(self) -> None: - """Verify DurableAgentProvider is abstract and cannot be instantiated.""" - with pytest.raises(TypeError): - DurableAgentProvider() # type: ignore[abstract] - - def test_provider_defines_get_agent_method(self) -> None: - """Verify DurableAgentProvider defines get_agent abstract method.""" - assert hasattr(DurableAgentProvider, "get_agent") - - -if __name__ == "__main__": - pytest.main([__file__, "-v", "--tb=short"]) diff --git a/python/packages/durabletask/tests/test_subworkflow_orchestration.py b/python/packages/durabletask/tests/test_subworkflow_orchestration.py deleted file mode 100644 index 37433965628..00000000000 --- a/python/packages/durabletask/tests/test_subworkflow_orchestration.py +++ /dev/null @@ -1,390 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Unit tests for sub-workflow (child-orchestration) dispatch and result handling. - -A ``WorkflowExecutor`` node runs its inner workflow as a durable child -orchestration. These tests cover the host-side glue: - -* :func:`_prepare_subworkflow_task` wraps the node's message in a trusted-input - marker and schedules ``dafx-{innerName}``. -* :func:`_process_subworkflow_result` turns the child's outputs into either - routed messages (default) or parent outputs (``allow_direct_output``). -* :func:`_try_unwrap_subworkflow_input` / :func:`_coerce_initial_input` reconstruct - the original typed object on the child side. -""" - -from typing import Any, cast -from unittest.mock import Mock - -from agent_framework import WorkflowExecutor - -from agent_framework_durabletask._workflows.naming import qualify_subworkflow_request_id -from agent_framework_durabletask._workflows.orchestrator import ( - SUBWORKFLOW_ADDRESS_KEY, - SUBWORKFLOW_INPUT_KEY, - TaskMetadata, - TaskType, - _coerce_initial_input, - _index_subworkflows, - _prepare_all_tasks, - _prepare_subworkflow_task, - _process_subworkflow_result, - _resolve_workflow_address, - _try_unwrap_subworkflow_input, - _unpack_subworkflow_result, -) -from agent_framework_durabletask._workflows.serialization import ( - SUBWORKFLOW_RESULT_KEY, - deserialize_value, - serialize_value, -) - - -def _subworkflow_executor(executor_id: str, inner_name: str, *, allow_direct_output: bool = False) -> Mock: - inner = Mock() - inner.name = inner_name - executor = Mock(spec=WorkflowExecutor) - executor.id = executor_id - executor.workflow = inner - executor.allow_direct_output = allow_direct_output - return executor - - -def _event(event_type: str, executor_id: str, data: object = None) -> dict[str, Any]: - """Build a serialized workflow-event dict as the child orchestrator emits it.""" - serialized: dict[str, Any] = {"type": event_type, "executor_id": executor_id} - if data is not None: - serialized["data"] = serialize_value(data) - return serialized - - -def _result_envelope(outputs: list[Any], events: list[dict[str, Any]]) -> dict[str, Any]: - """Build the SUBWORKFLOW_RESULT_KEY envelope a child orchestration returns.""" - return {SUBWORKFLOW_RESULT_KEY: True, "outputs": outputs, "events": events} - - -# A representative child address marker (root instance/workflow + one-hop path prefix). -_CHILD_ADDRESS = { - "root_instance_id": "root-instance", - "root_workflow_name": "outer_wf", - "request_path_prefix": "sub-node~0~", -} - - -class TestPrepareSubworkflowTask: - """Dispatch of a ``WorkflowExecutor`` node as a child orchestration.""" - - def test_schedules_inner_orchestration_by_scoped_name(self) -> None: - ctx = Mock() - ctx.call_sub_orchestrator.return_value = "task-sentinel" - executor = _subworkflow_executor("sub-node", "inner_wf") - - task = _prepare_subworkflow_task(ctx, executor, "hello", "parent::sub-node::0", _CHILD_ADDRESS) - - assert task == "task-sentinel" - ctx.call_sub_orchestrator.assert_called_once() - args, kwargs = ctx.call_sub_orchestrator.call_args - assert args[0] == "dafx-inner_wf" - assert kwargs["instance_id"] == "parent::sub-node::0" - - def test_wraps_message_in_marker(self) -> None: - ctx = Mock() - executor = _subworkflow_executor("sub-node", "inner_wf") - - _prepare_subworkflow_task(ctx, executor, "payload", "child-id", _CHILD_ADDRESS) - - args, _ = ctx.call_sub_orchestrator.call_args - child_input = args[1] - # The wrapped payload round-trips back to the original message. - assert deserialize_value(child_input[SUBWORKFLOW_INPUT_KEY]) == "payload" - # The address marker rides alongside so the child can build respond URLs. - assert child_input[SUBWORKFLOW_ADDRESS_KEY] == _CHILD_ADDRESS - - -class TestProcessSubworkflowResult: - """Conversion of a child orchestration's outputs into an ``ExecutorResult``.""" - - def test_default_routes_outputs_as_messages(self) -> None: - executor = _subworkflow_executor("sub-node", "inner_wf", allow_direct_output=False) - workflow_outputs: list[object] = [] - - result = _process_subworkflow_result(["a", "b"], executor, workflow_outputs) - - assert result.task_type == TaskType.SUBWORKFLOW - assert workflow_outputs == [] - assert result.activity_result is not None - sent = result.activity_result["sent_messages"] - assert [m["message"] for m in sent] == ["a", "b"] - assert all(m["source_id"] == "sub-node" and m["target_id"] is None for m in sent) - - def test_allow_direct_output_extends_parent_outputs(self) -> None: - executor = _subworkflow_executor("sub-node", "inner_wf", allow_direct_output=True) - workflow_outputs: list[object] = ["existing"] - - result = _process_subworkflow_result(["x", "y"], executor, workflow_outputs) - - assert workflow_outputs == ["existing", "x", "y"] - assert result.activity_result is not None - assert result.activity_result["sent_messages"] == [] - - def test_none_result_produces_no_outputs(self) -> None: - executor = _subworkflow_executor("sub-node", "inner_wf") - workflow_outputs: list[object] = [] - - result = _process_subworkflow_result(None, executor, workflow_outputs) - - assert result.activity_result is not None - assert result.activity_result["sent_messages"] == [] - assert workflow_outputs == [] - - def test_scalar_result_is_wrapped_as_single_output(self) -> None: - executor = _subworkflow_executor("sub-node", "inner_wf", allow_direct_output=True) - workflow_outputs: list[object] = [] - - _process_subworkflow_result("solo", executor, workflow_outputs) - - assert workflow_outputs == ["solo"] - - def test_envelope_outputs_routed_as_messages(self) -> None: - """Outputs carried in a result envelope are routed like a bare-list result.""" - executor = _subworkflow_executor("sub-node", "inner_wf", allow_direct_output=False) - workflow_outputs: list[object] = [] - envelope = _result_envelope(["a", "b"], events=[]) - - result = _process_subworkflow_result(envelope, executor, workflow_outputs) - - assert result.activity_result is not None - assert [m["message"] for m in result.activity_result["sent_messages"]] == ["a", "b"] - assert workflow_outputs == [] - - def test_envelope_allow_direct_output_extends_parent_outputs(self) -> None: - executor = _subworkflow_executor("sub-node", "inner_wf", allow_direct_output=True) - workflow_outputs: list[object] = [] - envelope = _result_envelope(["x", "y"], events=[]) - - _process_subworkflow_result(envelope, executor, workflow_outputs) - - assert workflow_outputs == ["x", "y"] - - def test_intermediate_events_bubbled_retagged_with_node_id(self) -> None: - """A child's intermediate events bubble up re-tagged with the node id. - - Mirrors the in-process WorkflowExecutor, which forwards child intermediate - emissions as WorkflowEvent("intermediate", executor_id=self.id, ...) so an - outer observer sees nested progress without the child's internal layout. - """ - executor = _subworkflow_executor("sub-node", "inner_wf") - workflow_outputs: list[object] = [] - envelope = _result_envelope( - outputs=["out"], - events=[_event("intermediate", "inner-exec", data="progress")], - ) - - result = _process_subworkflow_result(envelope, executor, workflow_outputs) - - assert result.activity_result is not None - bubbled = result.activity_result["events"] - assert len(bubbled) == 1 - # Re-tagged with the WorkflowExecutor node id, not the child's executor id. - assert bubbled[0]["executor_id"] == "sub-node" - assert bubbled[0]["type"] == "intermediate" - # Payload is preserved (still serialized for the parent timeline). - assert deserialize_value(bubbled[0]["data"]) == "progress" - - def test_non_intermediate_child_events_are_not_bubbled(self) -> None: - """Only intermediate events bubble: lifecycle/output events stay child-internal.""" - executor = _subworkflow_executor("sub-node", "inner_wf") - workflow_outputs: list[object] = [] - envelope = _result_envelope( - outputs=["out"], - events=[ - _event("executor_invoked", "inner-exec"), - _event("executor_completed", "inner-exec"), - _event("output", "inner-exec", data="out"), - ], - ) - - result = _process_subworkflow_result(envelope, executor, workflow_outputs) - - assert result.activity_result is not None - assert result.activity_result["events"] == [] - - -class TestUnpackSubworkflowResult: - """Splitting a child orchestration's return value into ``(outputs, events)``.""" - - def test_unpacks_result_envelope(self) -> None: - events = [_event("intermediate", "inner-exec", data="p")] - envelope = _result_envelope(["a", "b"], events=events) - - outputs, parsed_events = _unpack_subworkflow_result(envelope) - - assert outputs == ["a", "b"] - assert parsed_events == events - - def test_bare_list_is_outputs_with_no_events(self) -> None: - assert _unpack_subworkflow_result(["a", "b"]) == (["a", "b"], []) - - def test_none_is_empty_outputs_and_events(self) -> None: - assert _unpack_subworkflow_result(None) == ([], []) - - def test_scalar_is_single_output(self) -> None: - assert _unpack_subworkflow_result("solo") == (["solo"], []) - - def test_envelope_with_missing_keys_degrades_gracefully(self) -> None: - """A malformed envelope (missing outputs/events) yields empty lists, not errors.""" - outputs, events = _unpack_subworkflow_result({SUBWORKFLOW_RESULT_KEY: True}) - - assert outputs == [] - assert events == [] - - -class TestSubworkflowInputUnwrap: - """Child-side reconstruction of the parent-supplied marker payload.""" - - def test_unwrap_detects_and_reconstructs_marker(self) -> None: - marker = {SUBWORKFLOW_INPUT_KEY: "wrapped"} - - unwrapped, inner = _try_unwrap_subworkflow_input(marker) - - assert unwrapped is True - assert inner == "wrapped" - - def test_unwrap_ignores_non_marker_dict(self) -> None: - unwrapped, inner = _try_unwrap_subworkflow_input({"some": "data"}) - - assert unwrapped is False - assert inner is None - - def test_unwrap_ignores_non_dict(self) -> None: - assert _try_unwrap_subworkflow_input("plain") == (False, None) - - def test_coerce_initial_input_returns_unwrapped_inner(self) -> None: - # When the workflow runs as a child, _coerce_initial_input returns the - # reconstructed inner object directly, bypassing start-executor coercion. - workflow = Mock() - workflow.executors = {} - marker = {SUBWORKFLOW_INPUT_KEY: "inner-message"} - - assert _coerce_initial_input(workflow, marker) == "inner-message" - - -class TestResolveWorkflowAddress: - """Derivation of an orchestration's HITL address (root vs nested child).""" - - def test_top_level_is_its_own_root_with_empty_prefix(self) -> None: - addr = _resolve_workflow_address("plain input", "top-instance", "outer_wf") - assert addr == { - "root_instance_id": "top-instance", - "root_workflow_name": "outer_wf", - "request_path_prefix": "", - } - - def test_child_inherits_address_from_marker(self) -> None: - marker = { - SUBWORKFLOW_INPUT_KEY: "x", - SUBWORKFLOW_ADDRESS_KEY: { - "root_instance_id": "root", - "root_workflow_name": "outer_wf", - "request_path_prefix": "review_sub~0~", - }, - } - # ctx.instance_id ("child-id") is ignored in favour of the marker's root values. - addr = _resolve_workflow_address(marker, "child-id", "human_review") - assert addr == { - "root_instance_id": "root", - "root_workflow_name": "outer_wf", - "request_path_prefix": "review_sub~0~", - } - - def test_malformed_address_marker_falls_back_to_self(self) -> None: - # A non-dict / incomplete address marker is ignored (treated as top-level). - marker = {SUBWORKFLOW_ADDRESS_KEY: {"root_instance_id": 123}} - addr = _resolve_workflow_address(marker, "self-id", "wf") - assert addr == {"root_instance_id": "self-id", "root_workflow_name": "wf", "request_path_prefix": ""} - - -class TestSubworkflowAddressPropagation: - """The dispatch-side request-path prefix must match the read-side qualified id. - - The orchestrator builds each child's prefix from a *per-executor* ordinal; the - status/respond read path qualifies a nested request by ``enumerate()``-ing the - ``subworkflows[executorId]`` list. These two indexes must agree or an emailed - respond URL would resolve to the wrong child (or 404). This guards that agreement - for the tricky case: one node fanning out to several children in one superstep. - """ - - def _dispatch( - self, address: dict[str, str], message_count: int - ) -> tuple[list[dict[str, str]], list[str], list[TaskMetadata]]: - """Run _prepare_all_tasks for one WorkflowExecutor node fanning out N children. - - Returns (child_addresses, child_instance_ids, task_metadata) in dispatch order. - """ - node_id = "review_sub" - executor = _subworkflow_executor(node_id, "human_review") - workflow = Mock() - workflow.executors = {node_id: executor} - workflow.name = "moderation_pipeline" - - captured: list[dict[str, str]] = [] - - def _call_sub(name: str, input_: dict[str, object], *, instance_id: str) -> str: # noqa: ARG001 - captured.append(cast("dict[str, str]", input_[SUBWORKFLOW_ADDRESS_KEY])) - return f"task::{instance_id}" - - ctx = Mock() - ctx.instance_id = address["root_instance_id"] - ctx.call_sub_orchestrator.side_effect = _call_sub - - pending = {node_id: [(f"msg-{i}", "src") for i in range(message_count)]} - _all_tasks, task_metadata, _remaining = _prepare_all_tasks(ctx, workflow, pending, None, [0], address) - child_ids = [m.child_instance_id for m in task_metadata if m.task_type == TaskType.SUBWORKFLOW] - assert all(cid is not None for cid in child_ids) - return captured, [cid for cid in child_ids if cid is not None], task_metadata - - def test_fanout_prefixes_match_readside_qualification(self) -> None: - top = {"root_instance_id": "root", "root_workflow_name": "moderation_pipeline", "request_path_prefix": ""} - addresses, child_ids, _task_metadata = self._dispatch(top, message_count=3) - - # Read side: subworkflows["review_sub"] = [child0, child1, child2]; a nested - # request from child ``ordinal`` is qualified as review_sub~{ordinal}~{bare}. - bare = "req-xyz" - for ordinal, child_address in enumerate(addresses): - dispatch_qualified = f"{child_address['request_path_prefix']}{bare}" - readside_qualified = qualify_subworkflow_request_id("review_sub", ordinal, bare) - assert dispatch_qualified == readside_qualified - # Root identity is carried through unchanged at every child. - assert child_address["root_instance_id"] == "root" - assert child_address["root_workflow_name"] == "moderation_pipeline" - - # Child instance ids use the *global* counter (distinct from the ordinal). - assert child_ids == ["root::review_sub::0", "root::review_sub::1", "root::review_sub::2"] - - def test_prefix_accumulates_when_already_nested(self) -> None: - # Simulate dispatching from a workflow that is itself one level deep. - nested = { - "root_instance_id": "root", - "root_workflow_name": "moderation_pipeline", - "request_path_prefix": "outer_node~2~", - } - addresses, _child_ids, _task_metadata = self._dispatch(nested, message_count=2) - - assert [a["request_path_prefix"] for a in addresses] == [ - "outer_node~2~review_sub~0~", - "outer_node~2~review_sub~1~", - ] - - def test_readside_index_matches_dispatch_ordinal(self) -> None: - # Close the loop through the read-side map the parent actually publishes: a nested - # request qualified review_sub~{ordinal}~ must resolve, via subworkflows[executor], - # to the same child the dispatch stamped that ordinal onto. Guards the write ordinal - # and read index from drifting if task_metadata order or the grouping ever changes. - top = {"root_instance_id": "root", "root_workflow_name": "moderation_pipeline", "request_path_prefix": ""} - addresses, child_ids, task_metadata = self._dispatch(top, message_count=3) - - subworkflows = _index_subworkflows(task_metadata) - assert subworkflows == {"review_sub": child_ids} - for ordinal, (child_id, child_address) in enumerate(zip(child_ids, addresses, strict=True)): - assert child_address["request_path_prefix"] == qualify_subworkflow_request_id("review_sub", ordinal, "") - assert subworkflows["review_sub"][ordinal] == child_id diff --git a/python/packages/durabletask/tests/test_worker.py b/python/packages/durabletask/tests/test_worker.py deleted file mode 100644 index 24f7ccb8f1c..00000000000 --- a/python/packages/durabletask/tests/test_worker.py +++ /dev/null @@ -1,473 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Unit tests for DurableAIAgentWorker. - -Focuses on critical worker flows: agent registration, validation, callbacks, and lifecycle. -""" - -from unittest.mock import Mock - -import pytest - -from agent_framework_durabletask import DurableAIAgentWorker - - -@pytest.fixture -def mock_grpc_worker() -> Mock: - """Create a mock TaskHubGrpcWorker for testing.""" - mock = Mock() - mock.add_entity = Mock(return_value="dafx-test_agent") - mock.start = Mock() - mock.stop = Mock() - return mock - - -@pytest.fixture -def mock_agent() -> Mock: - """Create a mock agent for testing.""" - agent = Mock() - agent.name = "test_agent" - return agent - - -@pytest.fixture -def agent_worker(mock_grpc_worker: Mock) -> DurableAIAgentWorker: - """Create a DurableAIAgentWorker with mock worker.""" - return DurableAIAgentWorker(mock_grpc_worker) - - -class TestDurableAIAgentWorkerRegistration: - """Test agent registration behavior.""" - - def test_add_agent_accepts_agent_with_name( - self, agent_worker: DurableAIAgentWorker, mock_agent: Mock, mock_grpc_worker: Mock - ) -> None: - """Verify that agents with names can be registered.""" - agent_worker.add_agent(mock_agent) - - # Verify entity was registered with underlying worker - mock_grpc_worker.add_entity.assert_called_once() - # Verify agent name is tracked - assert "test_agent" in agent_worker.registered_agent_names - - def test_add_agent_rejects_agent_without_name(self, agent_worker: DurableAIAgentWorker) -> None: - """Verify that agents without names are rejected.""" - agent_no_name = Mock() - agent_no_name.name = None - - with pytest.raises(ValueError, match="Agent must have a name"): - agent_worker.add_agent(agent_no_name) - - def test_add_agent_rejects_empty_name(self, agent_worker: DurableAIAgentWorker) -> None: - """Verify that agents with empty names are rejected.""" - agent_empty_name = Mock() - agent_empty_name.name = "" - - with pytest.raises(ValueError, match="Agent must have a name"): - agent_worker.add_agent(agent_empty_name) - - def test_add_agent_rejects_duplicate_names(self, agent_worker: DurableAIAgentWorker, mock_agent: Mock) -> None: - """Verify duplicate agent names are not allowed.""" - agent_worker.add_agent(mock_agent) - - # Try to register another agent with the same name - duplicate_agent = Mock() - duplicate_agent.name = "test_agent" - - with pytest.raises(ValueError, match="already registered"): - agent_worker.add_agent(duplicate_agent) - - def test_registered_agent_names_tracks_multiple_agents(self, agent_worker: DurableAIAgentWorker) -> None: - """Verify registered_agent_names tracks all registered agents.""" - agent1 = Mock() - agent1.name = "agent1" - agent2 = Mock() - agent2.name = "agent2" - agent3 = Mock() - agent3.name = "agent3" - - agent_worker.add_agent(agent1) - agent_worker.add_agent(agent2) - agent_worker.add_agent(agent3) - - registered = agent_worker.registered_agent_names - assert "agent1" in registered - assert "agent2" in registered - assert "agent3" in registered - assert len(registered) == 3 - - -class TestDurableAIAgentWorkerCallbacks: - """Test callback configuration behavior.""" - - def test_worker_level_callback_accepted(self, mock_grpc_worker: Mock) -> None: - """Verify worker-level callback can be set.""" - mock_callback = Mock() - agent_worker = DurableAIAgentWorker(mock_grpc_worker, callback=mock_callback) - - assert agent_worker is not None - - def test_agent_level_callback_accepted(self, agent_worker: DurableAIAgentWorker, mock_agent: Mock) -> None: - """Verify agent-level callback can be set during registration.""" - mock_callback = Mock() - - # Should not raise exception - agent_worker.add_agent(mock_agent, callback=mock_callback) - - assert "test_agent" in agent_worker.registered_agent_names - - def test_none_callback_accepted(self, mock_grpc_worker: Mock, mock_agent: Mock) -> None: - """Verify None callback is valid (no callbacks required).""" - agent_worker = DurableAIAgentWorker(mock_grpc_worker, callback=None) - agent_worker.add_agent(mock_agent, callback=None) - - assert "test_agent" in agent_worker.registered_agent_names - - -class TestDurableAIAgentWorkerLifecycle: - """Test worker lifecycle behavior.""" - - def test_start_delegates_to_underlying_worker( - self, agent_worker: DurableAIAgentWorker, mock_grpc_worker: Mock - ) -> None: - """Verify start() delegates to wrapped worker.""" - agent_worker.start() - - mock_grpc_worker.start.assert_called_once() - - def test_stop_delegates_to_underlying_worker( - self, agent_worker: DurableAIAgentWorker, mock_grpc_worker: Mock - ) -> None: - """Verify stop() delegates to wrapped worker.""" - agent_worker.stop() - - mock_grpc_worker.stop.assert_called_once() - - def test_start_works_with_no_agents(self, agent_worker: DurableAIAgentWorker, mock_grpc_worker: Mock) -> None: - """Verify worker can start even with no agents registered.""" - agent_worker.start() - - mock_grpc_worker.start.assert_called_once() - - def test_start_works_with_multiple_agents(self, agent_worker: DurableAIAgentWorker, mock_grpc_worker: Mock) -> None: - """Verify worker can start with multiple agents registered.""" - agent1 = Mock() - agent1.name = "agent1" - agent2 = Mock() - agent2.name = "agent2" - - agent_worker.add_agent(agent1) - agent_worker.add_agent(agent2) - agent_worker.start() - - mock_grpc_worker.start.assert_called_once() - assert len(agent_worker.registered_agent_names) == 2 - - -class TestDurableAIAgentWorkerWorkflow: - """Test workflow registration, including the agent-executor identity fix.""" - - def test_add_agent_with_entity_id_registers_under_override( - self, agent_worker: DurableAIAgentWorker, mock_agent: Mock - ) -> None: - """An explicit entity_id overrides the agent name as the entity identity.""" - agent_worker.add_agent(mock_agent, entity_id="node-7") - - assert "node-7" in agent_worker.registered_agent_names - assert "test_agent" not in agent_worker.registered_agent_names - - def test_configure_workflow_registers_agent_entity_by_executor_id( - self, agent_worker: DurableAIAgentWorker, mock_grpc_worker: Mock - ) -> None: - """Workflow agent executors register entities keyed by the workflow-scoped id. - - The orchestrator dispatches by the scoped identity - ``{workflow}-{executorId}``, so an ``AgentExecutor(agent, id=...)`` whose id - differs from the agent name must still be reachable under that scoped id. - """ - from agent_framework import AgentExecutor - - agent = Mock() - agent.name = "Reviewer" - agent_executor = Mock(spec=AgentExecutor) - agent_executor.id = "custom-executor-id" - agent_executor.agent = agent - - workflow = Mock() - workflow.name = "review" - workflow.executors = {"custom-executor-id": agent_executor} - - agent_worker.configure_workflow(workflow) - - assert "review-custom-executor-id" in agent_worker.registered_agent_names - assert "Reviewer" not in agent_worker.registered_agent_names - assert "custom-executor-id" not in agent_worker.registered_agent_names - mock_grpc_worker.add_orchestrator.assert_called_once() - - def test_configure_workflow_registers_non_agent_executor_as_activity( - self, agent_worker: DurableAIAgentWorker, mock_grpc_worker: Mock - ) -> None: - """Non-agent executors are registered as activities, not entities.""" - from agent_framework import Executor - - activity_executor = Mock(spec=Executor) - activity_executor.id = "router-node" - - workflow = Mock() - workflow.name = "route" - workflow.executors = {"router-node": activity_executor} - - agent_worker.configure_workflow(workflow) - - assert agent_worker.registered_agent_names == [] - mock_grpc_worker.add_activity.assert_called_once() - mock_grpc_worker.add_orchestrator.assert_called_once() - # The activity is registered under the workflow-scoped name. - registered_activity = mock_grpc_worker.add_activity.call_args[0][0] - assert registered_activity.__name__ == "dafx-route-router-node" - - -class TestMultiWorkflowRegistration: - """Test hosting multiple workflows on one worker with scoped names.""" - - def _agent_workflow(self, name: str, executor_id: str) -> Mock: - from agent_framework import AgentExecutor - - agent = Mock() - agent.name = "Assistant" - agent_executor = Mock(spec=AgentExecutor) - agent_executor.id = executor_id - agent_executor.agent = agent - - workflow = Mock() - workflow.name = name - workflow.executors = {executor_id: agent_executor} - return workflow - - def test_two_workflows_reusing_executor_id_do_not_collide(self, agent_worker: DurableAIAgentWorker) -> None: - """Two workflows that reuse an executor id register distinct scoped entities.""" - agent_worker.configure_workflow(self._agent_workflow("orders", "assistant")) - agent_worker.configure_workflow(self._agent_workflow("billing", "assistant")) - - assert "orders-assistant" in agent_worker.registered_agent_names - assert "billing-assistant" in agent_worker.registered_agent_names - assert set(agent_worker.registered_workflow_names) == {"orders", "billing"} - - def test_registers_one_orchestrator_per_workflow( - self, agent_worker: DurableAIAgentWorker, mock_grpc_worker: Mock - ) -> None: - """Each configured workflow registers its own orchestrator.""" - agent_worker.configure_workflow(self._agent_workflow("orders", "a")) - agent_worker.configure_workflow(self._agent_workflow("billing", "b")) - - assert mock_grpc_worker.add_orchestrator.call_count == 2 - registered_names = {call.args[0].__name__ for call in mock_grpc_worker.add_orchestrator.call_args_list} - assert registered_names == {"dafx-orders", "dafx-billing"} - - def test_rejects_duplicate_workflow_name(self, agent_worker: DurableAIAgentWorker) -> None: - """Configuring two workflows with the same name is rejected.""" - agent_worker.configure_workflow(self._agent_workflow("orders", "a")) - - with pytest.raises(ValueError, match="already registered"): - agent_worker.configure_workflow(self._agent_workflow("orders", "b")) - - def test_rejects_case_insensitive_duplicate_workflow_name(self, agent_worker: DurableAIAgentWorker) -> None: - """Workflow names that differ only by case collide and are rejected. - - The route ownership guard folds case, so allowing both ``orders`` and - ``Orders`` would let one workflow's surface reach the other's instances. - """ - agent_worker.configure_workflow(self._agent_workflow("orders", "a")) - - with pytest.raises(ValueError, match="case-insensitively"): - agent_worker.configure_workflow(self._agent_workflow("Orders", "b")) - - def test_rejects_auto_generated_workflow_name(self, agent_worker: DurableAIAgentWorker) -> None: - """A workflow with an auto-generated WorkflowBuilder name is rejected.""" - import uuid - - workflow = self._agent_workflow(f"WorkflowBuilder-{uuid.uuid4()}", "a") - - with pytest.raises(ValueError, match="auto-generated"): - agent_worker.configure_workflow(workflow) - - def test_rejects_invalid_workflow_name(self, agent_worker: DurableAIAgentWorker) -> None: - """A workflow with an invalid name is rejected.""" - workflow = self._agent_workflow("has space", "a") - - with pytest.raises(ValueError, match="invalid"): - agent_worker.configure_workflow(workflow) - - -class TestSubworkflowRegistration: - """Test recursive registration of nested sub-workflows on one worker.""" - - def _inner_agent_workflow(self, name: str, executor_id: str) -> Mock: - from agent_framework import AgentExecutor - - agent = Mock() - agent.name = "InnerAssistant" - agent_executor = Mock(spec=AgentExecutor) - agent_executor.id = executor_id - agent_executor.agent = agent - - workflow = Mock() - workflow.name = name - workflow.executors = {executor_id: agent_executor} - return workflow - - def _outer_workflow(self, name: str, inner: Mock, *, sub_ids: tuple[str, ...] = ("sub",)) -> Mock: - from agent_framework import Executor, WorkflowExecutor - - executors: dict[str, Mock] = {} - for sub_id in sub_ids: - sub = Mock(spec=WorkflowExecutor) - sub.id = sub_id - sub.workflow = inner - sub.allow_direct_output = False - executors[sub_id] = sub - - router = Mock(spec=Executor) - router.id = "router" - executors["router"] = router - - workflow = Mock() - workflow.name = name - workflow.executors = executors - return workflow - - def test_nested_workflow_registers_both_orchestrations( - self, agent_worker: DurableAIAgentWorker, mock_grpc_worker: Mock - ) -> None: - """Configuring an outer workflow registers the inner workflow's orchestration too.""" - inner = self._inner_agent_workflow("inner", "agent_node") - outer = self._outer_workflow("outer", inner) - - agent_worker.configure_workflow(outer) - - registered = {call.args[0].__name__ for call in mock_grpc_worker.add_orchestrator.call_args_list} - assert registered == {"dafx-outer", "dafx-inner"} - - def test_nested_workflow_registers_inner_agent_scoped(self, agent_worker: DurableAIAgentWorker) -> None: - """The inner workflow's agent is registered under the inner-scoped id.""" - inner = self._inner_agent_workflow("inner", "agent_node") - outer = self._outer_workflow("outer", inner) - - agent_worker.configure_workflow(outer) - - assert "inner-agent_node" in agent_worker.registered_agent_names - - def test_subworkflow_node_not_registered_as_activity( - self, agent_worker: DurableAIAgentWorker, mock_grpc_worker: Mock - ) -> None: - """A WorkflowExecutor node is driven as a child orchestration, not an activity.""" - inner = self._inner_agent_workflow("inner", "agent_node") - outer = self._outer_workflow("outer", inner) - - agent_worker.configure_workflow(outer) - - # Only the outer 'router' non-agent executor becomes an activity. - registered_activities = {call.args[0].__name__ for call in mock_grpc_worker.add_activity.call_args_list} - assert registered_activities == {"dafx-outer-router"} - - def test_top_level_names_exclude_nested_workflows(self, agent_worker: DurableAIAgentWorker) -> None: - """``registered_workflow_names`` reports only top-level workflows.""" - inner = self._inner_agent_workflow("inner", "agent_node") - outer = self._outer_workflow("outer", inner) - - agent_worker.configure_workflow(outer) - - assert agent_worker.registered_workflow_names == ["outer"] - - def test_shared_subworkflow_registered_once( - self, agent_worker: DurableAIAgentWorker, mock_grpc_worker: Mock - ) -> None: - """A sub-workflow reused by two nodes registers its orchestration only once.""" - inner = self._inner_agent_workflow("inner", "agent_node") - outer = self._outer_workflow("outer", inner, sub_ids=("sub_a", "sub_b")) - - agent_worker.configure_workflow(outer) - - registered = [call.args[0].__name__ for call in mock_grpc_worker.add_orchestrator.call_args_list] - assert sorted(registered) == ["dafx-inner", "dafx-outer"] - - def test_nested_workflow_with_invalid_name_is_rejected(self, agent_worker: DurableAIAgentWorker) -> None: - """A nested sub-workflow must also have a valid, stable name.""" - inner = self._inner_agent_workflow("has space", "agent_node") - outer = self._outer_workflow("outer", inner) - - with pytest.raises(ValueError, match="invalid"): - agent_worker.configure_workflow(outer) - - def test_different_subworkflow_sharing_a_name_is_rejected(self, agent_worker: DurableAIAgentWorker) -> None: - """Two different sub-workflow instances that share a name collide and are rejected.""" - from agent_framework import Executor, WorkflowExecutor - - inner_a = self._inner_agent_workflow("shared", "agent_node") - inner_b = self._inner_agent_workflow("shared", "other_node") # different instance, same name - - sub_a = Mock(spec=WorkflowExecutor) - sub_a.id = "a" - sub_a.workflow = inner_a - sub_b = Mock(spec=WorkflowExecutor) - sub_b.id = "b" - sub_b.workflow = inner_b - router = Mock(spec=Executor) - router.id = "router" - outer = Mock() - outer.name = "outer" - outer.executors = {"a": sub_a, "b": sub_b, "router": router} - - with pytest.raises(ValueError, match="different workflow|different workflows"): - agent_worker.configure_workflow(outer) - - def test_cross_registration_nested_collision_is_atomic( - self, agent_worker: DurableAIAgentWorker, mock_grpc_worker: Mock - ) -> None: - """A later configure_workflow whose nested child collides leaves the worker unchanged. - - Reproduces the partial-registration path: configure one workflow, then configure - a second whose nested sub-workflow reuses the first's child name. The second call - must raise *before* mutating any state, so the second top-level workflow is not - left half-registered (which would also wedge a corrected retry on the duplicate - guard). - """ - shared_a = self._inner_agent_workflow("shared", "agent_node") - agent_worker.configure_workflow(self._outer_workflow("first", shared_a)) - - orchestrators_before = mock_grpc_worker.add_orchestrator.call_count - - # A *different* 'shared' instance nested under a new top-level workflow collides. - shared_b = self._inner_agent_workflow("shared", "other_node") - with pytest.raises(ValueError, match="collides"): - agent_worker.configure_workflow(self._outer_workflow("second", shared_b)) - - # The worker is not partially configured: 'second' was never added, and no new - # orchestration was registered. - assert agent_worker.registered_workflow_names == ["first"] - assert mock_grpc_worker.add_orchestrator.call_count == orchestrators_before - - def test_executor_id_with_reserved_separator_is_rejected(self, agent_worker: DurableAIAgentWorker) -> None: - """An executor id containing the nested-HITL separator is rejected at registration.""" - workflow = self._agent_workflow_with_executor_id("orders", "bad~id") - - with pytest.raises(ValueError, match="reserved sub-workflow request separator"): - agent_worker.configure_workflow(workflow) - - @staticmethod - def _agent_workflow_with_executor_id(name: str, executor_id: str) -> Mock: - from agent_framework import AgentExecutor - - agent = Mock() - agent.name = "Assistant" - agent_executor = Mock(spec=AgentExecutor) - agent_executor.id = executor_id - agent_executor.agent = agent - workflow = Mock() - workflow.name = name - workflow.executors = {executor_id: agent_executor} - return workflow - - -if __name__ == "__main__": - pytest.main([__file__, "-v", "--tb=short"]) diff --git a/python/packages/durabletask/tests/test_workflow_activity.py b/python/packages/durabletask/tests/test_workflow_activity.py deleted file mode 100644 index de6650ff277..00000000000 --- a/python/packages/durabletask/tests/test_workflow_activity.py +++ /dev/null @@ -1,193 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Unit tests for execute_workflow_activity (shared non-agent executor activity body). - -These tests exercise the host-agnostic activity execution shared by the Azure -Functions and standalone durabletask workflow hosts. In particular they protect -the state snapshot/diff semantics: the snapshot must be a *deep* copy so that -in-place mutations to nested objects (dicts, lists) are correctly detected as -updates (regression guard for the shallow-copy bug, #4500). -""" - -import json -from dataclasses import dataclass -from typing import Any -from unittest.mock import AsyncMock, Mock - -from agent_framework_durabletask import execute_workflow_activity -from agent_framework_durabletask._workflows.orchestrator import SOURCE_HITL_RESPONSE, SOURCE_ORCHESTRATOR -from agent_framework_durabletask._workflows.serialization import serialize_value - - -@dataclass -class ApprovalRequest: - """Typed request used to select a HITL response handler.""" - - prompt: str - - -def _make_executor(executor_id: str, mutate: Any) -> Mock: - """Build a mock non-agent executor whose execute() mutates shared state.""" - executor = Mock() - executor.id = executor_id - executor.execute = AsyncMock(side_effect=mutate) - return executor - - -def _run(executor: Mock, snapshot: dict[str, Any]) -> dict[str, Any]: - """Invoke execute_workflow_activity and return the parsed result dict.""" - input_data = json.dumps({ - "message": "test", - "shared_state_snapshot": snapshot, - "source_executor_ids": [SOURCE_ORCHESTRATOR], - }) - return json.loads(execute_workflow_activity(executor, input_data)) - - -class TestExecuteWorkflowActivityStateDiff: - """State snapshot/diff behavior of the shared workflow activity body.""" - - def test_nested_dict_mutation_detected(self) -> None: - """In-place mutation of a nested dict is reported as an update.""" - - async def mutate(message: Any, source_executor_ids: Any, state: Any, runner_context: Any) -> None: - config = state.get("Local.config") - config["code"] = "SOMECODEXXX" - config["enabled"] = True - state.commit() - - executor = _make_executor("test-exec", mutate) - result = _run(executor, {"Local.config": {"code": "", "enabled": False}, "simple_key": "simple_value"}) - - updates = result["shared_state_updates"] - assert "Local.config" in updates, "nested mutation not detected — snapshot may be a shallow copy" - assert updates["Local.config"]["code"] == "SOMECODEXXX" - assert updates["Local.config"]["enabled"] is True - - def test_new_key_in_nested_dict_detected(self) -> None: - """Adding a key to a nested dict is reported as an update.""" - - async def mutate(message: Any, source_executor_ids: Any, state: Any, runner_context: Any) -> None: - state.get("Local.data")["code"] = "NEW_CODE" - state.commit() - - executor = _make_executor("test-exec", mutate) - result = _run(executor, {"Local.data": {"existing": "value"}}) - - assert result["shared_state_updates"]["Local.data"]["code"] == "NEW_CODE" - - def test_nested_list_mutation_detected(self) -> None: - """Appending to a nested list is reported as an update.""" - - async def mutate(message: Any, source_executor_ids: Any, state: Any, runner_context: Any) -> None: - state.get("Local.items").append(4) - state.commit() - - executor = _make_executor("test-exec", mutate) - result = _run(executor, {"Local.items": [1, 2, 3]}) - - assert result["shared_state_updates"]["Local.items"] == [1, 2, 3, 4] - - def test_new_top_level_key_detected(self) -> None: - """Setting a new top-level key is reported as an update.""" - - async def mutate(message: Any, source_executor_ids: Any, state: Any, runner_context: Any) -> None: - state.set("Local.code", "SOMECODEXXX") - state.commit() - - executor = _make_executor("test-exec", mutate) - result = _run(executor, {"existing": "value"}) - - assert result["shared_state_updates"]["Local.code"] == "SOMECODEXXX" - - def test_unchanged_state_produces_empty_diff(self) -> None: - """Unmodified state produces no updates.""" - - async def mutate(message: Any, source_executor_ids: Any, state: Any, runner_context: Any) -> None: - # No mutations performed. - state.commit() - - executor = _make_executor("test-exec", mutate) - result = _run(executor, {"Local.config": {"code": "existing", "enabled": True}, "simple_key": "v"}) - - assert result["shared_state_updates"] == {} - - def test_deleted_key_reported(self) -> None: - """A key removed during execution is reported as a delete.""" - - async def mutate(message: Any, source_executor_ids: Any, state: Any, runner_context: Any) -> None: - state.delete("to_remove") - state.commit() - - executor = _make_executor("test-exec", mutate) - result = _run(executor, {"to_remove": "value", "keep": "value"}) - - assert "to_remove" in result["shared_state_deletes"] - assert "keep" not in result["shared_state_deletes"] - - -def test_hitl_response_handler_receives_typed_original_request() -> None: - """Already-serialized HITL requests are decoded before response handler dispatch.""" - original_request = ApprovalRequest(prompt="Approve this?") - hitl_message = { - "original_request": serialize_value(original_request), - "response": "approved", - "response_type": None, - } - input_data = json.dumps({ - "message": serialize_value(hitl_message), - "shared_state_snapshot": {}, - "source_executor_ids": [f"{SOURCE_HITL_RESPONSE}_request-1"], - }) - - handler = AsyncMock() - executor = Mock() - executor.id = "review-gate" - executor._find_response_handler.return_value = handler - - execute_workflow_activity(executor, input_data) - - executor._find_response_handler.assert_called_once_with(original_request, "approved") - handler.assert_awaited_once() - - -class TestExecuteWorkflowActivityHostMetadata: - """Orchestration metadata is surfaced to executors via the runner context.""" - - def test_host_context_surfaced_on_runner_context(self) -> None: - """``host_context`` in the activity input is exposed as ``runner_context.host_metadata``.""" - captured: dict[str, Any] = {} - - async def capture(message: Any, source_executor_ids: Any, state: Any, runner_context: Any) -> None: - captured["metadata"] = runner_context.host_metadata - state.commit() - - executor = _make_executor("test-exec", capture) - input_data = json.dumps({ - "message": "test", - "shared_state_snapshot": {}, - "source_executor_ids": [SOURCE_ORCHESTRATOR], - "host_context": {"instance_id": "abc123", "workflow_name": "content_moderation"}, - }) - json.loads(execute_workflow_activity(executor, input_data)) - - assert captured["metadata"] == {"instance_id": "abc123", "workflow_name": "content_moderation"} - - def test_absent_host_context_yields_none(self) -> None: - """When the input omits ``host_context``, ``host_metadata`` is ``None`` (in-process parity).""" - captured: dict[str, Any] = {} - - async def capture(message: Any, source_executor_ids: Any, state: Any, runner_context: Any) -> None: - captured["metadata"] = runner_context.host_metadata - state.commit() - - executor = _make_executor("test-exec", capture) - _run(executor, {}) - - assert captured["metadata"] is None - - -if __name__ == "__main__": - import pytest - - pytest.main([__file__, "-v", "--tb=short"]) diff --git a/python/packages/durabletask/tests/test_workflow_client.py b/python/packages/durabletask/tests/test_workflow_client.py deleted file mode 100644 index 6da63b89101..00000000000 --- a/python/packages/durabletask/tests/test_workflow_client.py +++ /dev/null @@ -1,692 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Unit tests for DurableWorkflowClient. - -Covers starting workflows, awaiting output (including error/timeout paths), -parsing pending human-in-the-loop (HITL) requests from custom status, and -sanitizing HITL responses before delivery. -""" - -import json -from dataclasses import dataclass -from unittest.mock import Mock - -import pytest -from agent_framework import WorkflowEvent - -from agent_framework_durabletask import DurableWorkflowClient -from agent_framework_durabletask._workflows.naming import workflow_orchestrator_name -from agent_framework_durabletask._workflows.serialization import serialize_value, serialize_workflow_event - - -@dataclass -class _Receipt: - """Module-level dataclass so it is picklable by serialize_value.""" - - order_id: int - total: float - - -@pytest.fixture -def mock_client() -> Mock: - """Create a mock TaskHubGrpcClient.""" - return Mock() - - -@pytest.fixture -def workflow_client(mock_client: Mock) -> DurableWorkflowClient: - """Create a DurableWorkflowClient wrapping the mock client.""" - return DurableWorkflowClient(mock_client) - - -class TestStartWorkflow: - """Test starting workflow orchestrations.""" - - def test_start_workflow_schedules_orchestrator( - self, workflow_client: DurableWorkflowClient, mock_client: Mock - ) -> None: - """start_workflow schedules the per-workflow orchestration by name.""" - mock_client.schedule_new_orchestration.return_value = "instance-1" - - result = workflow_client.start_workflow(input="hello", workflow_name="orders") - - assert result == "instance-1" - mock_client.schedule_new_orchestration.assert_called_once_with( - workflow_orchestrator_name("orders"), input="hello", instance_id=None - ) - - def test_start_workflow_passes_non_string_input_unchanged( - self, workflow_client: DurableWorkflowClient, mock_client: Mock - ) -> None: - """Non-string payloads are forwarded as-is (no string coercion).""" - mock_client.schedule_new_orchestration.return_value = "instance-2" - payload = {"order_id": 42, "items": ["a", "b"]} - - workflow_client.start_workflow(input=payload, workflow_name="orders") - - _, kwargs = mock_client.schedule_new_orchestration.call_args - assert kwargs["input"] == payload - - def test_start_workflow_strips_forged_subworkflow_envelope( - self, workflow_client: DurableWorkflowClient, mock_client: Mock - ) -> None: - """Reserved sub-workflow envelope keys in client input are stripped at the boundary. - - Only an internal child dispatch may carry these keys; if untrusted input could, - it would smuggle a payload onto the orchestrator's trusted (pickle) path. - """ - mock_client.schedule_new_orchestration.return_value = "i" - forged = {"__subworkflow_input__": {"__pickled__": "evil", "__type__": "x"}, "real": 1} - - workflow_client.start_workflow(input=forged, workflow_name="orders") - - _, kwargs = mock_client.schedule_new_orchestration.call_args - assert kwargs["input"] == {"real": 1} - assert "__subworkflow_input__" not in kwargs["input"] - - def test_start_workflow_forwards_instance_id( - self, workflow_client: DurableWorkflowClient, mock_client: Mock - ) -> None: - """An explicit instance id is forwarded to the underlying client.""" - mock_client.schedule_new_orchestration.return_value = "explicit-id" - - workflow_client.start_workflow(input="x", workflow_name="orders", instance_id="explicit-id") - - _, kwargs = mock_client.schedule_new_orchestration.call_args - assert kwargs["instance_id"] == "explicit-id" - - -class TestWorkflowNameTargeting: - """Resolving the target workflow name from a default or per-call value.""" - - def test_uses_constructor_default(self, mock_client: Mock) -> None: - """A client default workflow name is used when none is passed per call.""" - client = DurableWorkflowClient(mock_client, workflow_name="billing") - mock_client.schedule_new_orchestration.return_value = "i" - - client.start_workflow(input="x") - - mock_client.schedule_new_orchestration.assert_called_once_with( - workflow_orchestrator_name("billing"), input="x", instance_id=None - ) - - def test_per_call_overrides_default(self, mock_client: Mock) -> None: - """A per-call workflow name overrides the constructor default.""" - client = DurableWorkflowClient(mock_client, workflow_name="billing") - mock_client.schedule_new_orchestration.return_value = "i" - - client.start_workflow(input="x", workflow_name="orders") - - mock_client.schedule_new_orchestration.assert_called_once_with( - workflow_orchestrator_name("orders"), input="x", instance_id=None - ) - - def test_raises_when_no_name_resolvable(self, workflow_client: DurableWorkflowClient) -> None: - """With no default and no per-call name, starting raises a clear error.""" - with pytest.raises(ValueError, match="No workflow name"): - workflow_client.start_workflow(input="x") - - -class TestOwnershipValidation: - """Opt-in validation that an instance belongs to the targeted workflow.""" - - def test_runtime_status_returns_none_for_foreign_instance(self, mock_client: Mock) -> None: - """A status query scoped to a workflow returns None for a foreign instance.""" - client = DurableWorkflowClient(mock_client, workflow_name="orders") - state = Mock() - state.name = workflow_orchestrator_name("billing") # different workflow - state.runtime_status.name = "RUNNING" - mock_client.get_orchestration_state.return_value = state - - assert client.get_runtime_status("instance-1") is None - - def test_runtime_status_returns_status_for_owned_instance(self, mock_client: Mock) -> None: - """A status query returns the status for an instance of the targeted workflow.""" - client = DurableWorkflowClient(mock_client, workflow_name="orders") - state = Mock() - state.name = workflow_orchestrator_name("orders") - state.runtime_status.name = "RUNNING" - mock_client.get_orchestration_state.return_value = state - - assert client.get_runtime_status("instance-1") == "RUNNING" - - def test_pending_hitl_empty_for_foreign_instance(self, mock_client: Mock) -> None: - """Pending HITL is empty for an instance of a different workflow.""" - client = DurableWorkflowClient(mock_client, workflow_name="orders") - state = Mock() - state.name = workflow_orchestrator_name("billing") - state.serialized_custom_status = json.dumps({"pending_requests": {"req-1": {"source_executor_id": "x"}}}) - mock_client.get_orchestration_state.return_value = state - - assert client.get_pending_hitl_requests("instance-1") == [] - - def test_send_hitl_rejects_foreign_instance(self, mock_client: Mock) -> None: - """Sending a HITL response to a foreign instance raises and does not deliver.""" - client = DurableWorkflowClient(mock_client, workflow_name="orders") - state = Mock() - state.name = workflow_orchestrator_name("billing") - mock_client.get_orchestration_state.return_value = state - - with pytest.raises(ValueError, match="does not belong"): - client.send_hitl_response("instance-1", "req-1", {"approved": True}) - - mock_client.raise_orchestration_event.assert_not_called() - - def test_send_hitl_allows_owned_instance(self, mock_client: Mock) -> None: - """Sending a HITL response to an owned instance delivers the event.""" - client = DurableWorkflowClient(mock_client, workflow_name="orders") - state = Mock() - state.name = workflow_orchestrator_name("orders") - mock_client.get_orchestration_state.return_value = state - - client.send_hitl_response("instance-1", "req-1", {"approved": True}) - - mock_client.raise_orchestration_event.assert_called_once() - - -class TestAwaitWorkflowOutput: - """Test awaiting workflow completion and output.""" - - def test_returns_deserialized_output_on_completion( - self, workflow_client: DurableWorkflowClient, mock_client: Mock - ) -> None: - """A COMPLETED workflow returns its deserialized output.""" - metadata = Mock() - metadata.runtime_status.name = "COMPLETED" - metadata.serialized_output = json.dumps(["result"]) - mock_client.wait_for_orchestration_completion.return_value = metadata - - output = workflow_client.await_workflow_output("instance-1") - - assert output == ["result"] - - def test_returns_none_when_no_output(self, workflow_client: DurableWorkflowClient, mock_client: Mock) -> None: - """A COMPLETED workflow with no output returns None.""" - metadata = Mock() - metadata.runtime_status.name = "COMPLETED" - metadata.serialized_output = None - mock_client.wait_for_orchestration_completion.return_value = metadata - - assert workflow_client.await_workflow_output("instance-1") is None - - def test_reconstructs_typed_outputs(self, workflow_client: DurableWorkflowClient, mock_client: Mock) -> None: - """Typed outputs encoded by the activity come back as objects, not marker dicts.""" - receipt = _Receipt(order_id=7, total=19.99) - # The shared activity stores each yielded output via serialize_value(), so a - # typed object is persisted as a checkpoint-marker dict. - metadata = Mock() - metadata.runtime_status.name = "COMPLETED" - metadata.serialized_output = json.dumps([serialize_value(receipt)]) - mock_client.wait_for_orchestration_completion.return_value = metadata - - output = workflow_client.await_workflow_output("instance-1") - - assert output == [receipt] - assert isinstance(output[0], _Receipt) - - def test_raises_timeout_when_not_completed(self, workflow_client: DurableWorkflowClient, mock_client: Mock) -> None: - """A None metadata (no completion) raises TimeoutError.""" - mock_client.wait_for_orchestration_completion.return_value = None - - with pytest.raises(TimeoutError, match="did not complete"): - workflow_client.await_workflow_output("instance-1", timeout_seconds=5) - - def test_raises_runtime_error_on_failed_status( - self, workflow_client: DurableWorkflowClient, mock_client: Mock - ) -> None: - """A non-COMPLETED status raises RuntimeError.""" - metadata = Mock() - metadata.runtime_status.name = "FAILED" - metadata.serialized_output = "boom" - mock_client.wait_for_orchestration_completion.return_value = metadata - - with pytest.raises(RuntimeError, match="status FAILED"): - workflow_client.await_workflow_output("instance-1") - - -class TestGetRuntimeStatus: - """Test reading the workflow's runtime status.""" - - def test_returns_status_name(self, workflow_client: DurableWorkflowClient, mock_client: Mock) -> None: - """The runtime status name is returned when state is available.""" - state = Mock() - state.runtime_status.name = "RUNNING" - mock_client.get_orchestration_state.return_value = state - - assert workflow_client.get_runtime_status("instance-1") == "RUNNING" - - def test_returns_none_when_no_state(self, workflow_client: DurableWorkflowClient, mock_client: Mock) -> None: - """No orchestration state yields None (status unknown).""" - mock_client.get_orchestration_state.return_value = None - - assert workflow_client.get_runtime_status("instance-1") is None - - -class TestGetPendingHitlRequests: - """Test parsing pending HITL requests from custom status.""" - - def _state_with_status(self, status: object) -> Mock: - state = Mock() - state.serialized_custom_status = json.dumps(status) if status is not None else None - return state - - def test_returns_empty_when_no_state(self, workflow_client: DurableWorkflowClient, mock_client: Mock) -> None: - """No orchestration state yields an empty list.""" - mock_client.get_orchestration_state.return_value = None - - assert workflow_client.get_pending_hitl_requests("instance-1") == [] - - def test_returns_empty_when_status_blank(self, workflow_client: DurableWorkflowClient, mock_client: Mock) -> None: - """A blank custom status yields an empty list.""" - state = Mock() - state.serialized_custom_status = "" - mock_client.get_orchestration_state.return_value = state - - assert workflow_client.get_pending_hitl_requests("instance-1") == [] - - def test_returns_empty_on_invalid_json(self, workflow_client: DurableWorkflowClient, mock_client: Mock) -> None: - """Malformed custom status JSON yields an empty list.""" - state = Mock() - state.serialized_custom_status = "{not-json" - mock_client.get_orchestration_state.return_value = state - - assert workflow_client.get_pending_hitl_requests("instance-1") == [] - - def test_parses_pending_requests(self, workflow_client: DurableWorkflowClient, mock_client: Mock) -> None: - """Pending requests are normalized into the documented shape.""" - status = { - "pending_requests": { - "req-1": { - "request_id": "req-1", - "source_executor_id": "approver", - "data": {"prompt": "approve?"}, - "request_type": "ApprovalRequest", - "response_type": "ApprovalResponse", - } - } - } - mock_client.get_orchestration_state.return_value = self._state_with_status(status) - - requests = workflow_client.get_pending_hitl_requests("instance-1") - - assert requests == [ - { - "request_id": "req-1", - "source_executor_id": "approver", - "data": {"prompt": "approve?"}, - "request_type": "ApprovalRequest", - "response_type": "ApprovalResponse", - } - ] - - def test_falls_back_to_dict_key_for_request_id( - self, workflow_client: DurableWorkflowClient, mock_client: Mock - ) -> None: - """When a request omits request_id, the dict key is used.""" - status = {"pending_requests": {"req-key": {"source_executor_id": "x"}}} - mock_client.get_orchestration_state.return_value = self._state_with_status(status) - - requests = workflow_client.get_pending_hitl_requests("instance-1") - - assert requests[0]["request_id"] == "req-key" - - def test_ignores_non_dict_entries(self, workflow_client: DurableWorkflowClient, mock_client: Mock) -> None: - """Non-dict request entries are skipped.""" - status = {"pending_requests": {"req-1": "not-a-dict"}} - mock_client.get_orchestration_state.return_value = self._state_with_status(status) - - assert workflow_client.get_pending_hitl_requests("instance-1") == [] - - def test_returns_empty_when_pending_not_dict( - self, workflow_client: DurableWorkflowClient, mock_client: Mock - ) -> None: - """A non-dict pending_requests field yields an empty list.""" - status = {"pending_requests": ["unexpected"]} - mock_client.get_orchestration_state.return_value = self._state_with_status(status) - - assert workflow_client.get_pending_hitl_requests("instance-1") == [] - - -class TestSendHitlResponse: - """Test delivering HITL responses.""" - - def test_raises_orchestration_event_with_request_id( - self, workflow_client: DurableWorkflowClient, mock_client: Mock - ) -> None: - """The response is delivered as an external event named by request id.""" - workflow_client.send_hitl_response("instance-1", "req-1", {"approved": True}) - - mock_client.raise_orchestration_event.assert_called_once() - _, kwargs = mock_client.raise_orchestration_event.call_args - assert kwargs["event_name"] == "req-1" - assert kwargs["data"] == {"approved": True} - - def test_strips_pickle_markers_before_delivery( - self, workflow_client: DurableWorkflowClient, mock_client: Mock - ) -> None: - """A crafted pickle-marker payload is neutralized before reaching the worker. - - The HITL response is sent to the worker which deserializes it, so a payload - carrying the checkpoint ``__pickled__`` marker must be stripped client-side - (regression guard for the strip_pickle_markers call in send_hitl_response). - """ - malicious = {"__pickled__": "", "approved": True} - - workflow_client.send_hitl_response("instance-1", "req-1", malicious) - - _, kwargs = mock_client.raise_orchestration_event.call_args - # The whole marker-bearing dict is neutralized (replaced with None) rather - # than forwarded, so it can never reach pickle.loads on the worker. - assert kwargs["data"] is None - - -class TestStreamWorkflow: - """Test streaming typed workflow events by polling custom status.""" - - def _state(self, *, status: str, events: list[dict] | None = None) -> Mock: - state = Mock() - state.runtime_status.name = status - if events is None: - state.serialized_custom_status = None - else: - state.serialized_custom_status = json.dumps({"state": "running", "events": events}) - return state - - async def test_streams_events_in_order_until_terminal( - self, workflow_client: DurableWorkflowClient, mock_client: Mock - ) -> None: - """Events accrue across polls and stream in order; streaming ends at a terminal state.""" - # Each poll returns a growing accumulated event list, then a terminal status. - mock_client.get_orchestration_state.side_effect = [ - self._state(status="RUNNING", events=[{"type": "executor_invoked", "executor_id": "a"}]), - self._state( - status="RUNNING", - events=[ - {"type": "executor_invoked", "executor_id": "a"}, - {"type": "executor_completed", "executor_id": "a"}, - ], - ), - self._state( - status="COMPLETED", - events=[ - {"type": "executor_invoked", "executor_id": "a"}, - {"type": "executor_completed", "executor_id": "a"}, - ], - ), - ] - - seen = [event async for event in workflow_client.stream_workflow("instance-1", poll_interval_seconds=0)] - - # Each accumulated event is yielded exactly once, in order, as a typed event. - assert all(isinstance(e, WorkflowEvent) for e in seen) - assert [e.type for e in seen] == ["executor_invoked", "executor_completed"] - assert [e.executor_id for e in seen] == ["a", "a"] - - async def test_terminal_with_no_status_yields_nothing( - self, workflow_client: DurableWorkflowClient, mock_client: Mock - ) -> None: - """A workflow that is already terminal with no custom status streams no events.""" - mock_client.get_orchestration_state.return_value = self._state(status="COMPLETED") - - seen = [event async for event in workflow_client.stream_workflow("instance-1", poll_interval_seconds=0)] - - assert seen == [] - - async def test_streams_typed_event_data_roundtrip( - self, workflow_client: DurableWorkflowClient, mock_client: Mock - ) -> None: - """An output event's data is reconstructed into its original typed object.""" - receipt = _Receipt(order_id=7, total=42.5) - serialized_event = serialize_workflow_event(WorkflowEvent("output", data=receipt, executor_id="processor")) - mock_client.get_orchestration_state.side_effect = [ - self._state(status="RUNNING", events=[serialized_event]), - self._state(status="COMPLETED", events=[serialized_event]), - ] - - seen = [event async for event in workflow_client.stream_workflow("instance-1", poll_interval_seconds=0)] - - assert len(seen) == 1 - assert isinstance(seen[0], WorkflowEvent) - assert seen[0].type == "output" - assert seen[0].executor_id == "processor" - assert seen[0].data == receipt - - -class TestRunWorkflow: - """Test the async run_workflow convenience (start + optional wait).""" - - async def test_waits_and_returns_output_by_default( - self, workflow_client: DurableWorkflowClient, mock_client: Mock - ) -> None: - """By default run_workflow starts the workflow and returns its deserialized output.""" - mock_client.schedule_new_orchestration.return_value = "instance-1" - metadata = Mock() - metadata.name = workflow_orchestrator_name("orders") - metadata.runtime_status.name = "COMPLETED" - metadata.serialized_output = json.dumps(["done"]) - mock_client.wait_for_orchestration_completion.return_value = metadata - - result = await workflow_client.run_workflow(input="hello", workflow_name="orders") - - assert result == ["done"] - mock_client.schedule_new_orchestration.assert_called_once() - mock_client.wait_for_orchestration_completion.assert_called_once() - - async def test_no_wait_returns_instance_id_without_awaiting( - self, workflow_client: DurableWorkflowClient, mock_client: Mock - ) -> None: - """With wait=False, run_workflow returns the instance id and does not await completion.""" - mock_client.schedule_new_orchestration.return_value = "instance-2" - - result = await workflow_client.run_workflow(input="hello", workflow_name="orders", wait=False) - - assert result == "instance-2" - mock_client.wait_for_orchestration_completion.assert_not_called() - - -class TestSubworkflowHitl: - """Sub-workflow HITL: qualified request ids in/out (B2 single-surface addressing).""" - - @staticmethod - def _states(mock_client: Mock, by_instance: dict[str, dict | None]) -> None: - """Wire get_orchestration_state to return a state per instance id. - - Each value is the custom-status dict for that instance (or None for no - status). ``name`` is unset so ownership validation is skipped (these tests - construct the client without a workflow_name default). - """ - - def _get_state(instance_id: str) -> Mock | None: - if instance_id not in by_instance: - return None - status = by_instance[instance_id] - state = Mock() - state.serialized_custom_status = json.dumps(status) if status is not None else None - return state - - mock_client.get_orchestration_state.side_effect = _get_state - - def test_collects_nested_request_with_qualified_id( - self, workflow_client: DurableWorkflowClient, mock_client: Mock - ) -> None: - """A request pending in a child sub-workflow surfaces with an {executor}~{ordinal}~{id} id.""" - self._states( - mock_client, - { - "parent": {"state": "running", "subworkflows": {"sub": ["child-1"]}}, - "child-1": { - "state": "waiting_for_human_input", - "pending_requests": {"req-9": {"request_id": "req-9", "source_executor_id": "inner_node"}}, - }, - }, - ) - - requests = workflow_client.get_pending_hitl_requests("parent") - - assert len(requests) == 1 - assert requests[0]["request_id"] == "sub~0~req-9" - assert requests[0]["source_executor_id"] == "inner_node" - - def test_collects_parent_and_nested_requests_together( - self, workflow_client: DurableWorkflowClient, mock_client: Mock - ) -> None: - """Top-level and nested pending requests are both returned (nested qualified).""" - self._states( - mock_client, - { - "parent": { - "state": "waiting_for_human_input", - "pending_requests": {"top-1": {"request_id": "top-1", "source_executor_id": "outer_node"}}, - "subworkflows": {"sub": ["child-1"]}, - }, - "child-1": { - "state": "waiting_for_human_input", - "pending_requests": {"inner-1": {"request_id": "inner-1", "source_executor_id": "inner_node"}}, - }, - }, - ) - - ids = {r["request_id"] for r in workflow_client.get_pending_hitl_requests("parent")} - - assert ids == {"top-1", "sub~0~inner-1"} - - def test_collects_deeply_nested_request_with_full_path( - self, workflow_client: DurableWorkflowClient, mock_client: Mock - ) -> None: - """Two levels of nesting accumulate a full {a}~{i}~{b}~{j}~{id} path.""" - self._states( - mock_client, - { - "parent": {"state": "running", "subworkflows": {"mid": ["child-1"]}}, - "child-1": {"state": "running", "subworkflows": {"leaf": ["child-2"]}}, - "child-2": { - "state": "waiting_for_human_input", - "pending_requests": {"deep": {"request_id": "deep", "source_executor_id": "leaf_node"}}, - }, - }, - ) - - requests = workflow_client.get_pending_hitl_requests("parent") - - assert [r["request_id"] for r in requests] == ["mid~0~leaf~0~deep"] - - def test_send_qualified_response_routes_to_child_instance( - self, workflow_client: DurableWorkflowClient, mock_client: Mock - ) -> None: - """A qualified id resolves to the owning child instance and bare request id.""" - self._states( - mock_client, - {"parent": {"state": "running", "subworkflows": {"sub": ["child-1"]}}}, - ) - - workflow_client.send_hitl_response("parent", "sub~0~req-9", {"approved": True}) - - mock_client.raise_orchestration_event.assert_called_once() - args, kwargs = mock_client.raise_orchestration_event.call_args - assert args[0] == "child-1" - assert kwargs["event_name"] == "req-9" - assert kwargs["data"] == {"approved": True} - - def test_send_deeply_qualified_response_routes_to_leaf( - self, workflow_client: DurableWorkflowClient, mock_client: Mock - ) -> None: - """A two-level qualified id lands on the leaf child with the bare id.""" - self._states( - mock_client, - { - "parent": {"state": "running", "subworkflows": {"mid": ["child-1"]}}, - "child-1": {"state": "running", "subworkflows": {"leaf": ["child-2"]}}, - }, - ) - - workflow_client.send_hitl_response("parent", "mid~0~leaf~0~deep", {"ok": 1}) - - args, kwargs = mock_client.raise_orchestration_event.call_args - assert args[0] == "child-2" - assert kwargs["event_name"] == "deep" - - def test_send_qualified_response_unknown_subworkflow_raises( - self, workflow_client: DurableWorkflowClient, mock_client: Mock - ) -> None: - """A qualified id for an inactive sub-workflow raises and delivers nothing.""" - self._states(mock_client, {"parent": {"state": "running"}}) # no subworkflows map - - with pytest.raises(ValueError, match="No active sub-workflow"): - workflow_client.send_hitl_response("parent", "sub~0~req-9", {"approved": True}) - - mock_client.raise_orchestration_event.assert_not_called() - - def test_unqualified_response_still_targets_named_instance( - self, workflow_client: DurableWorkflowClient, mock_client: Mock - ) -> None: - """A plain (unqualified) request id targets the given instance directly.""" - self._states(mock_client, {"parent": {"state": "waiting_for_human_input"}}) - - workflow_client.send_hitl_response("parent", "req-1", {"approved": True}) - - args, kwargs = mock_client.raise_orchestration_event.call_args - assert args[0] == "parent" - assert kwargs["event_name"] == "req-1" - - def test_multiple_children_of_one_executor_stay_addressable( - self, workflow_client: DurableWorkflowClient, mock_client: Mock - ) -> None: - """Two children dispatched by one node are qualified by ordinal, not collapsed.""" - self._states( - mock_client, - { - "parent": {"state": "running", "subworkflows": {"sub": ["child-1", "child-2"]}}, - "child-1": { - "state": "waiting_for_human_input", - "pending_requests": {"r1": {"request_id": "r1", "source_executor_id": "a"}}, - }, - "child-2": { - "state": "waiting_for_human_input", - "pending_requests": {"r2": {"request_id": "r2", "source_executor_id": "b"}}, - }, - }, - ) - - ids = {r["request_id"] for r in workflow_client.get_pending_hitl_requests("parent")} - assert ids == {"sub~0~r1", "sub~1~r2"} - - # The second child (ordinal 1) is reachable, not shadowed by the first. - workflow_client.send_hitl_response("parent", "sub~1~r2", {"ok": 1}) - args, kwargs = mock_client.raise_orchestration_event.call_args - assert args[0] == "child-2" - assert kwargs["event_name"] == "r2" - - def test_nested_leaf_request_id_with_double_colon_round_trips( - self, workflow_client: DurableWorkflowClient, mock_client: Mock - ) -> None: - """A functional sub-workflow's ``auto::N`` leaf id survives qualification and routing.""" - self._states( - mock_client, - { - "parent": {"state": "running", "subworkflows": {"sub": ["child-1"]}}, - "child-1": { - "state": "waiting_for_human_input", - "pending_requests": {"auto::0": {"request_id": "auto::0", "source_executor_id": "fn"}}, - }, - }, - ) - - requests = workflow_client.get_pending_hitl_requests("parent") - assert [r["request_id"] for r in requests] == ["sub~0~auto::0"] - - workflow_client.send_hitl_response("parent", "sub~0~auto::0", {"ok": 1}) - args, kwargs = mock_client.raise_orchestration_event.call_args - assert args[0] == "child-1" - assert kwargs["event_name"] == "auto::0" - - def test_top_level_auto_request_id_is_not_treated_as_nested( - self, workflow_client: DurableWorkflowClient, mock_client: Mock - ) -> None: - """A top-level ``auto::N`` id (contains ``::`` but no ``~``) routes to the instance itself.""" - self._states(mock_client, {"parent": {"state": "waiting_for_human_input"}}) - - workflow_client.send_hitl_response("parent", "auto::0", {"approved": True}) - - args, kwargs = mock_client.raise_orchestration_event.call_args - assert args[0] == "parent" - assert kwargs["event_name"] == "auto::0" diff --git a/python/packages/durabletask/tests/test_workflow_dt_context.py b/python/packages/durabletask/tests/test_workflow_dt_context.py deleted file mode 100644 index 105945e6d4a..00000000000 --- a/python/packages/durabletask/tests/test_workflow_dt_context.py +++ /dev/null @@ -1,121 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Unit tests for the standalone durabletask workflow-context adapter.""" - -# pyright: reportPrivateUsage=false - -from __future__ import annotations - -from datetime import datetime, timezone -from typing import Any -from unittest.mock import Mock - -import pytest - -from agent_framework_durabletask._workflows.dt_context import DurableTaskWorkflowContext - - -class _FakeDurableAIAgent: - def __init__(self, executor: Any, name: str) -> None: - self.executor = executor - self.name = name - - def run(self, message: str, *, session: Any) -> dict[str, Any]: - return {"message": message, "session": session, "executor": self.executor, "name": self.name} - - -class _FakeTask: - def __init__(self, result: Any) -> None: - self._result = result - - def get_result(self) -> Any: - return self._result - - -class TestDurableTaskWorkflowContext: - """Behavior of the durabletask-host workflow-context adapter.""" - - @pytest.fixture - def orchestration_context(self) -> Mock: - context = Mock() - context.instance_id = "instance-456" - context.is_replaying = False - context.current_utc_datetime = datetime(2025, 2, 3, 4, 5, 6, tzinfo=timezone.utc) - context.call_activity.return_value = "activity-task" - context.call_sub_orchestrator.return_value = "sub-task" - context.wait_for_external_event.return_value = "event-task" - context.create_timer.return_value = "timer-task" - context.new_uuid.return_value = "uuid-456" - return context - - def test_exposes_basic_context_properties(self, orchestration_context: Mock) -> None: - workflow_context = DurableTaskWorkflowContext(orchestration_context) - - assert workflow_context.instance_id == "instance-456" - assert workflow_context.is_replaying is False - assert workflow_context.supports_event_streaming is True - assert workflow_context.current_utc_datetime == orchestration_context.current_utc_datetime - - def test_prepare_agent_task_wraps_session_and_executor( - self, - monkeypatch: pytest.MonkeyPatch, - orchestration_context: Mock, - ) -> None: - monkeypatch.setattr("agent_framework_durabletask._workflows.dt_context.DurableAIAgent", _FakeDurableAIAgent) - - workflow_context = DurableTaskWorkflowContext(orchestration_context) - result = workflow_context.prepare_agent_task("reviewer", "please approve", "orch-12") - - assert result["message"] == "please approve" - assert result["name"] == "reviewer" - assert result["session"].durable_session_id.name == "reviewer" - assert result["session"].durable_session_id.key == "orch-12" - assert result["executor"] is workflow_context._executor - - def test_delegates_activity_and_orchestrator_primitives( - self, - monkeypatch: pytest.MonkeyPatch, - orchestration_context: Mock, - ) -> None: - monkeypatch.setattr("agent_framework_durabletask._workflows.dt_context.when_all", lambda tasks: ("all", tasks)) - monkeypatch.setattr("agent_framework_durabletask._workflows.dt_context.when_any", lambda tasks: ("any", tasks)) - - workflow_context = DurableTaskWorkflowContext(orchestration_context) - - assert workflow_context.prepare_activity_task("activity-name", '{"payload": 1}') == "activity-task" - orchestration_context.call_activity.assert_called_once_with("activity-name", input='{"payload": 1}') - - assert workflow_context.call_sub_orchestrator("child", {"x": 1}, instance_id="child-2") == "sub-task" - orchestration_context.call_sub_orchestrator.assert_called_once_with( - "child", input={"x": 1}, instance_id="child-2" - ) - - assert workflow_context.task_all(["a", "b"]) == ("all", ["a", "b"]) - assert workflow_context.task_any(["a", "b"]) == ("any", ["a", "b"]) - - assert workflow_context.wait_for_external_event("approval") == "event-task" - orchestration_context.wait_for_external_event.assert_called_once_with("approval") - - assert workflow_context.create_timer(orchestration_context.current_utc_datetime) == "timer-task" - orchestration_context.create_timer.assert_called_once_with(orchestration_context.current_utc_datetime) - - def test_status_uuid_and_task_helpers_delegate( - self, - monkeypatch: pytest.MonkeyPatch, - orchestration_context: Mock, - ) -> None: - monkeypatch.setattr("agent_framework_durabletask._workflows.dt_context.Task", _FakeTask) - workflow_context = DurableTaskWorkflowContext(orchestration_context) - - workflow_context.set_custom_status({"state": "running"}) - orchestration_context.set_custom_status.assert_called_once_with({"state": "running"}) - assert workflow_context.new_uuid() == "uuid-456" - - cancellable = Mock() - workflow_context.cancel_task(cancellable) - cancellable.cancel.assert_called_once_with() - - workflow_context.cancel_task(object()) - - assert workflow_context.get_task_result(_FakeTask({"answer": 42})) == {"answer": 42} - assert workflow_context.get_task_result(Mock(result="fallback")) == "fallback" diff --git a/python/packages/durabletask/tests/test_workflow_input_coercion.py b/python/packages/durabletask/tests/test_workflow_input_coercion.py deleted file mode 100644 index 2b785a145d3..00000000000 --- a/python/packages/durabletask/tests/test_workflow_input_coercion.py +++ /dev/null @@ -1,135 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Unit tests for workflow initial-input coercion (`_coerce_initial_input`). - -A durable workflow runs as a durable orchestration, so its initial payload -arrives as plain JSON (no type markers). The shared engine reconstructs the -start executor's declared input type from that JSON, mirroring in-process -delivery. These tests pin that behavior across the relevant start-executor -shapes. -""" - -import json -from dataclasses import dataclass -from unittest.mock import Mock - -from agent_framework import AgentExecutor, Executor, WorkflowContext, handler -from pydantic import BaseModel - -from agent_framework_durabletask._workflows.orchestrator import _coerce_initial_input - - -@dataclass -class _Submission: - content_id: str - title: str - - -class _SubmissionModel(BaseModel): - content_id: str - title: str - - -class _StrStart(Executor): - def __init__(self) -> None: - super().__init__(id="str_start") - - @handler - async def run(self, message: str, ctx: WorkflowContext) -> None: # pragma: no cover - never invoked - ... - - -class _DataclassStart(Executor): - def __init__(self) -> None: - super().__init__(id="dc_start") - - @handler - async def run(self, message: _Submission, ctx: WorkflowContext) -> None: # pragma: no cover - never invoked - ... - - -class _PydanticStart(Executor): - def __init__(self) -> None: - super().__init__(id="pyd_start") - - @handler - async def run(self, message: _SubmissionModel, ctx: WorkflowContext) -> None: # pragma: no cover - never invoked - ... - - -def _workflow_with(executor: Executor | Mock) -> Mock: - workflow = Mock() - workflow.executors = {executor.id: executor} - workflow.start_executor_id = executor.id - return workflow - - -class TestCoerceInitialInput: - """Test reconstruction of the initial workflow input by start-executor type.""" - - def test_str_start_passes_string_through(self) -> None: - workflow = _workflow_with(_StrStart()) - - assert _coerce_initial_input(workflow, "hello world") == "hello world" - - def test_dataclass_start_reconstructs_from_dict(self) -> None: - workflow = _workflow_with(_DataclassStart()) - - result = _coerce_initial_input(workflow, {"content_id": "x", "title": "T"}) - - assert isinstance(result, _Submission) - assert result.content_id == "x" - assert result.title == "T" - - def test_pydantic_start_reconstructs_from_dict(self) -> None: - workflow = _workflow_with(_PydanticStart()) - - result = _coerce_initial_input(workflow, {"content_id": "x", "title": "T"}) - - assert isinstance(result, _SubmissionModel) - assert result.content_id == "x" - - def test_str_start_leaves_dict_unchanged(self) -> None: - """A str-typed start executor declares text; a dict is not coerced to str.""" - workflow = _workflow_with(_StrStart()) - payload = {"content_id": "x"} - - assert _coerce_initial_input(workflow, payload) == payload - - def test_agent_start_passes_string_through(self) -> None: - agent_executor = Mock(spec=AgentExecutor) - agent_executor.id = "agent" - workflow = _workflow_with(agent_executor) - - assert _coerce_initial_input(workflow, "draft this email") == "draft this email" - - def test_agent_start_stringifies_dict(self) -> None: - """Agents only consume text, so a structured payload is serialized to text.""" - agent_executor = Mock(spec=AgentExecutor) - agent_executor.id = "agent" - workflow = _workflow_with(agent_executor) - - result = _coerce_initial_input(workflow, {"email": "hi"}) - - assert result == json.dumps({"email": "hi"}) - - def test_missing_start_executor_passes_through(self) -> None: - workflow = Mock() - workflow.executors = {} - workflow.start_executor_id = "missing" - payload = {"a": 1} - - assert _coerce_initial_input(workflow, payload) == payload - - def test_pickle_marker_injection_is_neutralized(self) -> None: - """A crafted pickle-marker payload is stripped before reconstruction (no pickle RCE). - - The initial workflow input is untrusted, so a dict carrying the checkpoint - ``__pickled__`` marker must be neutralized rather than flowing into - ``deserialize_value`` (which would ``pickle.loads`` it). - """ - workflow = _workflow_with(_DataclassStart()) - malicious = {"__pickled__": "", "content_id": "x", "title": "T"} - - # The marker-bearing dict is replaced with None, never unpickled or reconstructed. - assert _coerce_initial_input(workflow, malicious) is None diff --git a/python/packages/durabletask/tests/test_workflow_naming.py b/python/packages/durabletask/tests/test_workflow_naming.py deleted file mode 100644 index de3124edd49..00000000000 --- a/python/packages/durabletask/tests/test_workflow_naming.py +++ /dev/null @@ -1,172 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Unit tests for the durable workflow naming helpers. - -These helpers derive the **stable** durable names a hosted workflow registers -under. Stability matters: durable replay resumes an in-flight orchestration only -if the orchestration name still resolves, so the round-trip -(``workflow_orchestrator_name`` ↔ ``workflow_name_from_orchestrator``) and the -validation rules (reject empty / malformed / auto-generated names) are the -contract the multi-workflow hosting builds on. -""" - -import uuid - -import pytest - -from agent_framework_durabletask import ( - DURABLE_NAME_PREFIX, - is_auto_generated_workflow_name, - validate_executor_id, - validate_workflow_name, - workflow_name_from_orchestrator, - workflow_orchestrator_name, -) -from agent_framework_durabletask._workflows.naming import ( - MAX_EXECUTOR_ID_LENGTH, - SUBWORKFLOW_REQUEST_SEPARATOR, - qualify_subworkflow_request_id, - split_subworkflow_request_id, -) - - -class TestWorkflowOrchestratorName: - """``workflow_orchestrator_name`` derives ``dafx-{name}`` for valid names.""" - - def test_prepends_prefix(self) -> None: - assert workflow_orchestrator_name("orders") == "dafx-orders" - - def test_uses_shared_prefix_constant(self) -> None: - assert workflow_orchestrator_name("orders") == f"{DURABLE_NAME_PREFIX}orders" - - @pytest.mark.parametrize("name", ["a", "Order_Processor", "spam-detection", "wf123"]) - def test_accepts_valid_names(self, name: str) -> None: - assert workflow_orchestrator_name(name) == f"dafx-{name}" - - @pytest.mark.parametrize("name", ["", "1abc", "has space", "bad/char", "emoji😀"]) - def test_rejects_invalid_names(self, name: str) -> None: - with pytest.raises(ValueError): - workflow_orchestrator_name(name) - - -class TestWorkflowNameRoundTrip: - """``workflow_name_from_orchestrator`` inverts ``workflow_orchestrator_name``.""" - - @pytest.mark.parametrize("name", ["orders", "Order_Processor", "spam-detection", "wf123"]) - def test_round_trips(self, name: str) -> None: - orchestrator = workflow_orchestrator_name(name) - assert workflow_name_from_orchestrator(orchestrator) == name - - def test_returns_none_without_prefix(self) -> None: - # A bare orchestration name (no dafx- prefix) is "not one of ours". - assert workflow_name_from_orchestrator("workflow_orchestrator") is None - - -class TestValidateExecutorId: - """``validate_executor_id`` guards the durable-naming / nested-HITL contract.""" - - @pytest.mark.parametrize("executor_id", ["router", "agent_node", "reviewer-node", "a", "Step1"]) - def test_accepts_ordinary_ids(self, executor_id: str) -> None: - validate_executor_id(executor_id) # does not raise - - def test_rejects_empty(self) -> None: - with pytest.raises(ValueError, match="non-empty"): - validate_executor_id("") - - def test_rejects_id_containing_separator(self) -> None: - bad = f"a{SUBWORKFLOW_REQUEST_SEPARATOR}b" - with pytest.raises(ValueError, match="reserved sub-workflow request separator"): - validate_executor_id(bad) - - def test_rejects_overly_long_id(self) -> None: - with pytest.raises(ValueError, match="too long"): - validate_executor_id("x" * (MAX_EXECUTOR_ID_LENGTH + 1)) - - -class TestSubworkflowRequestIdQualification: - """Round-trip of the ``{executor}~{ordinal}~{leaf}`` qualified-request-id scheme.""" - - def test_separator_is_url_safe_tilde(self) -> None: - # '~' is RFC 3986 unreserved and (unlike '::') never appears in core request ids. - assert SUBWORKFLOW_REQUEST_SEPARATOR == "~" - - def test_qualify_then_split_round_trips(self) -> None: - qualified = qualify_subworkflow_request_id("sub", 2, "req-9") - assert qualified == "sub~2~req-9" - assert split_subworkflow_request_id(qualified) == ("sub", 2, "req-9") - - def test_split_returns_none_for_bare_id(self) -> None: - assert split_subworkflow_request_id("req-9") is None - - def test_split_preserves_double_colon_leaf(self) -> None: - # A functional workflow's ``auto::0`` leaf survives one peel as the remainder. - assert split_subworkflow_request_id("sub~0~auto::0") == ("sub", 0, "auto::0") - - def test_split_treats_double_colon_only_id_as_bare(self) -> None: - # ``auto::0`` has no '~', so it is a bare leaf, not a nested hop. - assert split_subworkflow_request_id("auto::0") is None - - def test_split_treats_non_integer_ordinal_as_bare(self) -> None: - # A value whose second segment is not an integer is not a structural hop. - assert split_subworkflow_request_id("a~b~c") is None - - def test_nested_qualification_round_trips(self) -> None: - deep = qualify_subworkflow_request_id("mid", 0, qualify_subworkflow_request_id("leaf", 1, "deep")) - assert deep == "mid~0~leaf~1~deep" - hop = split_subworkflow_request_id(deep) - assert hop is not None - executor_id, ordinal, remainder = hop - assert (executor_id, ordinal) == ("mid", 0) - assert split_subworkflow_request_id(remainder) == ("leaf", 1, "deep") - - def test_returns_none_for_prefix_only(self) -> None: - assert workflow_name_from_orchestrator(DURABLE_NAME_PREFIX) is None - - def test_strips_only_leading_prefix(self) -> None: - # Reverse is meant for orchestration names; it strips just the prefix, so a - # scoped activity-style name returns the remainder verbatim. - assert workflow_name_from_orchestrator("dafx-orders-translator") == "orders-translator" - - -class TestValidateWorkflowName: - """``validate_workflow_name`` rejects unstable / unsafe identities.""" - - @pytest.mark.parametrize("name", ["a", "A", "wf", "Order_Processor", "spam-detection", "x" * 63]) - def test_accepts_valid(self, name: str) -> None: - validate_workflow_name(name) # should not raise - - def test_rejects_empty(self) -> None: - with pytest.raises(ValueError, match="non-empty"): - validate_workflow_name("") - - @pytest.mark.parametrize("name", ["1abc", "-abc", "_abc", "has space", "bad/char", "a.b", "x" * 64]) - def test_rejects_malformed(self, name: str) -> None: - with pytest.raises(ValueError, match="invalid"): - validate_workflow_name(name) - - def test_rejects_auto_generated(self) -> None: - name = f"WorkflowBuilder-{uuid.uuid4()}" - with pytest.raises(ValueError, match="auto-generated"): - validate_workflow_name(name) - - -class TestIsAutoGeneratedWorkflowName: - """``is_auto_generated_workflow_name`` detects WorkflowBuilder defaults.""" - - def test_detects_uuid_default(self) -> None: - assert is_auto_generated_workflow_name(f"WorkflowBuilder-{uuid.uuid4()}") is True - - def test_detects_uppercase_hex_uuid(self) -> None: - assert is_auto_generated_workflow_name(f"WorkflowBuilder-{str(uuid.uuid4()).upper()}") is True - - @pytest.mark.parametrize( - "name", - [ - "orders", - "WorkflowBuilder", - "WorkflowBuilder-not-a-uuid", - "MyWorkflowBuilder-3f2b1c0a-1234-5678-9abc-def012345678", - ], - ) - def test_ignores_explicit_names(self, name: str) -> None: - assert is_auto_generated_workflow_name(name) is False diff --git a/python/packages/durabletask/tests/test_workflow_orchestrator_helpers.py b/python/packages/durabletask/tests/test_workflow_orchestrator_helpers.py deleted file mode 100644 index 05d3f8d9424..00000000000 --- a/python/packages/durabletask/tests/test_workflow_orchestrator_helpers.py +++ /dev/null @@ -1,363 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Unit tests for shared durable workflow-orchestrator helper functions.""" - -# pyright: reportPrivateUsage=false - -from __future__ import annotations - -import json -from collections import defaultdict -from typing import Any -from unittest.mock import AsyncMock, Mock - -from agent_framework import ( - AgentExecutor, - AgentExecutorRequest, - AgentExecutorResponse, - AgentResponse, - Executor, - Message, -) -from agent_framework._workflows._edge import FanInEdgeGroup, SingleEdgeGroup -from agent_framework._workflows._state import State -from pydantic import BaseModel - -from agent_framework_durabletask._workflows.orchestrator import ( - SOURCE_HITL_RESPONSE, - ExecutorResult, - PendingHITLRequest, - TaskType, - _check_fan_in_ready, - _collect_hitl_requests, - _deserialize_hitl_response, - _prepare_activity_task, - _prepare_agent_task, - _prepare_all_tasks, - _process_activity_result, - _route_hitl_response, - _route_result_messages, - _select_primary_input_type, - execute_hitl_response_handler, -) -from agent_framework_durabletask._workflows.serialization import serialize_value - - -class _ApprovalModel(BaseModel): - approved: bool - - -def _agent_response(text: str) -> AgentExecutorResponse: - assistant = Message(role="assistant", contents=[text]) - return AgentExecutorResponse( - executor_id="exec", - agent_response=AgentResponse(messages=[assistant]), - full_conversation=[assistant], - ) - - -class TestPrepareTaskHelpers: - """Preparation helpers scope names and package activity input correctly.""" - - def test_prepare_agent_task_scopes_executor_id_and_extracts_message_text(self) -> None: - ctx = Mock() - ctx.instance_id = "instance-1" - ctx.prepare_agent_task.return_value = "agent-task" - request = AgentExecutorRequest(messages=[Message(role="user", contents=["hello there"])]) - - task = _prepare_agent_task(ctx, "reviewer", request, "moderation") - - assert task == "agent-task" - ctx.prepare_agent_task.assert_called_once_with("moderation-reviewer", "hello there", "instance-1") - - def test_prepare_activity_task_serializes_message_state_and_host_context(self) -> None: - ctx = Mock() - ctx.prepare_activity_task.return_value = "activity-task" - - task = _prepare_activity_task( - ctx, - "router", - {"payload": 1}, - "start", - {"existing": True}, - "moderation", - { - "root_instance_id": "root-1", - "root_workflow_name": "outer-workflow", - "request_path_prefix": "review~0~", - }, - ) - - assert task == "activity-task" - activity_name, activity_input_json = ctx.prepare_activity_task.call_args[0] - assert activity_name == "dafx-moderation-router" - - activity_input = json.loads(activity_input_json) - assert activity_input["executor_id"] == "router" - assert activity_input["message"] == serialize_value({"payload": 1}) - assert activity_input["shared_state_snapshot"] == {"existing": True} - assert activity_input["source_executor_ids"] == ["start"] - assert activity_input["host_context"] == { - "instance_id": "root-1", - "workflow_name": "outer-workflow", - "request_path_prefix": "review~0~", - } - - def test_prepare_all_tasks_groups_agent_messages_for_sequential_followup(self) -> None: - ctx = Mock() - ctx.instance_id = "instance-9" - ctx.prepare_agent_task.return_value = "agent-task" - ctx.prepare_activity_task.return_value = "activity-task" - - agent_executor = Mock(spec=AgentExecutor) - agent_executor.id = "reviewer" - activity_executor = Mock(spec=Executor) - activity_executor.id = "router" - - workflow = Mock() - workflow.name = "moderation" - workflow.executors = { - "reviewer": agent_executor, - "router": activity_executor, - } - - tasks, metadata, remaining = _prepare_all_tasks( - ctx, - workflow, - { - "reviewer": [("first", "start"), ("second", "other")], - "router": [(False, "reviewer")], - }, - {"x": 1}, - [0], - { - "root_instance_id": "root-9", - "root_workflow_name": "moderation", - "request_path_prefix": "", - }, - ) - - assert tasks == ["activity-task", "agent-task"] - assert [item.task_type for item in metadata] == [TaskType.ACTIVITY, TaskType.AGENT] - assert remaining == [("reviewer", "second", "other")] - - -class TestHitlHelpers: - """HITL helper functions sanitize, reconstruct, and route responses.""" - - def test_deserialize_hitl_response_handles_none_scalar_and_marker_rejection(self) -> None: - assert _deserialize_hitl_response(None, None) is None - assert _deserialize_hitl_response("approved", None) == "approved" - assert _deserialize_hitl_response({"__pickled__": "evil"}, None) is None - - def test_deserialize_hitl_response_reconstructs_typed_payload(self) -> None: - result = _deserialize_hitl_response({"approved": True}, f"{__name__}:_ApprovalModel") - - assert isinstance(result, _ApprovalModel) - assert result.approved is True - - def test_deserialize_hitl_response_returns_sanitized_dict_when_type_unknown(self) -> None: - payload = {"approved": False} - - assert _deserialize_hitl_response(payload, "missing.module:Type") == payload - - async def test_execute_hitl_response_handler_invokes_selected_handler(self) -> None: - handler = AsyncMock() - executor = Mock() - executor.id = "reviewer" - executor._find_response_handler.return_value = handler - shared_state = State() - runner_context = Mock() - - await execute_hitl_response_handler( - executor, - { - "original_request": {"question": "approve?"}, - "response": {"approved": True}, - "response_type": f"{__name__}:_ApprovalModel", - }, - shared_state, - runner_context, - ) - - assert handler.await_args is not None - response, workflow_context = handler.await_args.args - assert isinstance(response, _ApprovalModel) - assert response.approved is True - assert workflow_context._executor is executor - assert workflow_context._runner_context is runner_context - assert workflow_context.state is shared_state - executor._find_response_handler.assert_called_once() - - async def test_execute_hitl_response_handler_returns_when_no_handler_exists(self) -> None: - executor = Mock() - executor.id = "reviewer" - executor._find_response_handler.return_value = None - - await execute_hitl_response_handler( - executor, - {"original_request": {"question": "approve?"}, "response": "yes", "response_type": None}, - State(), - Mock(), - ) - - executor._find_response_handler.assert_called_once() - - def test_collect_hitl_requests_records_pending_entries(self) -> None: - pending: dict[str, PendingHITLRequest] = {} - - _collect_hitl_requests( - ExecutorResult( - executor_id="reviewer", - output_message=None, - activity_result={ - "pending_request_info_events": [ - { - "request_id": "req-1", - "data": {"question": "approve?"}, - "request_type": "ApprovalRequest", - "response_type": "ApprovalResponse", - } - ] - }, - task_type=TaskType.ACTIVITY, - ), - pending, - ) - - assert pending["req-1"] == PendingHITLRequest( - request_id="req-1", - source_executor_id="reviewer", - request_data={"question": "approve?"}, - request_type="ApprovalRequest", - response_type="ApprovalResponse", - ) - - def test_route_hitl_response_enqueues_message_for_source_executor(self) -> None: - pending_messages: dict[str, list[tuple[Any, str]]] = {} - - _route_hitl_response( - PendingHITLRequest( - request_id="req-2", - source_executor_id="reviewer", - request_data={"question": "approve?"}, - request_type="ApprovalRequest", - response_type="ApprovalResponse", - ), - {"approved": True}, - pending_messages, - ) - - assert pending_messages == { - "reviewer": [ - ( - { - "request_id": "req-2", - "original_request": {"question": "approve?"}, - "response": {"approved": True}, - "response_type": "ApprovalResponse", - }, - f"{SOURCE_HITL_RESPONSE}_req-2", - ) - ] - } - - -class TestResultRoutingHelpers: - """Result-processing helpers update state and feed routing queues correctly.""" - - def test_process_activity_result_applies_state_updates_and_outputs(self) -> None: - shared_state = {"keep": 1, "drop": 2} - workflow_outputs: list[Any] = [] - - result = _process_activity_result( - json.dumps({ - "shared_state_updates": {"added": 3}, - "shared_state_deletes": ["drop"], - "outputs": ["out-1"], - }), - "router", - shared_state, - workflow_outputs, - ) - - assert result.task_type == TaskType.ACTIVITY - assert shared_state == {"keep": 1, "added": 3} - assert workflow_outputs == ["out-1"] - - def test_route_result_messages_handles_output_messages_explicit_targets_and_fanin(self) -> None: - fan_in_group = FanInEdgeGroup(source_ids=["router", "other"], target_id="joined") - edge_group = SingleEdgeGroup(source_id="router", target_id="next", condition=lambda _message: True) - workflow = Mock() - workflow.edge_groups = [fan_in_group, edge_group] - - next_pending_messages: dict[str, list[tuple[Any, str]]] = {} - fan_in_pending: dict[str, dict[str, list[tuple[Any, str]]]] = {fan_in_group.id: defaultdict(list)} - - _route_result_messages( - ExecutorResult( - executor_id="router", - output_message=_agent_response("assistant said hello"), - activity_result={ - "sent_messages": [ - { - "message": serialize_value(0), - "target_id": "explicit", - "source_id": "router", - } - ] - }, - task_type=TaskType.ACTIVITY, - ), - workflow, - next_pending_messages, - fan_in_pending, - ) - - assert next_pending_messages["next"][0][1] == "router" - assert next_pending_messages["explicit"] == [(0, "router")] - assert fan_in_pending[fan_in_group.id]["router"][0][1] == "router" - - def test_check_fan_in_ready_delivers_aggregated_messages(self) -> None: - fan_in_group = FanInEdgeGroup(source_ids=["a", "b"], target_id="joined") - workflow = Mock() - workflow.edge_groups = [fan_in_group] - fan_in_pending = { - fan_in_group.id: { - "a": [("from-a", "a")], - "b": [("from-b", "b")], - } - } - next_pending_messages: dict[str, list[tuple[Any, str]]] = {} - - _check_fan_in_ready(workflow, fan_in_pending, next_pending_messages) - - assert next_pending_messages == {"joined": [(["from-a", "from-b"], "a")]} - assert fan_in_pending[fan_in_group.id] == defaultdict(list) - - def test_check_fan_in_ready_waits_for_all_sources(self) -> None: - fan_in_group = FanInEdgeGroup(source_ids=["a", "b"], target_id="joined") - workflow = Mock() - workflow.edge_groups = [fan_in_group] - fan_in_pending = {fan_in_group.id: {"a": [("from-a", "a")]}} - next_pending_messages: dict[str, list[tuple[Any, str]]] = {} - - _check_fan_in_ready(workflow, fan_in_pending, next_pending_messages) - - assert next_pending_messages == {} - - -class TestPrimaryInputSelection: - """Primary-input type selection skips non-concrete declarations.""" - - def test_returns_first_concrete_type(self) -> None: - executor = Mock() - executor.input_types = ["not-a-type", dict, str] - - assert _select_primary_input_type(executor) is dict - - def test_returns_none_when_no_concrete_type_exists(self) -> None: - executor = Mock() - executor.input_types = ["not-a-type", Mock()] - - assert _select_primary_input_type(executor) is None diff --git a/python/packages/durabletask/tests/test_workflow_registration.py b/python/packages/durabletask/tests/test_workflow_registration.py deleted file mode 100644 index 5f7f03fd4bc..00000000000 --- a/python/packages/durabletask/tests/test_workflow_registration.py +++ /dev/null @@ -1,189 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Unit tests for plan_workflow_registration. - -Verifies the host-agnostic decision of which executors become durable entities -(agent executors) versus durable activities (everything else), and that agent -executors are carried whole so each host can register entities under the -executor id the orchestrator dispatches to. -""" - -from unittest.mock import Mock - -import pytest -from agent_framework import AgentExecutor, Executor, WorkflowExecutor - -from agent_framework_durabletask import ( - WorkflowRegistrationPlan, - collect_hosted_workflows, - plan_workflow_registration, -) - - -def _agent_executor(executor_id: str, agent_name: str) -> Mock: - agent = Mock() - agent.name = agent_name - executor = Mock(spec=AgentExecutor) - executor.id = executor_id - executor.agent = agent - return executor - - -def _activity_executor(executor_id: str) -> Mock: - executor = Mock(spec=Executor) - executor.id = executor_id - return executor - - -def _subworkflow_executor(executor_id: str, inner_workflow: Mock) -> Mock: - executor = Mock(spec=WorkflowExecutor) - executor.id = executor_id - executor.workflow = inner_workflow - return executor - - -def _workflow(name: str, executors: dict[str, Mock]) -> Mock: - workflow = Mock() - workflow.name = name - workflow.executors = executors - return workflow - - -class TestPlanWorkflowRegistration: - """Test classification of workflow executors into durable primitives.""" - - def test_agent_executor_classified_as_entity(self) -> None: - """An AgentExecutor is carried whole in agent_executors.""" - agent_exec = _agent_executor("reviewer-node", "Reviewer") - workflow = Mock() - workflow.executors = {"reviewer-node": agent_exec} - - plan = plan_workflow_registration(workflow) - - assert plan.agent_executors == [agent_exec] - assert plan.activity_executors == [] - - def test_non_agent_executor_classified_as_activity(self) -> None: - """A plain Executor is classified as an activity.""" - activity_exec = _activity_executor("router-node") - workflow = Mock() - workflow.executors = {"router-node": activity_exec} - - plan = plan_workflow_registration(workflow) - - assert plan.agent_executors == [] - assert plan.activity_executors == [activity_exec] - - def test_mixed_executors_are_partitioned(self) -> None: - """Agent and non-agent executors are split into the correct buckets.""" - agent_exec = _agent_executor("agent-node", "Agent") - activity_exec = _activity_executor("activity-node") - workflow = Mock() - workflow.executors = {"agent-node": agent_exec, "activity-node": activity_exec} - - plan = plan_workflow_registration(workflow) - - assert plan.agent_executors == [agent_exec] - assert plan.activity_executors == [activity_exec] - - def test_agent_executor_id_is_preserved_when_distinct_from_name(self) -> None: - """The plan keeps the executor (and its id), not just the bare agent. - - This is the core of the identity fix: dispatch targets the executor id, - so registration must be able to use the id even when it differs from - ``agent.name``. - """ - agent_exec = _agent_executor("custom-executor-id", "ReusedAgentName") - workflow = Mock() - workflow.executors = {"custom-executor-id": agent_exec} - - plan = plan_workflow_registration(workflow) - - assert plan.agent_executors[0].id == "custom-executor-id" - assert plan.agent_executors[0].agent.name == "ReusedAgentName" - - def test_returns_workflow_registration_plan(self) -> None: - """The return value is a WorkflowRegistrationPlan.""" - workflow = Mock() - workflow.executors = {} - - plan = plan_workflow_registration(workflow) - - assert isinstance(plan, WorkflowRegistrationPlan) - assert plan.agent_executors == [] - assert plan.activity_executors == [] - - def test_subworkflow_executor_classified_separately(self) -> None: - """A WorkflowExecutor goes to subworkflow_executors, not activities.""" - inner = _workflow("inner", {}) - sub_exec = _subworkflow_executor("sub-node", inner) - activity_exec = _activity_executor("router-node") - workflow = _workflow("outer", {"sub-node": sub_exec, "router-node": activity_exec}) - - plan = plan_workflow_registration(workflow) - - assert plan.subworkflow_executors == [sub_exec] - assert plan.activity_executors == [activity_exec] - assert plan.agent_executors == [] - - -class TestCollectHostedWorkflows: - """Test the recursive walk over nested sub-workflows.""" - - def test_single_workflow_yields_itself(self) -> None: - workflow = _workflow("solo", {"node": _activity_executor("node")}) - - assert [w.name for w in collect_hosted_workflows(workflow)] == ["solo"] - - def test_yields_nested_subworkflows_parent_first(self) -> None: - inner = _workflow("inner", {"leaf": _activity_executor("leaf")}) - sub_exec = _subworkflow_executor("sub", inner) - outer = _workflow("outer", {"sub": sub_exec}) - - assert [w.name for w in collect_hosted_workflows(outer)] == ["outer", "inner"] - - def test_dedupes_shared_subworkflow_by_name(self) -> None: - """A sub-workflow reused by two nodes is yielded once.""" - inner = _workflow("shared", {"leaf": _activity_executor("leaf")}) - sub_a = _subworkflow_executor("a", inner) - sub_b = _subworkflow_executor("b", inner) - outer = _workflow("outer", {"a": sub_a, "b": sub_b}) - - assert [w.name for w in collect_hosted_workflows(outer)] == ["outer", "shared"] - - def test_walks_multiple_levels(self) -> None: - leaf = _workflow("leaf_wf", {"x": _activity_executor("x")}) - mid = _workflow("mid_wf", {"l": _subworkflow_executor("l", leaf)}) - top = _workflow("top_wf", {"m": _subworkflow_executor("m", mid)}) - - assert [w.name for w in collect_hosted_workflows(top)] == ["top_wf", "mid_wf", "leaf_wf"] - - def test_rejects_two_different_workflows_sharing_a_name(self) -> None: - """Two different sub-workflow instances with the same name collide and raise.""" - inner_a = _workflow("shared", {"x": _activity_executor("x")}) - inner_b = _workflow("shared", {"y": _activity_executor("y")}) # different instance, same name - outer = _workflow("outer", {"a": _subworkflow_executor("a", inner_a), "b": _subworkflow_executor("b", inner_b)}) - - with pytest.raises(ValueError, match="collides"): - list(collect_hosted_workflows(outer)) - - def test_rejects_case_insensitive_name_collision(self) -> None: - """Two different instances whose names differ only by case collide and raise. - - The route ownership guard compares the durable orchestration name - case-insensitively, so case-only name variants must be rejected here or one - workflow's routes could operate on the other's instances. - """ - inner_a = _workflow("shared", {"x": _activity_executor("x")}) - inner_b = _workflow("Shared", {"y": _activity_executor("y")}) # case-only difference - outer = _workflow("outer", {"a": _subworkflow_executor("a", inner_a), "b": _subworkflow_executor("b", inner_b)}) - - with pytest.raises(ValueError, match="collides"): - list(collect_hosted_workflows(outer)) - - def test_same_instance_reused_is_deduped_not_rejected(self) -> None: - """The same sub-workflow instance referenced by two nodes (fan-out) is yielded once.""" - inner = _workflow("shared", {"x": _activity_executor("x")}) - outer = _workflow("outer", {"a": _subworkflow_executor("a", inner), "b": _subworkflow_executor("b", inner)}) - - assert [w.name for w in collect_hosted_workflows(outer)] == ["outer", "shared"] diff --git a/python/packages/durabletask/tests/test_workflow_routing.py b/python/packages/durabletask/tests/test_workflow_routing.py deleted file mode 100644 index 079e0b69909..00000000000 --- a/python/packages/durabletask/tests/test_workflow_routing.py +++ /dev/null @@ -1,38 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Unit tests for synchronous edge-condition evaluation on the durabletask host. - -Durable orchestrators run as generators and evaluate edge conditions -synchronously. A condition that returns an awaitable cannot be evaluated in -that context, so the edge is treated as *not matched* (not traversed). -""" - -from agent_framework._workflows._edge import Edge # pyright: ignore[reportPrivateImportUsage] - -from agent_framework_durabletask._workflows.orchestrator import _evaluate_edge_condition_sync - - -class TestEvaluateEdgeConditionSync: - """Synchronous edge-condition evaluation semantics.""" - - def test_no_condition_traverses(self) -> None: - edge = Edge("a", "b") - assert _evaluate_edge_condition_sync(edge, {"x": 1}) is True - - def test_sync_true_traverses(self) -> None: - edge = Edge("a", "b", condition=lambda m: m["ok"]) - assert _evaluate_edge_condition_sync(edge, {"ok": True}) is True - - def test_sync_false_does_not_traverse(self) -> None: - edge = Edge("a", "b", condition=lambda m: m["ok"]) - assert _evaluate_edge_condition_sync(edge, {"ok": False}) is False - - def test_async_condition_is_not_traversed(self) -> None: - # The durabletask host evaluates conditions synchronously; an async - # condition cannot be evaluated, so the edge is treated as not matched - # even though it would resolve True when awaited. - async def gate(_message: object) -> bool: - return True - - edge = Edge("a", "b", condition=gate) - assert _evaluate_edge_condition_sync(edge, {"x": 1}) is False diff --git a/python/packages/durabletask/tests/test_workflow_runner_context.py b/python/packages/durabletask/tests/test_workflow_runner_context.py deleted file mode 100644 index 624d7053c64..00000000000 --- a/python/packages/durabletask/tests/test_workflow_runner_context.py +++ /dev/null @@ -1,103 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Unit tests for the durabletask workflow runner context.""" - -# pyright: reportPrivateUsage=false - -from __future__ import annotations - -from unittest.mock import Mock - -import pytest -from agent_framework import WorkflowEvent, WorkflowMessage -from agent_framework._workflows._state import State - -from agent_framework_durabletask._workflows.runner_context import ( - HOST_METADATA_INSTANCE_ID, - HOST_METADATA_REQUEST_PATH_PREFIX, - HOST_METADATA_WORKFLOW_NAME, - CapturingRunnerContext, -) - - -@pytest.fixture -def context() -> CapturingRunnerContext: - return CapturingRunnerContext() - - -async def test_send_and_drain_messages(context: CapturingRunnerContext) -> None: - message = WorkflowMessage(data="hello", target_id="target", source_id="source") - - await context.send_message(message) - - assert await context.has_messages() is True - assert await context.drain_messages() == {"source": [message]} - assert await context.has_messages() is False - - -async def test_events_can_be_queued_and_read(context: CapturingRunnerContext) -> None: - event = WorkflowEvent("output", executor_id="exec", data="payload") - - await context.add_event(event) - - assert await context.has_events() is True - assert await context.next_event() == event - assert await context.has_events() is False - - -def test_checkpointing_is_unsupported(context: CapturingRunnerContext) -> None: - storage = Mock() - - context.set_runtime_checkpoint_storage(storage) - context.clear_runtime_checkpoint_storage() - - assert context.has_checkpointing() is False - - -async def test_checkpoint_methods_raise(context: CapturingRunnerContext) -> None: - with pytest.raises(NotImplementedError, match="Checkpointing is not supported"): - await context.create_checkpoint("workflow", "sig", State(), None, 1) - - with pytest.raises(NotImplementedError, match="Checkpointing is not supported"): - await context.load_checkpoint("checkpoint-1") - - with pytest.raises(NotImplementedError, match="Checkpointing is not supported"): - await context.apply_checkpoint(Mock()) - - -def test_workflow_configuration_can_be_reset(context: CapturingRunnerContext) -> None: - context.set_workflow_id("workflow-123") - context.set_streaming(True) - context.set_host_metadata({ - HOST_METADATA_INSTANCE_ID: "root-instance", - HOST_METADATA_WORKFLOW_NAME: "wf", - HOST_METADATA_REQUEST_PATH_PREFIX: "sub~0~", - }) - context.set_yield_output_classifier(lambda executor_id: None if executor_id == "secret" else "intermediate") - - assert context.is_streaming() is True - assert context.host_metadata == { - HOST_METADATA_INSTANCE_ID: "root-instance", - HOST_METADATA_WORKFLOW_NAME: "wf", - HOST_METADATA_REQUEST_PATH_PREFIX: "sub~0~", - } - assert context.classify_yielded_output("secret") is None - assert context.classify_yielded_output("visible") == "intermediate" - - context.reset_for_new_run() - - assert context.is_streaming() is False - - -async def test_request_info_events_are_tracked(context: CapturingRunnerContext) -> None: - event = WorkflowEvent("request_info", executor_id="review", data={"question": "approve?"}, request_id="req-9") - - await context.add_request_info_event(event) - - assert await context.get_pending_request_info_events() == {"req-9": event} - assert await context.drain_events() == [event] - - -async def test_request_info_response_is_not_supported(context: CapturingRunnerContext) -> None: - with pytest.raises(NotImplementedError, match="orchestrator level"): - await context.send_request_info_response("req-9", {"approved": True}) diff --git a/python/packages/durabletask/tests/test_workflow_serialization.py b/python/packages/durabletask/tests/test_workflow_serialization.py deleted file mode 100644 index 381839b2c45..00000000000 --- a/python/packages/durabletask/tests/test_workflow_serialization.py +++ /dev/null @@ -1,466 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Unit tests for workflow serialization helpers. - -``resolve_type`` is annotated ``type | None`` and its result flows into -``reconstruct_to_type``, which calls ``issubclass``. A non-class attribute -(function, module member, etc.) would raise ``TypeError`` there, so the -resolver must only ever return actual classes. - -``deserialize_workflow_output`` reverses the per-output ``serialize_value`` -encoding the shared activity applies, so typed outputs are returned as the -original objects rather than checkpoint-marker dicts. - -``serialize_value`` / ``deserialize_value`` are the internal codec; the -round-trip, ``reconstruct_to_type``, and ``strip_pickle_markers`` suites below -guard the type fidelity and the trust-boundary defense that neutralizes -attacker-injected pickle/type markers before they can reach ``pickle.loads()``. -""" - -import json -from collections import OrderedDict -from dataclasses import dataclass - -from agent_framework import ( - AgentExecutorRequest, - AgentExecutorResponse, - AgentResponse, - Message, - WorkflowEvent, -) -from pydantic import BaseModel - -from agent_framework_durabletask._workflows.serialization import ( - SUBWORKFLOW_ADDRESS_KEY, - SUBWORKFLOW_INPUT_KEY, - deserialize_value, - deserialize_workflow_event, - deserialize_workflow_output, - reconstruct_to_type, - resolve_type, - serialize_value, - serialize_workflow_event, - strip_pickle_markers, - strip_subworkflow_markers, -) - - -@dataclass -class _Decision: - """Module-level dataclass so it is picklable by serialize_value.""" - - approved: bool - note: str - - -class TestResolveType: - """Test that resolve_type only returns real classes.""" - - def test_resolves_a_real_class(self) -> None: - assert resolve_type("collections:OrderedDict") is OrderedDict - - def test_returns_none_for_non_class_attribute(self) -> None: - # json.dumps is a function; if resolve_type returned it, issubclass() - # inside reconstruct_to_type() would raise TypeError at runtime. - assert resolve_type("json:dumps") is None - - def test_returns_none_for_unknown_attribute(self) -> None: - assert resolve_type("json:DoesNotExist") is None - - def test_returns_none_for_malformed_key(self) -> None: - assert resolve_type("not-a-valid-key") is None - - -class TestDeserializeWorkflowOutput: - """Reconstruction of stored workflow outputs.""" - - def test_primitives_pass_through(self) -> None: - # Mirror the stored shape: a list of yielded outputs, JSON round-tripped. - stored = json.loads(json.dumps([serialize_value("hello"), serialize_value(42)])) - - assert deserialize_workflow_output(stored) == ["hello", 42] - - def test_typed_outputs_are_reconstructed(self) -> None: - # A typed object is stored as a checkpoint-marker dict; it must come back - # as the original object, not the marker dict. - decision = _Decision(approved=True, note="ok") - stored = json.loads(json.dumps([serialize_value(decision)])) - - result = deserialize_workflow_output(stored) - - assert result == [decision] - assert isinstance(result[0], _Decision) - - def test_none_passes_through(self) -> None: - assert deserialize_workflow_output(None) is None - - -@dataclass -class _Approval: - """Module-level dataclass so it is picklable by serialize_value.""" - - reason: str - - -def _roundtrip(event: WorkflowEvent) -> WorkflowEvent: - # Mirror the real path: serialize, JSON round-trip through the custom status, - # then reconstruct on the client. - return deserialize_workflow_event(json.loads(json.dumps(serialize_workflow_event(event)))) - - -class TestWorkflowEventRoundtrip: - """serialize_workflow_event / deserialize_workflow_event preserve event identity.""" - - def test_output_event_reconstructs_typed_data(self) -> None: - result = _roundtrip(WorkflowEvent("output", data=_Approval(reason="ok"), executor_id="writer")) - - assert result.type == "output" - assert result.executor_id == "writer" - assert result.data == _Approval(reason="ok") - assert isinstance(result.data, _Approval) - - def test_executor_completed_without_data_roundtrips_to_none(self) -> None: - result = _roundtrip(WorkflowEvent.executor_completed("reviewer")) - - assert result.type == "executor_completed" - assert result.executor_id == "reviewer" - assert result.data is None - - def test_iteration_tag_is_preserved(self) -> None: - # The orchestrator tags each event with its superstep before publishing. - serialized = serialize_workflow_event(WorkflowEvent.executor_invoked("writer")) - serialized["iteration"] = 3 - - result = deserialize_workflow_event(json.loads(json.dumps(serialized))) - - assert result.type == "executor_invoked" - assert result.iteration == 3 - - def test_request_info_event_roundtrips(self) -> None: - event: WorkflowEvent = WorkflowEvent.request_info( - request_id="req-1", - source_executor_id="approver", - request_data=_Approval(reason="needs sign-off"), - response_type=bool, - ) - - result = _roundtrip(event) - - assert result.type == "request_info" - assert result.request_id == "req-1" - assert result.source_executor_id == "approver" - assert result.response_type is bool - assert result.data == _Approval(reason="needs sign-off") - - -# Module-level test types (must be importable for checkpoint encoding roundtrip). -@dataclass -class SampleData: - """Sample dataclass for testing checkpoint encoding roundtrip.""" - - name: str - value: int - - -class SampleModel(BaseModel): - """Sample Pydantic model for testing checkpoint encoding roundtrip.""" - - title: str - count: int - - -@dataclass -class DataclassWithPydanticField: - """Dataclass containing a Pydantic model field for testing nested serialization.""" - - label: str - model: SampleModel - - -class TestSerializationRoundtrip: - """``serialize_value`` / ``deserialize_value`` round-trip the typed objects used in workflows.""" - - def test_roundtrip_chat_message(self) -> None: - """Test Message survives encode → decode roundtrip.""" - original = Message(role="user", contents=["Hello"]) - encoded = serialize_value(original) - decoded = deserialize_value(encoded) - - assert isinstance(decoded, Message) - assert decoded.role == "user" - - def test_roundtrip_agent_executor_request(self) -> None: - """Test AgentExecutorRequest with nested Messages roundtrips.""" - original = AgentExecutorRequest( - messages=[Message(role="user", contents=["Hi"])], - should_respond=True, - ) - encoded = serialize_value(original) - decoded = deserialize_value(encoded) - - assert isinstance(decoded, AgentExecutorRequest) - assert len(decoded.messages) == 1 - assert isinstance(decoded.messages[0], Message) - assert decoded.should_respond is True - - def test_roundtrip_agent_executor_response(self) -> None: - """Test AgentExecutorResponse with nested AgentResponse roundtrips.""" - original = AgentExecutorResponse( - executor_id="test_exec", - agent_response=AgentResponse(messages=[Message(role="assistant", contents=["Reply"])]), - full_conversation=[Message(role="assistant", contents=["Reply"])], - ) - encoded = serialize_value(original) - decoded = deserialize_value(encoded) - - assert isinstance(decoded, AgentExecutorResponse) - assert decoded.executor_id == "test_exec" - assert isinstance(decoded.agent_response, AgentResponse) - - def test_roundtrip_dataclass(self) -> None: - """Test custom dataclass roundtrips.""" - original = SampleData(name="test", value=42) - encoded = serialize_value(original) - decoded = deserialize_value(encoded) - - assert isinstance(decoded, SampleData) - assert decoded.name == "test" - assert decoded.value == 42 - - def test_roundtrip_pydantic_model(self) -> None: - """Test Pydantic model roundtrips.""" - original = SampleModel(title="Hello", count=5) - encoded = serialize_value(original) - decoded = deserialize_value(encoded) - - assert isinstance(decoded, SampleModel) - assert decoded.title == "Hello" - assert decoded.count == 5 - - def test_roundtrip_primitives(self) -> None: - """Test primitives pass through unchanged.""" - assert serialize_value(None) is None - assert serialize_value("hello") == "hello" - assert serialize_value(42) == 42 - assert serialize_value(3.14) == 3.14 - assert serialize_value(True) is True - - def test_roundtrip_list_of_objects(self) -> None: - """Test list of typed objects roundtrips.""" - original = [ - Message(role="user", contents=["Q"]), - Message(role="assistant", contents=["A"]), - ] - encoded = serialize_value(original) - decoded = deserialize_value(encoded) - - assert isinstance(decoded, list) - assert len(decoded) == 2 - assert all(isinstance(m, Message) for m in decoded) - - def test_roundtrip_dict_of_objects(self) -> None: - """Test dict with typed values roundtrips (used for shared state).""" - original = {"count": 42, "msg": Message(role="user", contents=["Hi"])} - encoded = serialize_value(original) - decoded = deserialize_value(encoded) - - assert decoded["count"] == 42 - assert isinstance(decoded["msg"], Message) - - def test_roundtrip_dataclass_with_nested_pydantic(self) -> None: - """Test dataclass containing a Pydantic model field roundtrips correctly. - - This covers the HITL pattern where AnalysisWithSubmission (dataclass) - contains a ContentAnalysisResult (Pydantic BaseModel) field. - """ - original = DataclassWithPydanticField(label="test", model=SampleModel(title="Nested", count=99)) - encoded = serialize_value(original) - decoded = deserialize_value(encoded) - - assert isinstance(decoded, DataclassWithPydanticField) - assert decoded.label == "test" - assert isinstance(decoded.model, SampleModel) - assert decoded.model.title == "Nested" - assert decoded.model.count == 99 - - -class TestReconstructToType: - """Test suite for reconstruct_to_type function (used for HITL responses).""" - - def test_none_returns_none(self) -> None: - """Test that None input returns None.""" - assert reconstruct_to_type(None, str) is None - - def test_already_correct_type(self) -> None: - """Test that values already of the correct type are returned as-is.""" - assert reconstruct_to_type("hello", str) == "hello" - assert reconstruct_to_type(42, int) == 42 - - def test_non_dict_returns_original(self) -> None: - """Test that non-dict values are returned as-is.""" - assert reconstruct_to_type("hello", int) == "hello" - assert reconstruct_to_type([1, 2], dict) == [1, 2] - - def test_reconstruct_pydantic_model(self) -> None: - """Test reconstruction of Pydantic model from plain dict.""" - - class ApprovalResponse(BaseModel): - approved: bool - reason: str - - data = {"approved": True, "reason": "Looks good"} - result = reconstruct_to_type(data, ApprovalResponse) - - assert isinstance(result, ApprovalResponse) - assert result.approved is True - assert result.reason == "Looks good" - - def test_reconstruct_dataclass(self) -> None: - """Test reconstruction of dataclass from plain dict.""" - - @dataclass - class Feedback: - score: int - comment: str - - data = {"score": 5, "comment": "Great"} - result = reconstruct_to_type(data, Feedback) - - assert isinstance(result, Feedback) - assert result.score == 5 - assert result.comment == "Great" - - def test_reconstruct_from_checkpoint_markers(self) -> None: - """Test that data with checkpoint markers is decoded via deserialize_value. - - reconstruct_to_type is general-purpose and handles trusted checkpoint - data. Untrusted HITL callers must call strip_pickle_markers() first. - """ - original = SampleData(value=99, name="marker-test") - encoded = serialize_value(original) - - result = reconstruct_to_type(encoded, SampleData) - assert isinstance(result, SampleData) - assert result.value == 99 - - def test_unrecognized_dict_returns_original(self) -> None: - """Test that unrecognized dicts are returned as-is.""" - - @dataclass - class Unrelated: - completely_different: str - - data = {"some_key": "some_value"} - result = reconstruct_to_type(data, Unrelated) - - assert result == data - - def test_reconstruct_strips_injected_pickle_markers(self) -> None: - """End-to-end: strip_pickle_markers + reconstruct_to_type blocks attack. - - This mirrors the real HITL flow where callers sanitize before reconstruction. - """ - malicious = {"__pickled__": "gASVDgAAAAAAAACMBHRlc3SULg==", "__type__": "builtins:str"} - sanitized = strip_pickle_markers(malicious) - result = reconstruct_to_type(sanitized, str) - assert result is None - - -class TestStripPickleMarkers: - """Security tests for strip_pickle_markers — the defence-in-depth layer - that prevents untrusted HTTP input from reaching pickle.loads().""" - - def test_strips_top_level_pickle_marker(self) -> None: - """A dict containing __pickled__ must be replaced with None.""" - data = {"__pickled__": "PAYLOAD", "__type__": "os:system"} - assert strip_pickle_markers(data) is None - - def test_strips_top_level_type_marker_only(self) -> None: - """Even __type__ alone (without __pickled__) must be neutralised.""" - data = {"__type__": "os:system", "other": "value"} - assert strip_pickle_markers(data) is None - - def test_strips_nested_pickle_marker(self) -> None: - """Pickle markers nested inside a dict must be neutralised.""" - data = {"safe": "value", "nested": {"__pickled__": "PAYLOAD", "__type__": "os:system"}} - result = strip_pickle_markers(data) - assert result == {"safe": "value", "nested": None} - - def test_strips_pickle_marker_in_list(self) -> None: - """Pickle markers inside a list element must be neutralised.""" - data = [{"__pickled__": "PAYLOAD"}, "safe"] - result = strip_pickle_markers(data) - assert result == [None, "safe"] - - def test_strips_deeply_nested_marker(self) -> None: - """Deeply nested pickle markers must be neutralised.""" - data = {"a": {"b": {"c": {"__pickled__": "deep"}}}} - result = strip_pickle_markers(data) - assert result == {"a": {"b": {"c": None}}} - - def test_preserves_safe_dict(self) -> None: - """Dicts without pickle markers must be left untouched.""" - data = {"approved": True, "reason": "Looks good"} - assert strip_pickle_markers(data) == data - - def test_preserves_primitives(self) -> None: - """Primitive values must pass through unchanged.""" - assert strip_pickle_markers("hello") == "hello" - assert strip_pickle_markers(42) == 42 - assert strip_pickle_markers(None) is None - assert strip_pickle_markers(True) is True - - def test_preserves_safe_list(self) -> None: - """Lists without pickle markers must be left untouched.""" - data = [1, "two", {"key": "value"}] - assert strip_pickle_markers(data) == data - - def test_mixed_safe_and_malicious(self) -> None: - """Only the malicious entries should be stripped; safe entries remain.""" - data = { - "user_input": "hello", - "evil": {"__pickled__": "PAYLOAD", "__type__": "os:system"}, - "count": 42, - } - result = strip_pickle_markers(data) - assert result == {"user_input": "hello", "evil": None, "count": 42} - - -class TestStripSubworkflowMarkers: - """Boundary defence: a forged sub-workflow envelope in untrusted input is removed. - - Only an internal child dispatch (post trust boundary) may carry the reserved - key; if untrusted client input could, it would be treated as a trusted - sub-orchestration payload and reach pickle.loads without sanitization. - """ - - def test_strips_input_key(self) -> None: - data = {SUBWORKFLOW_INPUT_KEY: {"__pickled__": "evil"}, "real": 1} - assert strip_subworkflow_markers(data) == {"real": 1} - - def test_strips_address_key(self) -> None: - # A forged address marker would otherwise let a top-level caller point respond - # URLs at another instance (confused-deputy); it must be stripped too. - data = {SUBWORKFLOW_ADDRESS_KEY: {"root_instance_id": "victim"}, "real": 1} - assert strip_subworkflow_markers(data) == {"real": 1} - - def test_strips_both_markers_together(self) -> None: - data = { - SUBWORKFLOW_INPUT_KEY: "x", - SUBWORKFLOW_ADDRESS_KEY: {"root_instance_id": "victim"}, - "real": 1, - } - assert strip_subworkflow_markers(data) == {"real": 1} - - def test_strips_full_forged_envelope(self) -> None: - data = {SUBWORKFLOW_INPUT_KEY: "x"} - assert strip_subworkflow_markers(data) == {} - - def test_preserves_ordinary_dict(self) -> None: - data = {"order_id": 42, "items": ["a", "b"]} - assert strip_subworkflow_markers(data) == data - - def test_preserves_non_dict(self) -> None: - assert strip_subworkflow_markers("hello") == "hello" - assert strip_subworkflow_markers([1, 2]) == [1, 2] - assert strip_subworkflow_markers(None) is None diff --git a/python/pyproject.toml b/python/pyproject.toml index 01547cb17d9..11dfbb676f8 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -78,14 +78,12 @@ agent-framework-ag-ui = { workspace = true } agent-framework-azure-ai-search = { workspace = true } agent-framework-azure-cosmos = { workspace = true } agent-framework-anthropic = { workspace = true } -agent-framework-azurefunctions = { workspace = true } agent-framework-bedrock = { workspace = true } agent-framework-chatkit = { workspace = true } agent-framework-claude = { workspace = true } agent-framework-copilotstudio = { workspace = true } agent-framework-declarative = { workspace = true } agent-framework-devui = { workspace = true } -agent-framework-durabletask = { workspace = true } agent-framework-foundry = { workspace = true } agent-framework-foundry-hosting = { workspace = true } agent-framework-foundry-local = { workspace = true } diff --git a/python/samples/01-get-started/08_host_your_agent.py b/python/samples/01-get-started/08_host_your_agent.py deleted file mode 100644 index 12299f54de0..00000000000 --- a/python/samples/01-get-started/08_host_your_agent.py +++ /dev/null @@ -1,40 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -# ruff: noqa: E305 -# fmt: off -from typing import Any - -from agent_framework import Agent -from agent_framework.azure import AgentFunctionApp -from agent_framework.foundry import FoundryChatClient -from azure.identity import AzureCliCredential - -"""Host your agent with Azure Functions. -This sample shows the Python hosting pattern used in docs: -- Create an agent with `FoundryChatClient` -- Register it with `AgentFunctionApp` -- Run with Azure Functions Core Tools (`func start`) -Prerequisites: - pip install agent-framework-azurefunctions --pre -""" - - -# -def _create_agent() -> Any: - """Create a hosted agent backed by Azure OpenAI.""" - return Agent( - client=FoundryChatClient( - project_endpoint="https://your-project.services.ai.azure.com", - model="gpt-4o", - credential=AzureCliCredential(), - ), - name="HostedAgent", - instructions="You are a helpful assistant hosted in Azure Functions.", - ) -# -# -app = AgentFunctionApp(agents=[_create_agent()], enable_health_check=True, max_poll_retries=50) -# -if __name__ == "__main__": - print("Start the Functions host with: func start") - print("Then call: POST /api/agents/HostedAgent/run") diff --git a/python/samples/01-get-started/README.md b/python/samples/01-get-started/README.md index 6079bdbd077..c4180b85fab 100644 --- a/python/samples/01-get-started/README.md +++ b/python/samples/01-get-started/README.md @@ -29,7 +29,8 @@ export FOUNDRY_MODEL="gpt-4o" # optional, defaults to gpt-4o | 5 | [05_functional_workflow_with_agents.py](05_functional_workflow_with_agents.py) | Call agents inside a functional workflow. | | 6 | [06_functional_workflow_basics.py](06_functional_workflow_basics.py) | Write a workflow as a plain async function. | | 7 | [07_first_graph_workflow.py](07_first_graph_workflow.py) | Chain executors into a graph workflow with edges. | -| 8 | [08_host_your_agent.py](08_host_your_agent.py) | Host a single agent with Azure Functions. | + +To host agents and workflows with Durable Task or Azure Functions, continue with the [Durable Agent Framework extension samples](https://github.com/microsoft/agent-framework-durable-extension/tree/main/python/samples). Run any sample with: diff --git a/python/samples/04-hosting/README.md b/python/samples/04-hosting/README.md index bea9124f86e..314fc58ae0f 100644 --- a/python/samples/04-hosting/README.md +++ b/python/samples/04-hosting/README.md @@ -7,16 +7,15 @@ This directory contains Python samples that demonstrate different ways to host A | Option | Use this when you need... | Start here | |--------|----------------------------|------------| | A2A | Agent-to-Agent protocol interoperability or remote agent invocation. | [`a2a/README.md`](./a2a/README.md) | -| Azure Functions | HTTP or serverless hosting on Azure Functions. | [`azure_functions/README.md`](./azure_functions/README.md) | -| Durable Task | Durable execution, long-running flows, or orchestration patterns. | [`durabletask/README.md`](./durabletask/README.md) | +| Azure Functions | HTTP or serverless hosting on Azure Functions. | [Durable extension Azure Functions samples](https://github.com/microsoft/agent-framework-durable-extension/tree/main/python/samples/azure_functions) | +| Durable Task | Durable execution, long-running flows, or orchestration patterns. | [Durable extension samples](https://github.com/microsoft/agent-framework-durable-extension/tree/main/python/samples) | | Foundry Hosted Agents | Microsoft Foundry hosted agent deployment. | [`foundry-hosted-agents/README.md`](./foundry-hosted-agents/README.md) | | Self-Hosted Protocol Helpers | Application-owned OpenAI Responses endpoints or Telegram bots. | [`af-hosting/README.md`](./af-hosting/README.md) | ## How to Choose - Start with **A2A** if you want one agent to call or expose another agent over the A2A protocol. -- Start with **Azure Functions** if you want an HTTP-hosted or serverless entry point using Azure Functions. -- Start with **Durable Task** if you need persistent state, durable workflows, or orchestration across multiple steps. +- Start with the **Durable Agent Framework extension** if you need Azure Functions hosting, persistent state, durable workflows, or orchestration across multiple steps. - Start with **Foundry Hosted Agents** if you want to package and deploy an agent as a hosted agent in Microsoft Foundry. - Start with **Self-Hosted Protocol Helpers** if you want to own the web framework or native SDK, routing, authorization, and state storage while using OpenAI Responses or Telegram helpers. diff --git a/python/samples/04-hosting/azure_functions/01_single_agent/README.md b/python/samples/04-hosting/azure_functions/01_single_agent/README.md deleted file mode 100644 index 886f1156a0f..00000000000 --- a/python/samples/04-hosting/azure_functions/01_single_agent/README.md +++ /dev/null @@ -1,64 +0,0 @@ -# Single Agent Sample (Python) - -This sample demonstrates how to use the Durable Extension for Agent Framework 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 - -- Defining a simple agent with the Microsoft Agent Framework and wiring it into - an Azure Functions app via the Durable Extension for Agent Framework. -- Calling the agent through generated HTTP endpoints (`/api/agents/Joker/run`). -- Managing conversation state with session identifiers, so multiple clients can - interact with the agent concurrently without sharing context. - -## Prerequisites - -Follow the common setup steps in `../README.md` to install tooling, configure Azure OpenAI credentials, and install the Python dependencies for this sample. - -## Running the Sample - -Send a prompt to the Joker agent: - -Bash (Linux/macOS/WSL): - -```bash -curl -i -X POST http://localhost:7071/api/agents/Joker/run \ - -d "Tell me a short joke about cloud computing." -``` - -PowerShell: - -```powershell -Invoke-RestMethod -Method Post -Uri http://localhost:7071/api/agents/Joker/run ` - -Body "Tell me a short joke about cloud computing." -``` - -The agent responds with a JSON payload that includes the generated joke. - -> [!TIP] -> To return immediately with an HTTP 202 response instead of waiting for the agent output, set the `x-ms-wait-for-response` header or include `"wait_for_response": false` in the request body. The default behavior waits for the response. - -## Expected Output - -The default plain-text response looks like the following: - -```http -HTTP/1.1 200 OK -Content-Type: text/plain; charset=utf-8 -x-ms-thread-id: 4f205157170244bfbd80209df383757e - -Why did the cloud break up with the server? - -Because it found someone more "uplifting"! -``` - -When you specify the `x-ms-wait-for-response` header or include `"wait_for_response": false` in the request body, the Functions host responds with an HTTP 202 and queues the request to run in the background. A typical response body looks like the following: - -```json -{ - "status": "accepted", - "response": "Agent request accepted", - "message": "Tell me a short joke about cloud computing.", - "thread_id": "", - "correlation_id": "" -} -``` diff --git a/python/samples/04-hosting/azure_functions/01_single_agent/demo.http b/python/samples/04-hosting/azure_functions/01_single_agent/demo.http deleted file mode 100644 index b1feeb280df..00000000000 --- a/python/samples/04-hosting/azure_functions/01_single_agent/demo.http +++ /dev/null @@ -1,22 +0,0 @@ -### Joker Agent Sample Interactions -@baseUrl = http://localhost:7071 -@agentName = Joker -@agentRoute = {{baseUrl}}/api/agents/{{agentName}} -@healthRoute = {{baseUrl}}/api/health - -### Health Check -GET {{healthRoute}} - -### Ask for a joke (JSON payload) -POST {{agentRoute}}/run -Content-Type: application/json - -{ - "message": "Add a security element to it.", - "thread_id": "thread-001" -} - -### Ask for a joke (plain text payload) -POST {{agentRoute}}/run - -Give me a programming joke about race conditions. \ No newline at end of file diff --git a/python/samples/04-hosting/azure_functions/01_single_agent/function_app.py b/python/samples/04-hosting/azure_functions/01_single_agent/function_app.py deleted file mode 100644 index 9f0d21bffa8..00000000000 --- a/python/samples/04-hosting/azure_functions/01_single_agent/function_app.py +++ /dev/null @@ -1,52 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Host a single Foundry-powered agent inside Azure Functions. - -Components used in this sample: -- FoundryChatClient to call the Foundry deployment. -- AgentFunctionApp to expose HTTP endpoints via the Durable Functions extension. - -Prerequisites: set `FOUNDRY_PROJECT_ENDPOINT`, `FOUNDRY_MODEL`, and sign in -with Azure CLI before starting the Functions host.""" - -import os -from typing import Any - -from agent_framework import Agent -from agent_framework.azure import AgentFunctionApp -from agent_framework.foundry import FoundryChatClient -from azure.identity.aio import AzureCliCredential -from dotenv import load_dotenv - -load_dotenv() - - -# 1. Instantiate the agent with the chosen deployment and instructions. -def _create_agent() -> Any: - """Create the Joker agent.""" - return Agent( - client=FoundryChatClient( - project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], - model=os.environ["FOUNDRY_MODEL"], - credential=AzureCliCredential(), - ), - name="Joker", - instructions="You are good at telling jokes.", - ) - - -# 2. Register the agent with AgentFunctionApp so Azure Functions exposes the required triggers. -app = AgentFunctionApp(agents=[_create_agent()], enable_health_check=True, max_poll_retries=50) - -""" -Expected output when invoking `POST /api/agents/Joker/run` with plain-text input: - -HTTP/1.1 202 Accepted -{ - "status": "accepted", - "response": "Agent request accepted", - "message": "Tell me a short joke about cloud computing.", - "conversation_id": "", - "correlation_id": "" -} -""" diff --git a/python/samples/04-hosting/azure_functions/01_single_agent/host.json b/python/samples/04-hosting/azure_functions/01_single_agent/host.json deleted file mode 100644 index 9e7fd873dda..00000000000 --- a/python/samples/04-hosting/azure_functions/01_single_agent/host.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "version": "2.0", - "extensionBundle": { - "id": "Microsoft.Azure.Functions.ExtensionBundle", - "version": "[4.*, 5.0.0)" - }, - "extensions": { - "durableTask": { - "hubName": "%TASKHUB_NAME%" - } - } -} diff --git a/python/samples/04-hosting/azure_functions/01_single_agent/local.settings.json.template b/python/samples/04-hosting/azure_functions/01_single_agent/local.settings.json.template deleted file mode 100644 index 1d8bc82e392..00000000000 --- a/python/samples/04-hosting/azure_functions/01_single_agent/local.settings.json.template +++ /dev/null @@ -1,11 +0,0 @@ -{ - "IsEncrypted": false, - "Values": { - "FUNCTIONS_WORKER_RUNTIME": "python", - "AzureWebJobsStorage": "UseDevelopmentStorage=true", - "DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None", - "TASKHUB_NAME": "default", - "FOUNDRY_PROJECT_ENDPOINT": "", - "FOUNDRY_MODEL": "" - } -} diff --git a/python/samples/04-hosting/azure_functions/01_single_agent/requirements.txt b/python/samples/04-hosting/azure_functions/01_single_agent/requirements.txt deleted file mode 100644 index b71a6092a70..00000000000 --- a/python/samples/04-hosting/azure_functions/01_single_agent/requirements.txt +++ /dev/null @@ -1,12 +0,0 @@ -# Agent Framework packages -# To use the deployed version, uncomment the lines below and comment out the local installation lines -# agent-framework-foundry -# agent-framework-azurefunctions - -# Local installation (for development and testing) -# Each package must be listed explicitly because pip doesn't resolve uv workspace sources. -# Without explicit entries, pip would fetch transitive dependencies from PyPI instead of local source. --e ../../../../packages/core # Core framework - base dependency for all packages --e ../../../../packages/foundry # Foundry support - dependency for hosted chat/agent samples --e ../../../../packages/durabletask # Durable Task support - dependency of azurefunctions --e ../../../../packages/azurefunctions # Azure Functions integration - the main package for this sample diff --git a/python/samples/04-hosting/azure_functions/02_multi_agent/README.md b/python/samples/04-hosting/azure_functions/02_multi_agent/README.md deleted file mode 100644 index e133ca369c1..00000000000 --- a/python/samples/04-hosting/azure_functions/02_multi_agent/README.md +++ /dev/null @@ -1,104 +0,0 @@ -# Multi-Agent Sample - -This sample demonstrates how to use the Durable Extension for Agent Framework to create an Azure Functions app that hosts multiple AI agents and provides direct HTTP API access for interactive conversations with each agent. - -## Key Concepts Demonstrated - -- Using the Microsoft Agent Framework to define multiple AI agents with unique names and instructions. -- Registering multiple agents with the Function app and running them using HTTP. -- Conversation management (via session IDs) for isolated interactions per agent. -- Two different methods for registering agents: list-based initialization and incremental addition. - -## Prerequisites - -Complete the common environment preparation steps described in `../README.md`, including installing Azure Functions Core Tools, starting Azurite, configuring Azure OpenAI settings, and installing this sample's requirements. - -## Running the Sample - -With the environment setup and function app running, you can test the sample by sending HTTP requests to the different agent endpoints. - -You can use the `demo.http` file to send messages to the agents, or a command line tool like `curl` as shown below: - -> **Note:** Each endpoint waits for the agent response by default. To receive an immediate HTTP 202 instead, set the `x-ms-wait-for-response` header or include `"wait_for_response": false` in the request body. - -### Test the Weather Agent - -Bash (Linux/macOS/WSL): -Weather agent request: - -```bash -curl -X POST http://localhost:7071/api/agents/WeatherAgent/run \ - -H "Content-Type: application/json" \ - -d '{"message": "What is the weather in Seattle?"}' -``` - -Expected HTTP 202 payload: - -```json -{ - "status": "accepted", - "response": "Agent request accepted", - "message": "What is the weather in Seattle?", - "thread_id": "", - "correlation_id": "" -} -``` - -Math agent request: - -```bash -curl -X POST http://localhost:7071/api/agents/MathAgent/run \ - -H "Content-Type: application/json" \ - -d '{"message": "Calculate a 20% tip on a $50 bill"}' -``` - -Expected HTTP 202 payload: - -```json -{ - "status": "accepted", - "response": "Agent request accepted", - "message": "Calculate a 20% tip on a $50 bill", - "thread_id": "", - "correlation_id": "" -} -``` - -Health check (optional): - -```bash -curl http://localhost:7071/api/health -``` - -Expected response: - -```json -{ - "status": "healthy", - "agents": [ - {"name": "WeatherAgent", "type": "Agent"}, - {"name": "MathAgent", "type": "Agent"} - ], - "agent_count": 2 -} -``` - -## Code Structure - -The sample demonstrates two ways to register multiple agents: - -### Option 1: Pass list of agents during initialization -```python -app = AgentFunctionApp(agents=[weather_agent, math_agent]) -``` - -### Option 2: Add agents incrementally (commented in sample) -```python -app = AgentFunctionApp() -app.add_agent(weather_agent) -app.add_agent(math_agent) -``` - -Each agent automatically gets: -- `POST /api/agents/{agent_name}/run` - Send messages to the agent - diff --git a/python/samples/04-hosting/azure_functions/02_multi_agent/demo.http b/python/samples/04-hosting/azure_functions/02_multi_agent/demo.http deleted file mode 100644 index 3db743f879c..00000000000 --- a/python/samples/04-hosting/azure_functions/02_multi_agent/demo.http +++ /dev/null @@ -1,57 +0,0 @@ -### DAFx Multi-Agent Function App - HTTP Samples -### Use with the VS Code REST Client extension or any HTTP client -### -### API Structure: -### - POST /api/agents/{agentName}/run -> Send a message to an agent -### - GET /api/health -> Health check and agent metadata - -### Variables -@baseUrl = http://localhost:7071 -@weatherAgentName = WeatherAgent -@mathAgentName = MathAgent -@weatherAgentRoute = {{baseUrl}}/api/agents/{{weatherAgentName}} -@mathAgentRoute = {{baseUrl}}/api/agents/{{mathAgentName}} -@healthRoute = {{baseUrl}}/api/health - -### Health Check -# Confirms the Azure Functions app is running and both agents are registered -# Expected response: -# { -# "status": "healthy", -# "agents": [ -# {"name": "WeatherAgent", "type": "AzureOpenAIAssistantsAgent"}, -# {"name": "MathAgent", "type": "AzureOpenAIAssistantsAgent"} -# ], -# "agent_count": 2 -# } -GET {{healthRoute}} - -### - -### Weather Agent - Current Conditions -# Tests the Weather agent's tool-assisted response path -# Expected response: { "response": "The weather in Seattle...", "status": "success" } -POST {{weatherAgentRoute}}/run -Content-Type: application/json - -{ - "message": "What is the weather in Seattle?", - "thread_id": "weather-user-001" -} - -### - - -### Math Agent - Tip Calculation -# Exercises the Math agent with a calculation request -# Expected response: { "response": "A 20% tip on a $50 bill is $10...", "status": "success" } -POST {{mathAgentRoute}}/run -Content-Type: application/json - -{ - "message": "Calculate a 20% tip on a $50 bill", - "thread_id": "math-user-001" -} - -### - diff --git a/python/samples/04-hosting/azure_functions/02_multi_agent/function_app.py b/python/samples/04-hosting/azure_functions/02_multi_agent/function_app.py deleted file mode 100644 index e9e2cb05f35..00000000000 --- a/python/samples/04-hosting/azure_functions/02_multi_agent/function_app.py +++ /dev/null @@ -1,111 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Host multiple Azure OpenAI-powered agents inside a single Azure Functions app. - -Components used in this sample: -- OpenAIChatCompletionClient configured for Azure OpenAI. -- AgentFunctionApp to register multiple agents and expose dedicated HTTP endpoints. -- Custom tool functions to demonstrate tool invocation from different agents. - -Prerequisites: set `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_MODEL`, and sign in with Azure CLI before starting the Functions host.""" - -import logging -from typing import Any - -from agent_framework import Agent, tool -from agent_framework.azure import AgentFunctionApp -from agent_framework.openai import OpenAIChatCompletionClient -from azure.identity.aio import AzureCliCredential -from dotenv import load_dotenv - -# Load environment variables from .env file -load_dotenv() - -logger = logging.getLogger(__name__) - - -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; -# see samples/02-agents/tools/function_tool_with_approval.py -# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. -@tool(approval_mode="never_require") -def get_weather(location: str) -> dict[str, Any]: - """Get current weather for a location.""" - logger.info(f"🔧 [TOOL CALLED] get_weather(location={location})") - result = { - "location": location, - "temperature": 72, - "conditions": "Sunny", - "humidity": 45, - } - logger.info(f"✓ [TOOL RESULT] {result}") - return result - - -@tool(approval_mode="never_require") -def calculate_tip(bill_amount: float, tip_percentage: float = 15.0) -> dict[str, Any]: - """Calculate tip amount and total bill.""" - - logger.info(f"🔧 [TOOL CALLED] calculate_tip(bill_amount={bill_amount}, tip_percentage={tip_percentage})") - tip = bill_amount * (tip_percentage / 100) - total = bill_amount + tip - result = { - "bill_amount": bill_amount, - "tip_percentage": tip_percentage, - "tip_amount": round(tip, 2), - "total": round(total, 2), - } - logger.info(f"✓ [TOOL RESULT] {result}") - return result - - -# 1. Create multiple agents, each with its own instruction set and tools. -client = OpenAIChatCompletionClient( - credential=AzureCliCredential(), -) - -weather_agent = Agent( - client=client, - name="WeatherAgent", - instructions="You are a helpful weather assistant. Provide current weather information.", - tools=[get_weather], -) - -math_agent = Agent( - client=client, - name="MathAgent", - instructions="You are a helpful math assistant. Help users with calculations like tip calculations.", - tools=[calculate_tip], -) - - -# 2. Register both agents with AgentFunctionApp to expose their HTTP routes and health check. -app = AgentFunctionApp(agents=[weather_agent, math_agent], enable_health_check=True, max_poll_retries=50) - -# Option 2: Add agents after initialization (commented out as we're using Option 1) -# app = AgentFunctionApp(enable_health_check=True) -# app.add_agent(weather_agent) -# app.add_agent(math_agent) - -""" -Expected output when invoking `POST /api/agents/WeatherAgent/run`: - -HTTP/1.1 202 Accepted -{ - "status": "accepted", - "response": "Agent request accepted", - "message": "What is the weather in Seattle?", - "conversation_id": "", - "correlation_id": "" -} - -Expected output when invoking `POST /api/agents/MathAgent/run`: - -HTTP/1.1 202 Accepted -{ - "status": "accepted", - "response": "Agent request accepted", - "message": "Calculate a 20% tip on a $50 bill", - "conversation_id": "", - "correlation_id": "" -} -""" diff --git a/python/samples/04-hosting/azure_functions/02_multi_agent/host.json b/python/samples/04-hosting/azure_functions/02_multi_agent/host.json deleted file mode 100644 index 7efcaa14000..00000000000 --- a/python/samples/04-hosting/azure_functions/02_multi_agent/host.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "version": "2.0", - "logging": { - "applicationInsights": { - "samplingSettings": { - "isEnabled": true, - "maxTelemetryItemsPerSecond": 20 - } - } - }, - "extensionBundle": { - "id": "Microsoft.Azure.Functions.ExtensionBundle", - "version": "[4.*, 5.0.0)" - }, - "extensions": { - "durableTask": { - "hubName": "%TASKHUB_NAME%" - } - } -} diff --git a/python/samples/04-hosting/azure_functions/02_multi_agent/local.settings.json.template b/python/samples/04-hosting/azure_functions/02_multi_agent/local.settings.json.template deleted file mode 100644 index 1d8bc82e392..00000000000 --- a/python/samples/04-hosting/azure_functions/02_multi_agent/local.settings.json.template +++ /dev/null @@ -1,11 +0,0 @@ -{ - "IsEncrypted": false, - "Values": { - "FUNCTIONS_WORKER_RUNTIME": "python", - "AzureWebJobsStorage": "UseDevelopmentStorage=true", - "DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None", - "TASKHUB_NAME": "default", - "FOUNDRY_PROJECT_ENDPOINT": "", - "FOUNDRY_MODEL": "" - } -} diff --git a/python/samples/04-hosting/azure_functions/02_multi_agent/requirements.txt b/python/samples/04-hosting/azure_functions/02_multi_agent/requirements.txt deleted file mode 100644 index b71a6092a70..00000000000 --- a/python/samples/04-hosting/azure_functions/02_multi_agent/requirements.txt +++ /dev/null @@ -1,12 +0,0 @@ -# Agent Framework packages -# To use the deployed version, uncomment the lines below and comment out the local installation lines -# agent-framework-foundry -# agent-framework-azurefunctions - -# Local installation (for development and testing) -# Each package must be listed explicitly because pip doesn't resolve uv workspace sources. -# Without explicit entries, pip would fetch transitive dependencies from PyPI instead of local source. --e ../../../../packages/core # Core framework - base dependency for all packages --e ../../../../packages/foundry # Foundry support - dependency for hosted chat/agent samples --e ../../../../packages/durabletask # Durable Task support - dependency of azurefunctions --e ../../../../packages/azurefunctions # Azure Functions integration - the main package for this sample diff --git a/python/samples/04-hosting/azure_functions/03_reliable_streaming/README.md b/python/samples/04-hosting/azure_functions/03_reliable_streaming/README.md deleted file mode 100644 index 181a3389628..00000000000 --- a/python/samples/04-hosting/azure_functions/03_reliable_streaming/README.md +++ /dev/null @@ -1,132 +0,0 @@ -# Agent Response Callbacks with Redis Streaming - -This sample demonstrates how to use Redis Streams with agent response callbacks to enable reliable, resumable streaming for durable agents. Clients can disconnect and reconnect without losing messages by using cursor-based pagination. - -## Key Concepts Demonstrated - -- Using `AgentResponseCallbackProtocol` to capture streaming agent responses -- Persisting streaming chunks to Redis Streams for reliable delivery -- Building a custom HTTP endpoint to read from Redis with Server-Sent Events (SSE) format -- Supporting cursor-based resumption for disconnected clients -- Managing Redis client lifecycle with async context managers - -## Prerequisites - -In addition to the common setup steps in `../README.md`, this sample requires Redis: - -```bash -# Start Redis -docker run -d --name redis -p 6379:6379 redis:latest -``` - -Update `local.settings.json` with your Redis connection string: - -```json -{ - "Values": { - "REDIS_CONNECTION_STRING": "redis://localhost:6379" - } -} -``` - -## Running the Sample - -### Start the agent run - -The agent executes in the background via durable orchestration. The `RedisStreamCallback` persists streaming chunks to Redis: - -```bash -curl -X POST http://localhost:7071/api/agents/TravelPlanner/run \ - -H "Content-Type: text/plain" \ - -d "Plan a 3-day trip to Tokyo" -``` - -Response (202 Accepted): -```json -{ - "status": "accepted", - "response": "Agent request accepted", - "conversation_id": "abc-123-def-456", - "correlation_id": "xyz-789" -} -``` - -### Stream the response from Redis - -Use the custom `/api/agent/stream/{conversation_id}` endpoint to read persisted chunks: - -```bash -curl http://localhost:7071/api/agent/stream/abc-123-def-456 \ - -H "Accept: text/event-stream" -``` - -Response (SSE format): -``` -id: 1734649123456-0 -event: message -data: Here's a wonderful 3-day Tokyo itinerary... - -id: 1734649123789-0 -event: message -data: Day 1: Arrival and Shibuya... - -id: 1734649124012-0 -event: done -data: [DONE] -``` - -### Resume from a cursor - -Use a cursor ID from an SSE event to skip already-processed messages: - -```bash -curl "http://localhost:7071/api/agent/stream/abc-123-def-456?cursor=1734649123456-0" \ - -H "Accept: text/event-stream" -``` - -## How It Works - -### 1. Redis Callback - -The `RedisStreamCallback` class implements `AgentResponseCallbackProtocol` to capture streaming updates: - -```python -class RedisStreamCallback(AgentResponseCallbackProtocol): - async def on_streaming_response_update(self, update, context): - # Write chunk to Redis Stream - async with await get_stream_handler() as handler: - await handler.write_chunk(thread_id, update.text, sequence) - - async def on_agent_response(self, response, context): - # Write end-of-stream marker - async with await get_stream_handler() as handler: - await handler.write_completion(thread_id, sequence) -``` - -### 2. Custom Streaming Endpoint - -The `/api/agent/stream/{conversation_id}` endpoint reads from Redis: - -```python -@app.route(route="agent/stream/{conversation_id}", methods=["GET"]) -async def stream(req): - conversation_id = req.route_params.get("conversation_id") - cursor = req.params.get("cursor") # Optional - - async with await get_stream_handler() as handler: - async for chunk in handler.read_stream(conversation_id, cursor): - # Format and return chunks -``` - -### 3. Redis Streams - -Messages are stored in Redis Streams with automatic TTL (default: 10 minutes): - -``` -Stream Key: agent-stream:{conversation_id} -Entry: { - "text": "chunk content", - "sequence": "0", - "timestamp": "1734649123456" -} -``` \ No newline at end of file diff --git a/python/samples/04-hosting/azure_functions/03_reliable_streaming/demo.http b/python/samples/04-hosting/azure_functions/03_reliable_streaming/demo.http deleted file mode 100644 index 6cdc1d10c3b..00000000000 --- a/python/samples/04-hosting/azure_functions/03_reliable_streaming/demo.http +++ /dev/null @@ -1,55 +0,0 @@ -### Reliable Streaming with Redis - Demo HTTP Requests -### Use with the VS Code REST Client extension or any HTTP client -### -### Workflow: -### 1. POST /api/agents/{agentName}/run -> Start durable agent (returns conversation_id) -### 2. GET /api/agent/stream/{id} -> Read chunks from Redis (SSE or plain text) -### 3. Add ?cursor={id} to resume from a specific point -### -### Prerequisites: -### - Redis: docker run -d --name redis -p 6379:6379 redis:latest -### - Start function app: func start - -### Variables -@baseUrl = http://localhost:7071 -@agentName = TravelPlanner - -### Health Check -GET {{baseUrl}}/api/health - -### - -### Start Agent Run -# Starts the agent in the background via durable orchestration. -# The RedisStreamCallback persists streaming chunks to Redis. -# @name trip -POST {{baseUrl}}/api/agents/{{agentName}}/run -Content-Type: text/plain - -Plan a 3-day trip to Tokyo - -### - -### Stream from Redis (SSE format) -# Reads persisted chunks from Redis using cursor-based pagination. -# The conversation_id is automatically captured from the previous request. -@conversationId = {{trip.response.body.$.conversation_id}} -GET {{baseUrl}}/api/agent/stream/{{conversationId}} -Accept: text/event-stream - -### - -### Stream from Redis (plain text) -# Same as above, but returns plain text instead of SSE format -GET {{baseUrl}}/api/agent/stream/{{conversationId}} -Accept: text/plain - -### - -### Resume from cursor -# Use a cursor ID from an SSE event to skip already-processed messages -# Replace {cursor_id} with an actual entry ID from the SSE stream -GET {{baseUrl}}/api/agent/stream/{{conversationId}}?cursor={cursor_id} -Accept: text/event-stream - -### diff --git a/python/samples/04-hosting/azure_functions/03_reliable_streaming/function_app.py b/python/samples/04-hosting/azure_functions/03_reliable_streaming/function_app.py deleted file mode 100644 index 6b2bdf0bb33..00000000000 --- a/python/samples/04-hosting/azure_functions/03_reliable_streaming/function_app.py +++ /dev/null @@ -1,327 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Reliable streaming for durable agents using Redis Streams. - -This sample demonstrates how to implement reliable streaming for durable agents using Redis Streams. - -Components used in this sample: -- FoundryChatClient to create the travel planner agent with tools. -- AgentFunctionApp with a Redis-based callback for persistent streaming. -- Custom HTTP endpoint to resume streaming from any point using cursor-based pagination. - -Prerequisites: -- Set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL -- Sign in with Azure CLI (`az login`) for `AzureCliCredential` -- Redis running (docker run -d --name redis -p 6379:6379 redis:latest) -- DTS and Azurite running (see parent README) -""" - -import logging -import os -from datetime import timedelta - -import azure.functions as func -import redis.asyncio as aioredis -from agent_framework import Agent, AgentResponseUpdate -from agent_framework.azure import ( - AgentCallbackContext, - AgentFunctionApp, - AgentResponseCallbackProtocol, -) -from agent_framework.foundry import FoundryChatClient -from azure.identity.aio import AzureCliCredential -from dotenv import load_dotenv -from redis_stream_response_handler import RedisStreamResponseHandler, StreamChunk # pyrefly: ignore[missing-import] -from tools import get_local_events, get_weather_forecast # pyrefly: ignore[missing-import] - -# Load environment variables from .env file -load_dotenv() - -logger = logging.getLogger(__name__) - -# Configuration -REDIS_CONNECTION_STRING = os.environ.get("REDIS_CONNECTION_STRING", "redis://localhost:6379") -REDIS_STREAM_TTL_MINUTES = int(os.environ.get("REDIS_STREAM_TTL_MINUTES", "10")) - - -async def get_stream_handler() -> RedisStreamResponseHandler: - """Create a new Redis stream handler for each request. - - This avoids event loop conflicts in Azure Functions by creating - a fresh Redis client in the current event loop context. - """ - # Create a new Redis client in the current event loop - redis_client = aioredis.from_url( - REDIS_CONNECTION_STRING, - encoding="utf-8", - decode_responses=False, - ) - - return RedisStreamResponseHandler( - redis_client=redis_client, - stream_ttl=timedelta(minutes=REDIS_STREAM_TTL_MINUTES), - ) - - -class RedisStreamCallback(AgentResponseCallbackProtocol): - """Callback that writes streaming updates to Redis Streams for reliable delivery. - - This enables clients to disconnect and reconnect without losing messages. - """ - - def __init__(self) -> None: - self._logger = logging.getLogger("durableagent.samples.redis_streaming") - self._sequence_numbers = {} # Track sequence per thread - - async def on_streaming_response_update( - self, - update: AgentResponseUpdate, - context: AgentCallbackContext, - ) -> None: - """Write streaming update to Redis Stream. - - Args: - update: The streaming response update chunk. - context: The callback context with thread_id, agent_name, etc. - """ - thread_id = context.thread_id - if not thread_id: - self._logger.warning("No thread_id available for streaming update") - return - - if not update.text: - return - - text = update.text - - # Get or initialize sequence number for this thread - if thread_id not in self._sequence_numbers: - self._sequence_numbers[thread_id] = 0 - - sequence = self._sequence_numbers[thread_id] - - try: - # Use context manager to ensure Redis client is properly closed - async with await get_stream_handler() as stream_handler: - # Write chunk to Redis Stream using public API - await stream_handler.write_chunk(thread_id, text, sequence) - - self._sequence_numbers[thread_id] += 1 - - self._logger.info( - "[%s][%s] Wrote chunk to Redis: seq=%d, text=%s", - context.agent_name, - thread_id[:8], - sequence, - text, - ) - except Exception as ex: - self._logger.error(f"Error writing to Redis stream: {ex}", exc_info=True) - - async def on_agent_response(self, response, context: AgentCallbackContext) -> None: - """Write end-of-stream marker when agent completes. - - Args: - response: The final agent response. - context: The callback context. - """ - thread_id = context.thread_id - if not thread_id: - return - - sequence = self._sequence_numbers.get(thread_id, 0) - - try: - # Use context manager to ensure Redis client is properly closed - async with await get_stream_handler() as stream_handler: - # Write end-of-stream marker using public API - await stream_handler.write_completion(thread_id, sequence) - - self._logger.info( - "[%s][%s] Agent completed, wrote end-of-stream marker", - context.agent_name, - thread_id[:8], - ) - - # Clean up sequence tracker - self._sequence_numbers.pop(thread_id, None) - except Exception as ex: - self._logger.error(f"Error writing end-of-stream marker: {ex}", exc_info=True) - - -# Create the Redis streaming callback -redis_callback = RedisStreamCallback() - - -# Create the travel planner agent -def create_travel_agent(): - """Create the TravelPlanner agent with tools.""" - return Agent( - client=FoundryChatClient( - project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], - model=os.environ["FOUNDRY_MODEL"], - credential=AzureCliCredential(), - ), - name="TravelPlanner", - instructions="""You are an expert travel planner who creates detailed, personalized travel itineraries. -When asked to plan a trip, you should: -1. Create a comprehensive day-by-day itinerary -2. Include specific recommendations for activities, restaurants, and attractions -3. Provide practical tips for each destination -4. Consider weather and local events when making recommendations -5. Include estimated times and logistics between activities - -Always use the available tools to get current weather forecasts and local events -for the destination to make your recommendations more relevant and timely. - -Format your response with clear headings for each day and include emoji icons -to make the itinerary easy to scan and visually appealing.""", - tools=[get_weather_forecast, get_local_events], - ) - - -# Create AgentFunctionApp with the Redis callback -app = AgentFunctionApp( - agents=[create_travel_agent()], - enable_health_check=True, - default_callback=redis_callback, - max_poll_retries=100, # Increase for longer-running agents -) - - -# Custom streaming endpoint for reading from Redis -# Use the standard /api/agents/TravelPlanner/run endpoint to start agent runs - - -@app.function_name("stream") -@app.route(route="agent/stream/{conversation_id}", methods=["GET"]) -async def stream(req: func.HttpRequest) -> func.HttpResponse: - """Resume streaming from a specific cursor position for an existing session. - - This endpoint reads all currently available chunks from Redis for the given - conversation ID, starting from the specified cursor (or beginning if no cursor). - - Use this endpoint to resume a stream after disconnection. Pass the conversation ID - and optionally a cursor (Redis entry ID) to continue from where you left off. - - Query Parameters: - cursor (optional): Redis stream entry ID to resume from. If not provided, starts from beginning. - - Response Headers: - Content-Type: text/event-stream or text/plain based on Accept header - x-conversation-id: The conversation/thread ID - - SSE Event Fields (when Accept: text/event-stream): - id: Redis stream entry ID (use as cursor for resumption) - event: "message" for content, "done" for completion, "error" for errors - data: The text content or status message - """ - try: - conversation_id = req.route_params.get("conversation_id") - if not conversation_id: - return func.HttpResponse( - "Conversation ID is required.", - status_code=400, - ) - - # Get optional cursor from query string - cursor = req.params.get("cursor") - - logger.info(f"Resuming stream for conversation {conversation_id} from cursor: {cursor or '(beginning)'}") - - # Check Accept header to determine response format - accept_header = req.headers.get("Accept", "") - use_sse_format = "text/plain" not in accept_header.lower() - - # Stream chunks from Redis - return await _stream_to_client(conversation_id, cursor, use_sse_format) - - except Exception as ex: - logger.error(f"Error in stream endpoint: {ex}", exc_info=True) - return func.HttpResponse( - f"Internal server error: {str(ex)}", - status_code=500, - ) - - -async def _stream_to_client( - conversation_id: str, - cursor: str | None, - use_sse_format: bool, -) -> func.HttpResponse: - """Stream chunks from Redis to the HTTP response. - - Args: - conversation_id: The conversation ID to stream from. - cursor: Optional cursor to resume from. If None, streams from the beginning. - use_sse_format: True to use SSE format, false for plain text. - - Returns: - HTTP response with all currently available chunks. - """ - chunks = [] - - # Use context manager to ensure Redis client is properly closed - async with await get_stream_handler() as stream_handler: - try: - async for chunk in stream_handler.read_stream(conversation_id, cursor): - if chunk.error: - logger.warning(f"Stream error for {conversation_id}: {chunk.error}") - chunks.append(_format_error(chunk.error, use_sse_format)) - break - - if chunk.is_done: - chunks.append(_format_end_of_stream(chunk.entry_id, use_sse_format)) - break - - if chunk.text: - chunks.append(_format_chunk(chunk, use_sse_format)) - - except Exception as ex: - logger.error(f"Error reading from Redis: {ex}", exc_info=True) - chunks.append(_format_error(str(ex), use_sse_format)) - - # Return all chunks - response_body = "".join(chunks) - - return func.HttpResponse( - body=response_body, - mimetype="text/event-stream" if use_sse_format else "text/plain; charset=utf-8", - headers={ - "Cache-Control": "no-cache", - "Connection": "keep-alive", - "x-conversation-id": conversation_id, - }, - ) - - -def _format_chunk(chunk: StreamChunk, use_sse_format: bool) -> str: - """Format a text chunk.""" - if use_sse_format: - return _format_sse_event("message", chunk.text or "", chunk.entry_id) - return chunk.text or "" - - -def _format_end_of_stream(entry_id: str, use_sse_format: bool) -> str: - """Format end-of-stream marker.""" - if use_sse_format: - return _format_sse_event("done", "[DONE]", entry_id) - return "\n" - - -def _format_error(error: str, use_sse_format: bool) -> str: - """Format error message.""" - if use_sse_format: - return _format_sse_event("error", error, None) - return f"\n[Error: {error}]\n" - - -def _format_sse_event(event_type: str, data: str, event_id: str | None = None) -> str: - """Format a Server-Sent Event.""" - lines = [] - if event_id: - lines.append(f"id: {event_id}") - lines.append(f"event: {event_type}") - lines.append(f"data: {data}") - lines.append("") - return "\n".join(lines) + "\n" diff --git a/python/samples/04-hosting/azure_functions/03_reliable_streaming/host.json b/python/samples/04-hosting/azure_functions/03_reliable_streaming/host.json deleted file mode 100644 index 7efcaa14000..00000000000 --- a/python/samples/04-hosting/azure_functions/03_reliable_streaming/host.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "version": "2.0", - "logging": { - "applicationInsights": { - "samplingSettings": { - "isEnabled": true, - "maxTelemetryItemsPerSecond": 20 - } - } - }, - "extensionBundle": { - "id": "Microsoft.Azure.Functions.ExtensionBundle", - "version": "[4.*, 5.0.0)" - }, - "extensions": { - "durableTask": { - "hubName": "%TASKHUB_NAME%" - } - } -} diff --git a/python/samples/04-hosting/azure_functions/03_reliable_streaming/local.settings.json.template b/python/samples/04-hosting/azure_functions/03_reliable_streaming/local.settings.json.template deleted file mode 100644 index a41192bda21..00000000000 --- a/python/samples/04-hosting/azure_functions/03_reliable_streaming/local.settings.json.template +++ /dev/null @@ -1,13 +0,0 @@ -{ - "IsEncrypted": false, - "Values": { - "FUNCTIONS_WORKER_RUNTIME": "python", - "AzureWebJobsStorage": "UseDevelopmentStorage=true", - "DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None", - "TASKHUB_NAME": "default", - "REDIS_CONNECTION_STRING": "redis://localhost:6379", - "REDIS_STREAM_TTL_MINUTES": "10", - "FOUNDRY_PROJECT_ENDPOINT": "", - "FOUNDRY_MODEL": "" - } -} diff --git a/python/samples/04-hosting/azure_functions/03_reliable_streaming/redis_stream_response_handler.py b/python/samples/04-hosting/azure_functions/03_reliable_streaming/redis_stream_response_handler.py deleted file mode 100644 index d4987a827cc..00000000000 --- a/python/samples/04-hosting/azure_functions/03_reliable_streaming/redis_stream_response_handler.py +++ /dev/null @@ -1,201 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Redis-based streaming response handler for durable agents. - -This module provides reliable, resumable streaming of agent responses using Redis Streams -as a message broker. It enables clients to disconnect and reconnect without losing messages. -""" - -import asyncio -import time -from collections.abc import AsyncIterator -from dataclasses import dataclass -from datetime import timedelta - -import redis.asyncio as aioredis - - -@dataclass -class StreamChunk: - """Represents a chunk of streamed data from Redis. - - Attributes: - entry_id: The Redis stream entry ID (used as cursor for resumption). - text: The text content of the chunk, if any. - is_done: Whether this is the final chunk in the stream. - error: Error message if an error occurred, otherwise None. - """ - - entry_id: str - text: str | None = None - is_done: bool = False - error: str | None = None - - -class RedisStreamResponseHandler: - """Handles agent responses by persisting them to Redis Streams. - - This handler writes agent response updates to Redis Streams, enabling reliable, - resumable streaming delivery to clients. Clients can disconnect and reconnect - at any point using cursor-based pagination. - - Attributes: - MAX_EMPTY_READS: Maximum number of empty reads before timing out. - POLL_INTERVAL_MS: Interval in milliseconds between polling attempts. - """ - - MAX_EMPTY_READS = 300 - POLL_INTERVAL_MS = 1000 - - def __init__(self, redis_client: aioredis.Redis, stream_ttl: timedelta): - """Initialize the Redis stream response handler. - - Args: - redis_client: The async Redis client instance. - stream_ttl: Time-to-live for stream entries in Redis. - """ - self._redis = redis_client - self._stream_ttl = stream_ttl - - async def __aenter__(self): - """Enter async context manager.""" - return self - - async def __aexit__(self, exc_type, exc_val, exc_tb): - """Exit async context manager and close Redis connection.""" - await self._redis.aclose() - - async def write_chunk( - self, - conversation_id: str, - text: str, - sequence: int, - ) -> None: - """Write a single text chunk to the Redis Stream. - - Args: - conversation_id: The conversation ID for this agent run. - text: The text content to write. - sequence: The sequence number for ordering. - """ - stream_key = self._get_stream_key(conversation_id) - await self._redis.xadd( - stream_key, - { - "text": text, - "sequence": str(sequence), - "timestamp": str(int(time.time() * 1000)), - }, - ) - await self._redis.expire(stream_key, self._stream_ttl) - - async def write_completion( - self, - conversation_id: str, - sequence: int, - ) -> None: - """Write an end-of-stream marker to the Redis Stream. - - Args: - conversation_id: The conversation ID for this agent run. - sequence: The final sequence number. - """ - stream_key = self._get_stream_key(conversation_id) - await self._redis.xadd( - stream_key, - { - "text": "", - "sequence": str(sequence), - "timestamp": str(int(time.time() * 1000)), - "done": "true", - }, - ) - await self._redis.expire(stream_key, self._stream_ttl) - - async def read_stream( - self, - conversation_id: str, - cursor: str | None = None, - ) -> AsyncIterator[StreamChunk]: - """Read entries from a Redis Stream with cursor-based pagination. - - This method polls the Redis Stream for new entries, yielding chunks as they - become available. Clients can resume from any point using the entry_id from - a previous chunk. - - Args: - conversation_id: The conversation ID to read from. - cursor: Optional cursor to resume from. If None, starts from beginning. - - Yields: - StreamChunk instances containing text content or status markers. - """ - stream_key = self._get_stream_key(conversation_id) - start_id = cursor if cursor else "0-0" - - empty_read_count = 0 - has_seen_data = False - - while True: - try: - # Read up to 100 entries from the stream - entries = await self._redis.xread( - {stream_key: start_id}, - count=100, - block=None, - ) - - if not entries: - # No entries found - if not has_seen_data: - empty_read_count += 1 - if empty_read_count >= self.MAX_EMPTY_READS: - timeout_seconds = self.MAX_EMPTY_READS * self.POLL_INTERVAL_MS / 1000 - yield StreamChunk( - entry_id=start_id, - error=f"Stream not found or timed out after {timeout_seconds} seconds", - ) - return - - # Wait before polling again - await asyncio.sleep(self.POLL_INTERVAL_MS / 1000) - continue - - has_seen_data = True - - # Process entries from the stream - for _stream_name, stream_entries in entries: - for entry_id, entry_data in stream_entries: - start_id = entry_id.decode() if isinstance(entry_id, bytes) else entry_id - - # Decode entry data - text = entry_data.get(b"text", b"").decode() if b"text" in entry_data else None - done = entry_data.get(b"done", b"").decode() if b"done" in entry_data else None - error = entry_data.get(b"error", b"").decode() if b"error" in entry_data else None - - if error: - yield StreamChunk(entry_id=start_id, error=error) - return - - if done == "true": - yield StreamChunk(entry_id=start_id, is_done=True) - return - - if text: - yield StreamChunk(entry_id=start_id, text=text) - - except Exception as ex: - yield StreamChunk(entry_id=start_id, error=str(ex)) - return - - @staticmethod - def _get_stream_key(conversation_id: str) -> str: - """Generate the Redis key for a conversation's stream. - - Args: - conversation_id: The conversation ID. - - Returns: - The Redis stream key. - """ - return f"agent-stream:{conversation_id}" diff --git a/python/samples/04-hosting/azure_functions/03_reliable_streaming/requirements.txt b/python/samples/04-hosting/azure_functions/03_reliable_streaming/requirements.txt deleted file mode 100644 index 19f872e53fc..00000000000 --- a/python/samples/04-hosting/azure_functions/03_reliable_streaming/requirements.txt +++ /dev/null @@ -1,16 +0,0 @@ -# Agent Framework packages -# To use the deployed version, uncomment the lines below and comment out the local installation lines -# agent-framework-foundry -# agent-framework-azurefunctions - -# Local installation (for development and testing) -# Each package must be listed explicitly because pip doesn't resolve uv workspace sources. -# Without explicit entries, pip would fetch transitive dependencies from PyPI instead of local source. --e ../../../../packages/core # Core framework - base dependency for all packages --e ../../../../packages/foundry # Foundry support - dependency for hosted chat/agent samples --e ../../../../packages/durabletask # Durable Task support - dependency of azurefunctions --e ../../../../packages/azurefunctions # Azure Functions integration - the main package for this sample - - -# Redis client with asyncio support (used by redis_stream_response_handler.py) -redis[asyncio] diff --git a/python/samples/04-hosting/azure_functions/03_reliable_streaming/tools.py b/python/samples/04-hosting/azure_functions/03_reliable_streaming/tools.py deleted file mode 100644 index 29be74a8463..00000000000 --- a/python/samples/04-hosting/azure_functions/03_reliable_streaming/tools.py +++ /dev/null @@ -1,164 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Mock travel tools for demonstration purposes. - -In a real application, these would call actual weather and events APIs. -""" - -from typing import Annotated - - -def get_weather_forecast( - destination: Annotated[str, "The destination city or location"], - date: Annotated[str, 'The date for the forecast (e.g., "2025-01-15" or "next Monday")'], -) -> str: - """Get the weather forecast for a destination on a specific date. - - Use this to provide weather-aware recommendations in the itinerary. - - Args: - destination: The destination city or location. - date: The date for the forecast. - - Returns: - A weather forecast summary. - """ - # Mock weather data based on destination for realistic responses - weather_by_region = { - "Tokyo": ("Partly cloudy with a chance of light rain", 58, 45), - "Paris": ("Overcast with occasional drizzle", 52, 41), - "New York": ("Clear and cold", 42, 28), - "London": ("Foggy morning, clearing in afternoon", 48, 38), - "Sydney": ("Sunny and warm", 82, 68), - "Rome": ("Sunny with light breeze", 62, 48), - "Barcelona": ("Partly sunny", 59, 47), - "Amsterdam": ("Cloudy with light rain", 46, 38), - "Dubai": ("Sunny and hot", 85, 72), - "Singapore": ("Tropical thunderstorms in afternoon", 88, 77), - "Bangkok": ("Hot and humid, afternoon showers", 91, 78), - "Los Angeles": ("Sunny and pleasant", 72, 55), - "San Francisco": ("Morning fog, afternoon sun", 62, 52), - "Seattle": ("Rainy with breaks", 48, 40), - "Miami": ("Warm and sunny", 78, 65), - "Honolulu": ("Tropical paradise weather", 82, 72), - } - - # Find a matching destination or use a default - forecast = ("Partly cloudy", 65, 50) - for city, weather in weather_by_region.items(): - if city.lower() in destination.lower(): - forecast = weather - break - - condition, high_f, low_f = forecast - high_c = (high_f - 32) * 5 // 9 - low_c = (low_f - 32) * 5 // 9 - - recommendation = _get_weather_recommendation(condition) - - return f"""Weather forecast for {destination} on {date}: -Conditions: {condition} -High: {high_f}°F ({high_c}°C) -Low: {low_f}°F ({low_c}°C) - -Recommendation: {recommendation}""" - - -def get_local_events( - destination: Annotated[str, "The destination city or location"], - date: Annotated[str, 'The date to search for events (e.g., "2025-01-15" or "next week")'], -) -> str: - """Get local events and activities happening at a destination around a specific date. - - Use this to suggest timely activities and experiences. - - Args: - destination: The destination city or location. - date: The date to search for events. - - Returns: - A list of local events and activities. - """ - # Mock events data based on destination - events_by_city = { - "Tokyo": [ - "🎭 Kabuki Theater Performance at Kabukiza Theatre - Traditional Japanese drama", - "🌸 Winter Illuminations at Yoyogi Park - Spectacular light displays", - "🍜 Ramen Festival at Tokyo Station - Sample ramen from across Japan", - "🎮 Gaming Expo at Tokyo Big Sight - Latest video games and technology", - ], - "Paris": [ - "🎨 Impressionist Exhibition at Musée d'Orsay - Extended evening hours", - "🍷 Wine Tasting Tour in Le Marais - Local sommelier guided", - "🎵 Jazz Night at Le Caveau de la Huchette - Historic jazz club", - "🥐 French Pastry Workshop - Learn from master pâtissiers", - ], - "New York": [ - "🎭 Broadway Show: Hamilton - Limited engagement performances", - "🏀 Knicks vs Lakers at Madison Square Garden", - "🎨 Modern Art Exhibit at MoMA - New installations", - "🍕 Pizza Walking Tour of Brooklyn - Artisan pizzerias", - ], - "London": [ - "👑 Royal Collection Exhibition at Buckingham Palace", - "🎭 West End Musical: The Phantom of the Opera", - "🍺 Craft Beer Festival at Brick Lane", - "🎪 Winter Wonderland at Hyde Park - Rides and markets", - ], - "Sydney": [ - "🏄 Pro Surfing Competition at Bondi Beach", - "🎵 Opera at Sydney Opera House - La Bohème", - "🦘 Wildlife Night Safari at Taronga Zoo", - "🍽️ Harbor Dinner Cruise with fireworks", - ], - "Rome": [ - "🏛️ After-Hours Vatican Tour - Skip the crowds", - "🍝 Pasta Making Class in Trastevere", - "🎵 Classical Concert at Borghese Gallery", - "🍷 Wine Tasting in Roman Cellars", - ], - } - - # Find events for the destination or use generic events - events = [ - "🎭 Local theater performance", - "🍽️ Food and wine festival", - "🎨 Art gallery opening", - "🎵 Live music at local venues", - ] - - for city, city_events in events_by_city.items(): - if city.lower() in destination.lower(): - events = city_events - break - - event_list = "\n• ".join(events) - return f"""Local events in {destination} around {date}: - -• {event_list} - -💡 Tip: Book popular events in advance as they may sell out quickly!""" - - -def _get_weather_recommendation(condition: str) -> str: - """Get a recommendation based on weather conditions. - - Args: - condition: The weather condition description. - - Returns: - A recommendation string. - """ - condition_lower = condition.lower() - - if "rain" in condition_lower or "drizzle" in condition_lower: - return "Bring an umbrella and waterproof jacket. Consider indoor activities for backup." - if "fog" in condition_lower: - return "Morning visibility may be limited. Plan outdoor sightseeing for afternoon." - if "cold" in condition_lower: - return "Layer up with warm clothing. Hot drinks and cozy cafés recommended." - if "hot" in condition_lower or "warm" in condition_lower: - return "Stay hydrated and use sunscreen. Plan strenuous activities for cooler morning hours." - if "thunder" in condition_lower or "storm" in condition_lower: - return "Keep an eye on weather updates. Have indoor alternatives ready." - return "Pleasant conditions expected. Great day for outdoor exploration!" diff --git a/python/samples/04-hosting/azure_functions/04_single_agent_orchestration_chaining/README.md b/python/samples/04-hosting/azure_functions/04_single_agent_orchestration_chaining/README.md deleted file mode 100644 index 332c03d3780..00000000000 --- a/python/samples/04-hosting/azure_functions/04_single_agent_orchestration_chaining/README.md +++ /dev/null @@ -1,53 +0,0 @@ -# Single Agent Orchestration Sample (Python) - -This sample shows how to chain two invocations of the same agent inside a Durable Functions orchestration while -preserving the conversation state between runs. - -## Key Concepts -- Deterministic orchestrations that make sequential agent calls on a shared session -- Reusing an agent session to carry conversation history across invocations -- HTTP endpoints for starting the orchestration and polling for status/output - -## Prerequisites - -Start with the shared setup instructions in `../README.md` to create a virtual environment, install dependencies, and configure Azure OpenAI and storage settings. - -## Running the Sample -Start the orchestration: - -```bash -curl -X POST http://localhost:7071/api/singleagent/run -``` - -Poll the returned `statusQueryGetUri` until completion: - -```bash -curl http://localhost:7071/api/singleagent/status/ -``` - -> **Note:** The underlying agent run endpoint now waits for responses by default. If you invoke it directly and prefer an immediate HTTP 202, set the `x-ms-wait-for-response` header or include `"wait_for_response": false` in the payload. - -The orchestration first requests an inspirational sentence from the agent, then refines the initial response while -keeping it under 25 words—mirroring the behaviour of the corresponding .NET sample. - -## Expected Output - -Sample response when starting the orchestration: - -```json -{ - "message": "Single-agent orchestration started.", - "instanceId": "ebb5c1df123e4d6fb8e7d703ffd0d0b0", - "statusQueryGetUri": "http://localhost:7071/api/singleagent/status/ebb5c1df123e4d6fb8e7d703ffd0d0b0" -} -``` - -Sample completed status payload: - -```json -{ - "instanceId": "ebb5c1df123e4d6fb8e7d703ffd0d0b0", - "runtimeStatus": "Completed", - "output": "Learning is a journey where curiosity turns effort into mastery." -} -``` diff --git a/python/samples/04-hosting/azure_functions/04_single_agent_orchestration_chaining/demo.http b/python/samples/04-hosting/azure_functions/04_single_agent_orchestration_chaining/demo.http deleted file mode 100644 index 74a45538c67..00000000000 --- a/python/samples/04-hosting/azure_functions/04_single_agent_orchestration_chaining/demo.http +++ /dev/null @@ -1,9 +0,0 @@ -### Start the single-agent orchestration -POST http://localhost:7071/api/singleagent/run - - -### Check the status of the orchestration - -@instanceId = - -GET http://localhost:7071/api/singleagent/status/{{instanceId}} \ No newline at end of file diff --git a/python/samples/04-hosting/azure_functions/04_single_agent_orchestration_chaining/function_app.py b/python/samples/04-hosting/azure_functions/04_single_agent_orchestration_chaining/function_app.py deleted file mode 100644 index c3bdcf963fc..00000000000 --- a/python/samples/04-hosting/azure_functions/04_single_agent_orchestration_chaining/function_app.py +++ /dev/null @@ -1,174 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Chain two runs of a single agent inside a Durable Functions orchestration. - -Components used in this sample: -- FoundryChatClient to construct the writer agent hosted by Agent Framework. -- AgentFunctionApp to surface HTTP and orchestration triggers via the Azure Functions extension. -- Durable Functions orchestration to run sequential agent invocations on the same conversation session. - -Prerequisites: configure `FOUNDRY_PROJECT_ENDPOINT`, `FOUNDRY_MODEL`, and sign in with Azure CLI before starting the Functions host.""" - -import json -import logging -import os -from collections.abc import Generator -from typing import Any - -import azure.functions as func -from agent_framework import Agent -from agent_framework.azure import AgentFunctionApp -from agent_framework.foundry import FoundryChatClient -from azure.durable_functions import DurableOrchestrationClient, DurableOrchestrationContext -from azure.identity.aio import AzureCliCredential - -logger = logging.getLogger(__name__) - -# 1. Define the agent name used across the orchestration. -WRITER_AGENT_NAME = "WriterAgent" - - -# 2. Create the writer agent that will be invoked twice within the orchestration. -def _create_writer_agent() -> Any: - """Create the writer agent with the same persona as the C# sample.""" - instructions = ( - "You refine short pieces of text. When given an initial sentence you enhance it;\n" - "when given an improved sentence you polish it further." - ) - - _client = FoundryChatClient( - project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], - model=os.environ["FOUNDRY_MODEL"], - credential=AzureCliCredential(), - ) - return Agent( - client=_client, - name=WRITER_AGENT_NAME, - instructions=instructions, - ) - - -# 3. Register the agent with AgentFunctionApp so HTTP and orchestration triggers are exposed. -app = AgentFunctionApp(agents=[_create_writer_agent()], enable_health_check=True) - - -# 4. Orchestration that runs the agent sequentially on a shared session for chaining behaviour. -@app.orchestration_trigger(context_name="context") -def single_agent_orchestration(context: DurableOrchestrationContext) -> Generator[Any, Any, str]: - """Run the writer agent twice on the same session to mirror chaining behaviour.""" - - writer = app.get_agent(context, WRITER_AGENT_NAME) - writer_session = writer.create_session() - - initial = yield writer.run( - messages="Write a concise inspirational sentence about learning.", - session=writer_session, - ) - - improved_prompt = f"Improve this further while keeping it under 25 words: {initial.text}" - - refined = yield writer.run( - messages=improved_prompt, - session=writer_session, - ) - - return refined.text - - -# 5. HTTP endpoint to kick off the orchestration and return the status query URI. -@app.route(route="singleagent/run", methods=["POST"]) -@app.durable_client_input(client_name="client") -async def start_single_agent_orchestration( - req: func.HttpRequest, - client: DurableOrchestrationClient, -) -> func.HttpResponse: - """Start the orchestration and return status metadata.""" - - instance_id = await client.start_new( - orchestration_function_name="single_agent_orchestration", - ) - - logger.info("[HTTP] Started orchestration with instance_id: %s", instance_id) - - status_url = _build_status_url(req.url, instance_id, route="singleagent") - - payload = { - "message": "Single-agent orchestration started.", - "instanceId": instance_id, - "statusQueryGetUri": status_url, - } - - return func.HttpResponse( - body=json.dumps(payload), - status_code=202, - mimetype="application/json", - ) - - -# 6. HTTP endpoint to fetch orchestration status using the original instance ID. -@app.route(route="singleagent/status/{instanceId}", methods=["GET"]) -@app.durable_client_input(client_name="client") -async def get_orchestration_status( - req: func.HttpRequest, - client: DurableOrchestrationClient, -) -> func.HttpResponse: - """Return orchestration runtime status.""" - - instance_id = req.route_params.get("instanceId") - if not instance_id: - return func.HttpResponse( - body=json.dumps({"error": "Missing instanceId"}), - status_code=400, - mimetype="application/json", - ) - - status = await client.get_status(instance_id) - - response_data: dict[str, Any] = { - "instanceId": status.instance_id, - "runtimeStatus": status.runtime_status.name if status.runtime_status else None, - } - - if status.input_ is not None: - response_data["input"] = status.input_ - - if status.output is not None: - response_data["output"] = status.output - - return func.HttpResponse( - body=json.dumps(response_data), - status_code=200, - mimetype="application/json", - ) - - -# 7. Helper to construct durable status URLs similar to the .NET sample implementation. -def _build_status_url(request_url: str, instance_id: str, *, route: str) -> str: - """Construct the status query URI similar to DurableHttpApiExtensions in C#.""" - - # Split once on /api/ to preserve host and scheme in local emulator and Azure. - base_url, _, _ = request_url.partition("/api/") - if not base_url: - base_url = request_url.rstrip("/") - return f"{base_url}/api/{route}/status/{instance_id}" - - -""" -Expected output when calling `POST /api/singleagent/run` and following the returned status URL: - -HTTP/1.1 202 Accepted -{ - "message": "Single-agent orchestration started.", - "instanceId": "", - "statusQueryGetUri": "http://localhost:7071/api/singleagent/status/" -} - -Subsequent `GET /api/singleagent/status/` after completion returns: - -HTTP/1.1 200 OK -{ - "instanceId": "", - "runtimeStatus": "Completed", - "output": "Learning is a journey where curiosity turns effort into mastery." -} -""" diff --git a/python/samples/04-hosting/azure_functions/04_single_agent_orchestration_chaining/host.json b/python/samples/04-hosting/azure_functions/04_single_agent_orchestration_chaining/host.json deleted file mode 100644 index 9e7fd873dda..00000000000 --- a/python/samples/04-hosting/azure_functions/04_single_agent_orchestration_chaining/host.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "version": "2.0", - "extensionBundle": { - "id": "Microsoft.Azure.Functions.ExtensionBundle", - "version": "[4.*, 5.0.0)" - }, - "extensions": { - "durableTask": { - "hubName": "%TASKHUB_NAME%" - } - } -} diff --git a/python/samples/04-hosting/azure_functions/04_single_agent_orchestration_chaining/local.settings.json.template b/python/samples/04-hosting/azure_functions/04_single_agent_orchestration_chaining/local.settings.json.template deleted file mode 100644 index 1d8bc82e392..00000000000 --- a/python/samples/04-hosting/azure_functions/04_single_agent_orchestration_chaining/local.settings.json.template +++ /dev/null @@ -1,11 +0,0 @@ -{ - "IsEncrypted": false, - "Values": { - "FUNCTIONS_WORKER_RUNTIME": "python", - "AzureWebJobsStorage": "UseDevelopmentStorage=true", - "DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None", - "TASKHUB_NAME": "default", - "FOUNDRY_PROJECT_ENDPOINT": "", - "FOUNDRY_MODEL": "" - } -} diff --git a/python/samples/04-hosting/azure_functions/04_single_agent_orchestration_chaining/requirements.txt b/python/samples/04-hosting/azure_functions/04_single_agent_orchestration_chaining/requirements.txt deleted file mode 100644 index b71a6092a70..00000000000 --- a/python/samples/04-hosting/azure_functions/04_single_agent_orchestration_chaining/requirements.txt +++ /dev/null @@ -1,12 +0,0 @@ -# Agent Framework packages -# To use the deployed version, uncomment the lines below and comment out the local installation lines -# agent-framework-foundry -# agent-framework-azurefunctions - -# Local installation (for development and testing) -# Each package must be listed explicitly because pip doesn't resolve uv workspace sources. -# Without explicit entries, pip would fetch transitive dependencies from PyPI instead of local source. --e ../../../../packages/core # Core framework - base dependency for all packages --e ../../../../packages/foundry # Foundry support - dependency for hosted chat/agent samples --e ../../../../packages/durabletask # Durable Task support - dependency of azurefunctions --e ../../../../packages/azurefunctions # Azure Functions integration - the main package for this sample diff --git a/python/samples/04-hosting/azure_functions/05_multi_agent_orchestration_concurrency/README.md b/python/samples/04-hosting/azure_functions/05_multi_agent_orchestration_concurrency/README.md deleted file mode 100644 index 33f8606b774..00000000000 --- a/python/samples/04-hosting/azure_functions/05_multi_agent_orchestration_concurrency/README.md +++ /dev/null @@ -1,58 +0,0 @@ -# Multi-Agent Orchestration (Concurrency) – Python - -This sample starts a Durable Functions orchestration that runs two agents in parallel and merges their responses. - -## Highlights -- Two agents (`PhysicistAgent` and `ChemistAgent`) share a single Azure OpenAI deployment configuration. -- The orchestration uses `context.task_all(...)` to safely run both agents concurrently. -- HTTP routes (`/api/multiagent/run` and `/api/multiagent/status/{instanceId}`) mirror the .NET sample for parity. - -## Prerequisites - -Use the shared setup instructions in `../README.md` to prepare the environment, install dependencies, and configure Azure OpenAI and storage settings before running this sample. - -## Running the Sample -Start the orchestration: - -```bash -curl -X POST \ - -H "Content-Type: text/plain" \ - --data "What is temperature?" \ - http://localhost:7071/api/multiagent/run -``` - -Poll the returned `statusQueryGetUri` until completion: - -```bash -curl http://localhost:7071/api/multiagent/status/ -``` - -> **Note:** The agent run endpoints wait for responses by default. If you call them directly and need an immediate HTTP 202, set the `x-ms-wait-for-response` header or include `"wait_for_response": false` in the request payload. - -The orchestration launches both agents simultaneously so their domain-specific answers can be combined for the caller. - -## Expected Output - -Example response when starting the orchestration: - -```json -{ - "message": "Multi-agent concurrent orchestration started.", - "prompt": "What is temperature?", - "instanceId": "94d56266f0a04e5a8f9f3a1f77a4c597", - "statusQueryGetUri": "http://localhost:7071/api/multiagent/status/94d56266f0a04e5a8f9f3a1f77a4c597" -} -``` - -Example completed status payload: - -```json -{ - "instanceId": "94d56266f0a04e5a8f9f3a1f77a4c597", - "runtimeStatus": "Completed", - "output": { - "physicist": "Temperature measures the average kinetic energy of particles in a system.", - "chemist": "Temperature reflects how molecular motion influences reaction rates and equilibria." - } -} -``` diff --git a/python/samples/04-hosting/azure_functions/05_multi_agent_orchestration_concurrency/demo.http b/python/samples/04-hosting/azure_functions/05_multi_agent_orchestration_concurrency/demo.http deleted file mode 100644 index 28f3cdc2838..00000000000 --- a/python/samples/04-hosting/azure_functions/05_multi_agent_orchestration_concurrency/demo.http +++ /dev/null @@ -1,11 +0,0 @@ -### Start the multi-agent concurrent orchestration -POST http://localhost:7071/api/multiagent/run -Content-Type: text/plain - -What is temperature? - -### Check the status of the orchestration - -@instanceId = - -GET http://localhost:7071/api/multiagent/status/{{instanceId}} diff --git a/python/samples/04-hosting/azure_functions/05_multi_agent_orchestration_concurrency/function_app.py b/python/samples/04-hosting/azure_functions/05_multi_agent_orchestration_concurrency/function_app.py deleted file mode 100644 index 4f68c5237f9..00000000000 --- a/python/samples/04-hosting/azure_functions/05_multi_agent_orchestration_concurrency/function_app.py +++ /dev/null @@ -1,206 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Fan out concurrent runs across two agents inside a Durable Functions orchestration. - -Components used in this sample: -- FoundryChatClient to create domain-specific agents hosted by Agent Framework. -- AgentFunctionApp to expose orchestration and HTTP triggers. -- Durable Functions orchestration that executes agent calls in parallel and aggregates results. - -Prerequisites: configure `FOUNDRY_PROJECT_ENDPOINT`, `FOUNDRY_MODEL`, and sign in with Azure CLI before starting the Functions host.""" - -import json -import logging -import os -from collections.abc import Generator -from typing import Any, cast - -import azure.functions as func -from agent_framework import Agent, AgentResponse -from agent_framework.azure import AgentFunctionApp -from agent_framework.foundry import FoundryChatClient -from azure.durable_functions import DurableOrchestrationClient, DurableOrchestrationContext -from azure.identity.aio import AzureCliCredential -from dotenv import load_dotenv - -# Load environment variables from .env file -load_dotenv() - -logger = logging.getLogger(__name__) - -# 1. Define agent names shared across the orchestration. -PHYSICIST_AGENT_NAME = "PhysicistAgent" -CHEMIST_AGENT_NAME = "ChemistAgent" - - -# 2. Instantiate both agents that the orchestration will run concurrently. -def _create_agents() -> list[Any]: - physicist = Agent( - client=FoundryChatClient( - project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], - model=os.environ["FOUNDRY_MODEL"], - credential=AzureCliCredential(), - ), - name=PHYSICIST_AGENT_NAME, - instructions="You are an expert in physics. You answer questions from a physics perspective.", - ) - - chemist = Agent( - client=FoundryChatClient( - project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], - model=os.environ["FOUNDRY_MODEL"], - credential=AzureCliCredential(), - ), - name=CHEMIST_AGENT_NAME, - instructions="You are an expert in chemistry. You answer questions from a chemistry perspective.", - ) - - return [physicist, chemist] - - -# 3. Register both agents with AgentFunctionApp and selectively enable HTTP endpoints. -agents = _create_agents() -app = AgentFunctionApp(enable_health_check=True, enable_http_endpoints=False) -app.add_agent(agents[0], enable_http_endpoint=True) -app.add_agent(agents[1]) - - -# 4. Durable Functions orchestration that runs both agents in parallel. -@app.orchestration_trigger(context_name="context") -def multi_agent_concurrent_orchestration(context: DurableOrchestrationContext) -> Generator[Any, Any, dict[str, str]]: - """Fan out to two domain-specific agents and aggregate their responses.""" - prompt = context.get_input() - if not prompt or not str(prompt).strip(): - raise ValueError("Prompt is required") - - physicist = app.get_agent(context, PHYSICIST_AGENT_NAME) - chemist = app.get_agent(context, CHEMIST_AGENT_NAME) - - physicist_session = physicist.create_session() - chemist_session = chemist.create_session() - - # Create tasks from agent.run() calls - physicist_task = physicist.run(messages=str(prompt), session=physicist_session) - chemist_task = chemist.run(messages=str(prompt), session=chemist_session) - - # Execute both tasks concurrently using task_all - task_results = yield context.task_all([physicist_task, chemist_task]) - - physicist_result = cast(AgentResponse, task_results[0]) - chemist_result = cast(AgentResponse, task_results[1]) - - return { - "physicist": physicist_result.text, - "chemist": chemist_result.text, - } - - -# 5. HTTP endpoint to accept prompts and start the concurrent orchestration. -@app.route(route="multiagent/run", methods=["POST"]) -@app.durable_client_input(client_name="client") -async def start_multi_agent_concurrent_orchestration( - req: func.HttpRequest, - client: DurableOrchestrationClient, -) -> func.HttpResponse: - """Kick off the orchestration with a plain text prompt.""" - - body_bytes = req.get_body() or b"" - prompt = body_bytes.decode("utf-8", errors="replace").strip() - if not prompt: - return func.HttpResponse( - body=json.dumps({"error": "Prompt is required"}), - status_code=400, - mimetype="application/json", - ) - - instance_id = await client.start_new( - orchestration_function_name="multi_agent_concurrent_orchestration", - client_input=prompt, - ) - - logger.info("[HTTP] Started orchestration with instance_id: %s", instance_id) - - status_url = _build_status_url(req.url, instance_id, route="multiagent") - - payload = { - "message": "Multi-agent concurrent orchestration started.", - "prompt": prompt, - "instanceId": instance_id, - "statusQueryGetUri": status_url, - } - - return func.HttpResponse( - body=json.dumps(payload), - status_code=202, - mimetype="application/json", - ) - - -# 6. HTTP endpoint to retrieve orchestration status and aggregated outputs. -@app.route(route="multiagent/status/{instanceId}", methods=["GET"]) -@app.durable_client_input(client_name="client") -async def get_orchestration_status( - req: func.HttpRequest, - client: DurableOrchestrationClient, -) -> func.HttpResponse: - instance_id = req.route_params.get("instanceId") - if not instance_id: - return func.HttpResponse( - body=json.dumps({"error": "Missing instanceId"}), - status_code=400, - mimetype="application/json", - ) - - status = await client.get_status(instance_id) - - response_data: dict[str, Any] = { - "instanceId": status.instance_id, - "runtimeStatus": status.runtime_status.name if status.runtime_status else None, - "createdTime": status.created_time.isoformat() if status.created_time else None, - "lastUpdatedTime": status.last_updated_time.isoformat() if status.last_updated_time else None, - } - - if status.input_ is not None: - response_data["input"] = status.input_ - - if status.output is not None: - response_data["output"] = status.output - - return func.HttpResponse( - body=json.dumps(response_data), - status_code=200, - mimetype="application/json", - ) - - -# 7. Helper to construct durable status URLs. -def _build_status_url(request_url: str, instance_id: str, *, route: str) -> str: - base_url, _, _ = request_url.partition("/api/") - if not base_url: - base_url = request_url.rstrip("/") - return f"{base_url}/api/{route}/status/{instance_id}" - - -""" -Expected output when calling `POST /api/multiagent/run` with a plain-text prompt: - -HTTP/1.1 202 Accepted -{ - "message": "Multi-agent concurrent orchestration started.", - "prompt": "What is temperature?", - "instanceId": "", - "statusQueryGetUri": "http://localhost:7071/api/multiagent/status/" -} - -Polling `GET /api/multiagent/status/` after completion returns: - -HTTP/1.1 200 OK -{ - "instanceId": "", - "runtimeStatus": "Completed", - "output": { - "physicist": "Temperature measures the average kinetic energy of particles in a system.", - "chemist": "Temperature reflects how molecular motion influences reaction rates and equilibria." - } -} -""" diff --git a/python/samples/04-hosting/azure_functions/05_multi_agent_orchestration_concurrency/host.json b/python/samples/04-hosting/azure_functions/05_multi_agent_orchestration_concurrency/host.json deleted file mode 100644 index 9e7fd873dda..00000000000 --- a/python/samples/04-hosting/azure_functions/05_multi_agent_orchestration_concurrency/host.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "version": "2.0", - "extensionBundle": { - "id": "Microsoft.Azure.Functions.ExtensionBundle", - "version": "[4.*, 5.0.0)" - }, - "extensions": { - "durableTask": { - "hubName": "%TASKHUB_NAME%" - } - } -} diff --git a/python/samples/04-hosting/azure_functions/05_multi_agent_orchestration_concurrency/local.settings.json.template b/python/samples/04-hosting/azure_functions/05_multi_agent_orchestration_concurrency/local.settings.json.template deleted file mode 100644 index 1d8bc82e392..00000000000 --- a/python/samples/04-hosting/azure_functions/05_multi_agent_orchestration_concurrency/local.settings.json.template +++ /dev/null @@ -1,11 +0,0 @@ -{ - "IsEncrypted": false, - "Values": { - "FUNCTIONS_WORKER_RUNTIME": "python", - "AzureWebJobsStorage": "UseDevelopmentStorage=true", - "DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None", - "TASKHUB_NAME": "default", - "FOUNDRY_PROJECT_ENDPOINT": "", - "FOUNDRY_MODEL": "" - } -} diff --git a/python/samples/04-hosting/azure_functions/05_multi_agent_orchestration_concurrency/requirements.txt b/python/samples/04-hosting/azure_functions/05_multi_agent_orchestration_concurrency/requirements.txt deleted file mode 100644 index b71a6092a70..00000000000 --- a/python/samples/04-hosting/azure_functions/05_multi_agent_orchestration_concurrency/requirements.txt +++ /dev/null @@ -1,12 +0,0 @@ -# Agent Framework packages -# To use the deployed version, uncomment the lines below and comment out the local installation lines -# agent-framework-foundry -# agent-framework-azurefunctions - -# Local installation (for development and testing) -# Each package must be listed explicitly because pip doesn't resolve uv workspace sources. -# Without explicit entries, pip would fetch transitive dependencies from PyPI instead of local source. --e ../../../../packages/core # Core framework - base dependency for all packages --e ../../../../packages/foundry # Foundry support - dependency for hosted chat/agent samples --e ../../../../packages/durabletask # Durable Task support - dependency of azurefunctions --e ../../../../packages/azurefunctions # Azure Functions integration - the main package for this sample diff --git a/python/samples/04-hosting/azure_functions/06_multi_agent_orchestration_conditionals/README.md b/python/samples/04-hosting/azure_functions/06_multi_agent_orchestration_conditionals/README.md deleted file mode 100644 index da38bf0dd6f..00000000000 --- a/python/samples/04-hosting/azure_functions/06_multi_agent_orchestration_conditionals/README.md +++ /dev/null @@ -1,35 +0,0 @@ -# Multi-Agent Orchestration (Conditionals) – Python - -This sample evaluates incoming emails with a spam detector agent and, -when appropriate, drafts a response using an email assistant agent. - -## Prerequisites - -Set up the shared prerequisites outlined in `../README.md`, including the virtual environment, dependency installation, and Azure OpenAI and storage configuration. - -## Scenario Overview -- Two Azure OpenAI agents share a single deployment: one flags spam, the other drafts replies. -- Structured responses (`is_spam` and `reason`, or `response`) determine which orchestration branch runs. -- Activity functions handle the side effects of spam handling and email sending. - -## Running the Sample -Submit an email payload: - -```bash -curl -X POST "http://localhost:7071/api/spamdetection/run" \ - -H "Content-Type: application/json" \ - -d '{"email_id": "email-001", "email_content": "URGENT! You'\''ve won $1,000,000! Click here now to claim your prize! Limited time offer! Don'\''t miss out!"}' -``` - -Poll the returned `statusQueryGetUri` or call the status route directly: - -```bash -curl http://localhost:7071/api/spamdetection/status/ -``` - -> **Note:** The spam detection run endpoint waits for responses by default. To opt into an immediate HTTP 202, set the `x-ms-wait-for-response` header or include `"wait_for_response": false` in the POST body. - -## Expected Responses -- Spam payloads return `Email marked as spam: ` by invoking the `handle_spam_email` activity. -- Legitimate emails return `Email sent: ` after the email assistant agent produces a structured reply. -- The status endpoint mirrors Durable Functions metadata, including runtime status and the agent output. diff --git a/python/samples/04-hosting/azure_functions/06_multi_agent_orchestration_conditionals/demo.http b/python/samples/04-hosting/azure_functions/06_multi_agent_orchestration_conditionals/demo.http deleted file mode 100644 index 44b49c5c46e..00000000000 --- a/python/samples/04-hosting/azure_functions/06_multi_agent_orchestration_conditionals/demo.http +++ /dev/null @@ -1,24 +0,0 @@ -### 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!" -} - - -### Check the status of the orchestration -@instanceId = - -GET http://localhost:7071/api/spamdetection/status/{{instanceId}} diff --git a/python/samples/04-hosting/azure_functions/06_multi_agent_orchestration_conditionals/function_app.py b/python/samples/04-hosting/azure_functions/06_multi_agent_orchestration_conditionals/function_app.py deleted file mode 100644 index 76fbec16bef..00000000000 --- a/python/samples/04-hosting/azure_functions/06_multi_agent_orchestration_conditionals/function_app.py +++ /dev/null @@ -1,265 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Route email requests through conditional orchestration with two agents. - -Components used in this sample: -- FoundryChatClient agents for spam detection and email drafting. -- AgentFunctionApp with Durable orchestration, activity, and HTTP triggers. -- Pydantic models that validate payloads and agent JSON responses. - -Prerequisites: set `FOUNDRY_PROJECT_ENDPOINT`, `FOUNDRY_MODEL`, and sign in with Azure CLI before running the -Functions host.""" - -import json -import logging -import os -from collections.abc import Generator, Mapping -from typing import Any - -import azure.functions as func -from agent_framework import Agent -from agent_framework.azure import AgentFunctionApp -from agent_framework.foundry import FoundryChatClient -from azure.durable_functions import DurableOrchestrationClient, DurableOrchestrationContext -from azure.identity.aio import AzureCliCredential -from pydantic import BaseModel, ValidationError - -logger = logging.getLogger(__name__) - -# 1. Define agent names shared across the orchestration. -SPAM_AGENT_NAME = "SpamDetectionAgent" -EMAIL_AGENT_NAME = "EmailAssistantAgent" - - -class SpamDetectionResult(BaseModel): - is_spam: bool - reason: str - - -class EmailResponse(BaseModel): - response: str - - -class EmailPayload(BaseModel): - email_id: str - email_content: str - - -# 2. Instantiate both agents so they can be registered with AgentFunctionApp. -def _create_agents() -> list[Any]: - client = FoundryChatClient( - project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], - model=os.environ["FOUNDRY_MODEL"], - credential=AzureCliCredential(), - ) - - spam_agent = Agent( - client=client, - name=SPAM_AGENT_NAME, - instructions="You are a spam detection assistant that identifies spam emails.", - ) - - email_agent = Agent( - client=client, - name=EMAIL_AGENT_NAME, - instructions="You are an email assistant that helps users draft responses to emails with professionalism.", - ) - - return [spam_agent, email_agent] - - -app = AgentFunctionApp(agents=_create_agents(), enable_health_check=True) - - -# 3. Activities handle the side effects for spam and legitimate emails. -@app.activity_trigger(input_name="reason") -def handle_spam_email(reason: str) -> str: - return f"Email marked as spam: {reason}" - - -@app.activity_trigger(input_name="message") -def send_email(message: str) -> str: - return f"Email sent: {message}" - - -# 4. Orchestration validates input, runs agents, and branches on spam results. -@app.orchestration_trigger(context_name="context") -def spam_detection_orchestration(context: DurableOrchestrationContext) -> Generator[Any, Any, str]: - payload_raw = context.get_input() - if not isinstance(payload_raw, Mapping): - raise ValueError("Email data is required") - - try: - payload = EmailPayload.model_validate(payload_raw) - except ValidationError as exc: - raise ValueError(f"Invalid email payload: {exc}") from exc - - spam_agent = app.get_agent(context, SPAM_AGENT_NAME) - email_agent = app.get_agent(context, EMAIL_AGENT_NAME) - - spam_session = spam_agent.create_session() - - spam_prompt = ( - "Analyze this email for spam content and return a JSON response with 'is_spam' (boolean) " - "and 'reason' (string) fields:\n" - f"Email ID: {payload.email_id}\n" - f"Content: {payload.email_content}" - ) - - spam_result_raw = yield spam_agent.run( - messages=spam_prompt, - session=spam_session, - options={"response_format": SpamDetectionResult}, - ) - - try: - spam_result = spam_result_raw.value - except Exception as ex: - raise ValueError("Failed to parse spam detection result") from ex - - if spam_result.is_spam: - result = yield context.call_activity("handle_spam_email", spam_result.reason) # type: ignore[misc] - return result - - email_session = email_agent.create_session() - - email_prompt = ( - "Draft a professional response to this email. Return a JSON response with a 'response' field " - "containing the reply:\n\n" - f"Email ID: {payload.email_id}\n" - f"Content: {payload.email_content}" - ) - - email_result_raw = yield email_agent.run( - messages=email_prompt, - session=email_session, - options={"response_format": EmailResponse}, - ) - - try: - email_result = email_result_raw.value - except Exception as ex: - raise ValueError("Failed to parse email response") from ex - - result = yield context.call_activity("send_email", email_result.response) # type: ignore[misc] - return result - - -# 5. HTTP starter endpoint launches the orchestration for each email payload. -@app.route(route="spamdetection/run", methods=["POST"]) -@app.durable_client_input(client_name="client") -async def start_spam_detection_orchestration( - req: func.HttpRequest, - client: DurableOrchestrationClient, -) -> func.HttpResponse: - try: - body = req.get_json() - except ValueError: - body = None - - if not isinstance(body, Mapping): - return func.HttpResponse( - body=json.dumps({"error": "Email data is required"}), - status_code=400, - mimetype="application/json", - ) - - try: - payload = EmailPayload.model_validate(body) - except ValidationError as exc: - return func.HttpResponse( - body=json.dumps({"error": f"Invalid email payload: {exc}"}), - status_code=400, - mimetype="application/json", - ) - - instance_id = await client.start_new( - orchestration_function_name="spam_detection_orchestration", - client_input=payload.model_dump(), - ) - - logger.info("[HTTP] Started spam detection orchestration with instance_id: %s", instance_id) - - status_url = _build_status_url(req.url, instance_id, route="spamdetection") - - payload_json = { - "message": "Spam detection orchestration started.", - "emailId": payload.email_id, - "instanceId": instance_id, - "statusQueryGetUri": status_url, - } - - return func.HttpResponse( - body=json.dumps(payload_json), - status_code=202, - mimetype="application/json", - ) - - -# 6. Status endpoint mirrors Durable Functions default payload with agent data. -@app.route(route="spamdetection/status/{instanceId}", methods=["GET"]) -@app.durable_client_input(client_name="client") -async def get_orchestration_status( - req: func.HttpRequest, - client: DurableOrchestrationClient, -) -> func.HttpResponse: - instance_id = req.route_params.get("instanceId") - if not instance_id: - return func.HttpResponse( - body=json.dumps({"error": "Missing instanceId"}), - status_code=400, - mimetype="application/json", - ) - - status = await client.get_status(instance_id) - - response_data: dict[str, Any] = { - "instanceId": status.instance_id, - "runtimeStatus": status.runtime_status.name if status.runtime_status else None, - "createdTime": status.created_time.isoformat() if status.created_time else None, - "lastUpdatedTime": status.last_updated_time.isoformat() if status.last_updated_time else None, - } - - if status.input_ is not None: - response_data["input"] = status.input_ - - if status.output is not None: - response_data["output"] = status.output - - return func.HttpResponse( - body=json.dumps(response_data), - status_code=200, - mimetype="application/json", - ) - - -# 7. Helper utilities keep URL construction and structured parsing deterministic. -def _build_status_url(request_url: str, instance_id: str, *, route: str) -> str: - base_url, _, _ = request_url.partition("/api/") - if not base_url: - base_url = request_url.rstrip("/") - return f"{base_url}/api/{route}/status/{instance_id}" - - -""" -Expected response from `POST /api/spamdetection/run`: - -HTTP/1.1 202 Accepted -{ - "message": "Spam detection orchestration started.", - "emailId": "123", - "instanceId": "", - "statusQueryGetUri": "http://localhost:7071/runtime/webhooks/durabletask/instances/" -} - -Expected response from `GET /api/spamdetection/status/{instanceId}` once complete: - -HTTP/1.1 200 OK -{ - "instanceId": "", - "runtimeStatus": "Completed", - "createdTime": "2024-01-01T00:00:00+00:00", - "lastUpdatedTime": "2024-01-01T00:00:10+00:00", - "output": "Email sent: Thank you for reaching out..." -} -""" diff --git a/python/samples/04-hosting/azure_functions/06_multi_agent_orchestration_conditionals/host.json b/python/samples/04-hosting/azure_functions/06_multi_agent_orchestration_conditionals/host.json deleted file mode 100644 index 9e7fd873dda..00000000000 --- a/python/samples/04-hosting/azure_functions/06_multi_agent_orchestration_conditionals/host.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "version": "2.0", - "extensionBundle": { - "id": "Microsoft.Azure.Functions.ExtensionBundle", - "version": "[4.*, 5.0.0)" - }, - "extensions": { - "durableTask": { - "hubName": "%TASKHUB_NAME%" - } - } -} diff --git a/python/samples/04-hosting/azure_functions/06_multi_agent_orchestration_conditionals/local.settings.json.template b/python/samples/04-hosting/azure_functions/06_multi_agent_orchestration_conditionals/local.settings.json.template deleted file mode 100644 index 1d8bc82e392..00000000000 --- a/python/samples/04-hosting/azure_functions/06_multi_agent_orchestration_conditionals/local.settings.json.template +++ /dev/null @@ -1,11 +0,0 @@ -{ - "IsEncrypted": false, - "Values": { - "FUNCTIONS_WORKER_RUNTIME": "python", - "AzureWebJobsStorage": "UseDevelopmentStorage=true", - "DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None", - "TASKHUB_NAME": "default", - "FOUNDRY_PROJECT_ENDPOINT": "", - "FOUNDRY_MODEL": "" - } -} diff --git a/python/samples/04-hosting/azure_functions/06_multi_agent_orchestration_conditionals/requirements.txt b/python/samples/04-hosting/azure_functions/06_multi_agent_orchestration_conditionals/requirements.txt deleted file mode 100644 index b71a6092a70..00000000000 --- a/python/samples/04-hosting/azure_functions/06_multi_agent_orchestration_conditionals/requirements.txt +++ /dev/null @@ -1,12 +0,0 @@ -# Agent Framework packages -# To use the deployed version, uncomment the lines below and comment out the local installation lines -# agent-framework-foundry -# agent-framework-azurefunctions - -# Local installation (for development and testing) -# Each package must be listed explicitly because pip doesn't resolve uv workspace sources. -# Without explicit entries, pip would fetch transitive dependencies from PyPI instead of local source. --e ../../../../packages/core # Core framework - base dependency for all packages --e ../../../../packages/foundry # Foundry support - dependency for hosted chat/agent samples --e ../../../../packages/durabletask # Durable Task support - dependency of azurefunctions --e ../../../../packages/azurefunctions # Azure Functions integration - the main package for this sample diff --git a/python/samples/04-hosting/azure_functions/07_single_agent_orchestration_hitl/README.md b/python/samples/04-hosting/azure_functions/07_single_agent_orchestration_hitl/README.md deleted file mode 100644 index 4e90f3fb26c..00000000000 --- a/python/samples/04-hosting/azure_functions/07_single_agent_orchestration_hitl/README.md +++ /dev/null @@ -1,48 +0,0 @@ -# Single-Agent Orchestration (HITL) – Python - -This sample demonstrates the human-in-the-loop (HITL) scenario. -A single writer agent iterates on content until a human reviewer approves the -output or a maximum number of attempts is reached. - -## Prerequisites - -Complete the common setup instructions in `../README.md` to prepare the virtual environment, install dependencies, and configure Foundry and storage settings. - -## What It Shows -- Identical environment variable usage (`FOUNDRY_PROJECT_ENDPOINT`, - `FOUNDRY_MODEL`) and HTTP surface area (`/api/hitl/...`). -- Durable orchestrations that pause for external events while maintaining - deterministic state (`context.wait_for_external_event` + timed cancellation). -- Activity functions that encapsulate the out-of-band operations such as notifying -a reviewer and publishing content. - -## Running the Sample -Start the HITL orchestration: - -```bash -curl -X POST http://localhost:7071/api/hitl/run \ - -H "Content-Type: application/json" \ - -d '{"topic": "Write a friendly release note"}' -``` - -Poll the returned `statusQueryGetUri` or call the status route directly: - -```bash -curl http://localhost:7071/api/hitl/status/ -``` - -Approve or reject the draft: - -```bash -curl -X POST http://localhost:7071/api/hitl/approve/ \ - -H "Content-Type: application/json" \ - -d '{"approved": true, "feedback": "Looks good"}' -``` - -> **Note:** Calls to the underlying agent run endpoint wait for responses by default. If you need an immediate HTTP 202 response, set the `x-ms-wait-for-response` header or include `"wait_for_response": false` in the request body. - -## Expected Responses -- `POST /api/hitl/run` returns a 202 Accepted payload with the Durable Functions instance ID. -- `POST /api/hitl/approve/{instanceId}` echoes the decision that the orchestration receives. -- `GET /api/hitl/status/{instanceId}` reports `runtimeStatus`, custom status messages, and the final content when approved. -The orchestration sets custom status messages, retries on rejection with reviewer feedback, and raises a timeout if human approval does not arrive. diff --git a/python/samples/04-hosting/azure_functions/07_single_agent_orchestration_hitl/demo.http b/python/samples/04-hosting/azure_functions/07_single_agent_orchestration_hitl/demo.http deleted file mode 100644 index 42f93b85436..00000000000 --- a/python/samples/04-hosting/azure_functions/07_single_agent_orchestration_hitl/demo.http +++ /dev/null @@ -1,45 +0,0 @@ -### Start the HITL content generation orchestration with default timeout (72 hours) -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 a short timeout (~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 -} - - -### Replace INSTANCE_ID_GOES_HERE below with the value returned from the POST call -@instanceId= - -### Check the status of the orchestration -GET http://localhost:7071/api/hitl/status/{{instanceId}} - -### Send human approval -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 -POST http://localhost:7071/api/hitl/approve/{{instanceId}} -Content-Type: application/json - -{ - "approved": false, - "feedback": "The article needs more technical depth and better examples." -} - diff --git a/python/samples/04-hosting/azure_functions/07_single_agent_orchestration_hitl/function_app.py b/python/samples/04-hosting/azure_functions/07_single_agent_orchestration_hitl/function_app.py deleted file mode 100644 index 1833277ed98..00000000000 --- a/python/samples/04-hosting/azure_functions/07_single_agent_orchestration_hitl/function_app.py +++ /dev/null @@ -1,402 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Iterate on generated content with a human-in-the-loop Durable orchestration. - -Components used in this sample: -- FoundryChatClient for a single writer agent that emits structured JSON. -- AgentFunctionApp with Durable orchestration, HTTP triggers, and activity triggers. -- External events that pause the workflow until a human decision arrives or times out. - -Prerequisites: configure `FOUNDRY_PROJECT_ENDPOINT`, `FOUNDRY_MODEL`, and sign in with Azure CLI before running `func start`.""" - -import json -import logging -import os -from collections.abc import Generator, Mapping -from datetime import timedelta -from typing import Any - -import azure.functions as func -from agent_framework import Agent -from agent_framework.azure import AgentFunctionApp -from agent_framework.foundry import FoundryChatClient -from azure.durable_functions import DurableOrchestrationClient, DurableOrchestrationContext -from azure.identity.aio import AzureCliCredential -from pydantic import BaseModel, ValidationError - -logger = logging.getLogger(__name__) - -# 1. Define orchestration constants used throughout the workflow. -WRITER_AGENT_NAME = "WriterAgent" -HUMAN_APPROVAL_EVENT = "HumanApproval" - - -class ContentGenerationInput(BaseModel): - topic: str - max_review_attempts: int = 3 - approval_timeout_hours: float = 72 - - -class GeneratedContent(BaseModel): - title: str - content: str - - -class HumanApproval(BaseModel): - approved: bool - feedback: str = "" - - -# 2. Create the writer agent that produces structured JSON responses. -def _create_writer_agent() -> Any: - instructions = ( - "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. " - "Return your response as JSON with 'title' and 'content' fields." - ) - - _client = FoundryChatClient( - project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], - model=os.environ["FOUNDRY_MODEL"], - credential=AzureCliCredential(), - ) - return Agent( - client=_client, - name=WRITER_AGENT_NAME, - instructions=instructions, - ) - - -app = AgentFunctionApp(agents=[_create_writer_agent()], enable_health_check=True) - - -# 3. Activities encapsulate external work for review notifications and publishing. -@app.activity_trigger(input_name="content") -def notify_user_for_approval(content: dict) -> None: - model = GeneratedContent.model_validate(content) - logger.info("NOTIFICATION: Please review the following content for approval:") - logger.info("Title: %s", model.title or "(untitled)") - logger.info("Content: %s", model.content) - logger.info("Use the approval endpoint to approve or reject this content.") - - -@app.activity_trigger(input_name="content") -def publish_content(content: dict) -> None: - model = GeneratedContent.model_validate(content) - logger.info("PUBLISHING: Content has been published successfully:") - logger.info("Title: %s", model.title or "(untitled)") - logger.info("Content: %s", model.content) - - -# 4. Orchestration loops until the human approves, times out, or attempts are exhausted. -@app.orchestration_trigger(context_name="context") -def content_generation_hitl_orchestration(context: DurableOrchestrationContext) -> Generator[Any, Any, dict[str, str]]: - payload_raw = context.get_input() - if not isinstance(payload_raw, Mapping): - raise ValueError("Content generation input is required") - - try: - payload = ContentGenerationInput.model_validate(payload_raw) - except ValidationError as exc: - raise ValueError(f"Invalid content generation input: {exc}") from exc - - writer = app.get_agent(context, WRITER_AGENT_NAME) - writer_session = writer.create_session() - - context.set_custom_status(f"Starting content generation for topic: {payload.topic}") - - initial_raw = yield writer.run( - messages=f"Write a short article about '{payload.topic}'.", - session=writer_session, - options={"response_format": GeneratedContent}, - ) - - content = initial_raw.value - - if content is None: - raise ValueError("Agent returned no content after extraction.") - - attempt = 0 - while attempt < payload.max_review_attempts: - attempt += 1 - context.set_custom_status( - f"Requesting human feedback. Iteration #{attempt}. Timeout: {payload.approval_timeout_hours} hour(s)." - ) - - yield context.call_activity("notify_user_for_approval", content.model_dump()) # type: ignore[misc] - - approval_task = context.wait_for_external_event(HUMAN_APPROVAL_EVENT) - timeout_task = context.create_timer( - context.current_utc_datetime + timedelta(hours=payload.approval_timeout_hours) - ) - - winner = yield context.task_any([approval_task, timeout_task]) - - if winner == approval_task: - timeout_task.cancel() # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] - approval_payload = _parse_human_approval(approval_task.result) - - if approval_payload.approved: - context.set_custom_status("Content approved by human reviewer. Publishing content...") - yield context.call_activity("publish_content", content.model_dump()) # type: ignore[misc] - context.set_custom_status( - f"Content published successfully at {context.current_utc_datetime:%Y-%m-%dT%H:%M:%S}" - ) - return {"content": content.content} - - context.set_custom_status("Content rejected by human reviewer. Incorporating feedback and regenerating...") - - # Check if we've exhausted attempts - if attempt >= payload.max_review_attempts: - break - - rewrite_prompt = ( - "The content was rejected by a human reviewer. Please rewrite the article incorporating their feedback.\n\n" - f"Human Feedback: {approval_payload.feedback or 'No feedback provided.'}" - ) - rewritten_raw = yield writer.run( - messages=rewrite_prompt, - session=writer_session, - options={"response_format": GeneratedContent}, - ) - - try: - content = rewritten_raw.value - except Exception as ex: - raise ValueError("Agent returned no content after rewrite.") from ex - else: - context.set_custom_status( - f"Human approval timed out after {payload.approval_timeout_hours} hour(s). Treating as rejection." - ) - raise TimeoutError(f"Human approval timed out after {payload.approval_timeout_hours} hour(s).") - - # If we exit the loop without returning, max attempts were exhausted - context.set_custom_status("Max review attempts exhausted.") - raise RuntimeError(f"Content could not be approved after {payload.max_review_attempts} iteration(s).") - - -# 5. HTTP endpoint that starts the human-in-the-loop orchestration. -@app.route(route="hitl/run", methods=["POST"]) -@app.durable_client_input(client_name="client") -async def start_content_generation( - req: func.HttpRequest, - client: DurableOrchestrationClient, -) -> func.HttpResponse: - try: - body = req.get_json() - except ValueError: - body = None - - if not isinstance(body, Mapping): - return func.HttpResponse( - body=json.dumps({"error": "Request body must be valid JSON."}), - status_code=400, - mimetype="application/json", - ) - - try: - payload = ContentGenerationInput.model_validate(body) - except ValidationError as exc: - return func.HttpResponse( - body=json.dumps({"error": f"Invalid content generation input: {exc}"}), - status_code=400, - mimetype="application/json", - ) - - instance_id = await client.start_new( - orchestration_function_name="content_generation_hitl_orchestration", - client_input=payload.model_dump(), - ) - - status_url = _build_status_url(req.url, instance_id, route="hitl") - - payload_json = { - "message": "HITL content generation orchestration started.", - "topic": payload.topic, - "instanceId": instance_id, - "statusQueryGetUri": status_url, - } - - return func.HttpResponse( - body=json.dumps(payload_json), - status_code=202, - mimetype="application/json", - ) - - -# 6. Endpoint that delivers human approval or rejection back into the orchestration. -@app.route(route="hitl/approve/{instanceId}", methods=["POST"]) -@app.durable_client_input(client_name="client") -async def send_human_approval( - req: func.HttpRequest, - client: DurableOrchestrationClient, -) -> func.HttpResponse: - instance_id = req.route_params.get("instanceId") - if not instance_id: - return func.HttpResponse( - body=json.dumps({"error": "Missing instanceId in route."}), - status_code=400, - mimetype="application/json", - ) - - try: - body = req.get_json() - except ValueError: - body = None - - if not isinstance(body, Mapping): - return func.HttpResponse( - body=json.dumps({"error": "Approval response is required"}), - status_code=400, - mimetype="application/json", - ) - - try: - approval = HumanApproval.model_validate(body) - except ValidationError as exc: - return func.HttpResponse( - body=json.dumps({"error": f"Invalid approval payload: {exc}"}), - status_code=400, - mimetype="application/json", - ) - - await client.raise_event(instance_id, HUMAN_APPROVAL_EVENT, approval.model_dump()) - - payload_json = { - "message": "Human approval sent to orchestration.", - "instanceId": instance_id, - "approved": approval.approved, - } - - return func.HttpResponse( - body=json.dumps(payload_json), - status_code=200, - mimetype="application/json", - ) - - -# 7. Endpoint that mirrors Durable Functions status plus custom workflow messaging. -@app.route(route="hitl/status/{instanceId}", methods=["GET"]) -@app.durable_client_input(client_name="client") -async def get_orchestration_status( - req: func.HttpRequest, - client: DurableOrchestrationClient, -) -> func.HttpResponse: - instance_id = req.route_params.get("instanceId") - if not instance_id: - return func.HttpResponse( - body=json.dumps({"error": "Missing instanceId"}), - status_code=400, - mimetype="application/json", - ) - - status = await client.get_status( - instance_id, - show_history=False, - show_history_output=False, - show_input=True, - ) - - # Check if status is None or if the instance doesn't exist (runtime_status is None) - if getattr(status, "runtime_status", None) is None: - return func.HttpResponse( - body=json.dumps({"error": "Instance not found."}), - status_code=404, - mimetype="application/json", - ) - - response_data: dict[str, Any] = { - "instanceId": getattr(status, "instance_id", None), - "runtimeStatus": getattr(status.runtime_status, "name", None) - if getattr(status, "runtime_status", None) - else None, - "workflowStatus": getattr(status, "custom_status", None), - } - - if getattr(status, "input_", None) is not None: - response_data["input"] = status.input_ - - if getattr(status, "output", None) is not None: - response_data["output"] = status.output - - failure_details = getattr(status, "failure_details", None) - if failure_details is not None: - response_data["failureDetails"] = failure_details - - return func.HttpResponse( - body=json.dumps(response_data), - status_code=200, - mimetype="application/json", - ) - - -# 8. Helper utilities keep parsing logic deterministic. -def _build_status_url(request_url: str, instance_id: str, *, route: str) -> str: - base_url, _, _ = request_url.partition("/api/") - if not base_url: - base_url = request_url.rstrip("/") - return f"{base_url}/api/{route}/status/{instance_id}" - - -def _parse_human_approval(raw: Any) -> HumanApproval: - if isinstance(raw, Mapping): - return HumanApproval.model_validate(raw) - - if isinstance(raw, str): - stripped = raw.strip() - if not stripped: - return HumanApproval(approved=False, feedback="") - try: - parsed = json.loads(stripped) - if isinstance(parsed, Mapping): - return HumanApproval.model_validate(parsed) - except json.JSONDecodeError: - logger.debug( - "[HITL] Approval payload is not valid JSON; using string heuristics.", - exc_info=True, - ) - - affirmative = {"true", "yes", "approved", "y", "1"} - negative = {"false", "no", "rejected", "n", "0"} - lower = stripped.lower() - if lower in affirmative: - return HumanApproval(approved=True, feedback="") - if lower in negative: - return HumanApproval(approved=False, feedback="") - return HumanApproval(approved=False, feedback=stripped) - - raise ValueError("Approval payload must be a JSON object or string.") - - -""" -Expected response from `POST /api/hitl/run`: - -HTTP/1.1 202 Accepted -{ - "message": "HITL content generation orchestration started.", - "topic": "Contoso launch", - "instanceId": "", - "statusQueryGetUri": "http://localhost:7071/api/hitl/status/" -} - -Expected response after approving via `POST /api/hitl/approve/{instanceId}`: - -HTTP/1.1 200 OK -{ - "message": "Human approval sent to orchestration.", - "instanceId": "", - "approved": true -} - -Expected response from `GET /api/hitl/status/{instanceId}` once published: - -HTTP/1.1 200 OK -{ - "instanceId": "", - "runtimeStatus": "Completed", - "workflowStatus": "Content published successfully at 2024-01-01T12:00:00", - "output": { - "content": "Thank you for joining the Contoso product launch..." - } -} -""" diff --git a/python/samples/04-hosting/azure_functions/07_single_agent_orchestration_hitl/host.json b/python/samples/04-hosting/azure_functions/07_single_agent_orchestration_hitl/host.json deleted file mode 100644 index 9e7fd873dda..00000000000 --- a/python/samples/04-hosting/azure_functions/07_single_agent_orchestration_hitl/host.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "version": "2.0", - "extensionBundle": { - "id": "Microsoft.Azure.Functions.ExtensionBundle", - "version": "[4.*, 5.0.0)" - }, - "extensions": { - "durableTask": { - "hubName": "%TASKHUB_NAME%" - } - } -} diff --git a/python/samples/04-hosting/azure_functions/07_single_agent_orchestration_hitl/local.settings.json.template b/python/samples/04-hosting/azure_functions/07_single_agent_orchestration_hitl/local.settings.json.template deleted file mode 100644 index 1d8bc82e392..00000000000 --- a/python/samples/04-hosting/azure_functions/07_single_agent_orchestration_hitl/local.settings.json.template +++ /dev/null @@ -1,11 +0,0 @@ -{ - "IsEncrypted": false, - "Values": { - "FUNCTIONS_WORKER_RUNTIME": "python", - "AzureWebJobsStorage": "UseDevelopmentStorage=true", - "DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None", - "TASKHUB_NAME": "default", - "FOUNDRY_PROJECT_ENDPOINT": "", - "FOUNDRY_MODEL": "" - } -} diff --git a/python/samples/04-hosting/azure_functions/07_single_agent_orchestration_hitl/requirements.txt b/python/samples/04-hosting/azure_functions/07_single_agent_orchestration_hitl/requirements.txt deleted file mode 100644 index b71a6092a70..00000000000 --- a/python/samples/04-hosting/azure_functions/07_single_agent_orchestration_hitl/requirements.txt +++ /dev/null @@ -1,12 +0,0 @@ -# Agent Framework packages -# To use the deployed version, uncomment the lines below and comment out the local installation lines -# agent-framework-foundry -# agent-framework-azurefunctions - -# Local installation (for development and testing) -# Each package must be listed explicitly because pip doesn't resolve uv workspace sources. -# Without explicit entries, pip would fetch transitive dependencies from PyPI instead of local source. --e ../../../../packages/core # Core framework - base dependency for all packages --e ../../../../packages/foundry # Foundry support - dependency for hosted chat/agent samples --e ../../../../packages/durabletask # Durable Task support - dependency of azurefunctions --e ../../../../packages/azurefunctions # Azure Functions integration - the main package for this sample diff --git a/python/samples/04-hosting/azure_functions/08_mcp_server/README.md b/python/samples/04-hosting/azure_functions/08_mcp_server/README.md deleted file mode 100644 index 71c963a25bb..00000000000 --- a/python/samples/04-hosting/azure_functions/08_mcp_server/README.md +++ /dev/null @@ -1,198 +0,0 @@ -# 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 - -## Configuration - -Update your `local.settings.json` with your Foundry project settings: - -```json -{ - "Values": { - "FOUNDRY_PROJECT_ENDPOINT": "https://your-project.services.ai.azure.com/api/projects/your-project", - "FOUNDRY_MODEL": "your-deployment-name" - } -} -``` - -## Running the Sample - -1. **Start the Function App**: - ```bash - cd python/samples/04-hosting/azure_functions/08_mcp_server - 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: - ``` - MCP server endpoint: http://localhost:7071/runtime/webhooks/mcp - ``` - -## Testing MCP Tool Integration - -### Using MCP Inspector - -1. Install the [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector) -2. Connect using the MCP server endpoint from your terminal output -3. Select **"Streamable HTTP"** as the transport method -4. Test the available MCP tools: - - `StockAdvisor` - Available only as MCP tool - - `PlantAdvisor` - Available as both HTTP and MCP tool - -### Using Other MCP Clients - -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. - -## Testing HTTP Endpoints - -For agents with HTTP triggers enabled (Joker and PlantAdvisor), you can test them using curl: - -```bash -# Test Joker agent (HTTP only) -curl -X POST http://localhost:7071/api/agents/Joker/run \ - -H "Content-Type: application/json" \ - -d '{"message": "Tell me a joke"}' - -# Test PlantAdvisor agent (HTTP and MCP) -curl -X POST http://localhost:7071/api/agents/PlantAdvisor/run \ - -H "Content-Type: application/json" \ - -d '{"message": "Recommend an indoor plant"}' -``` - -Note: StockAdvisor does not have HTTP endpoints and is only accessible via MCP tool triggers. - -## Expected Output - -**HTTP Responses** will be returned directly to your HTTP client. - -**MCP Tool Responses** will be visible in: -- The terminal where `func start` is running -- Your MCP client interface -- The DTS dashboard at `http://localhost:8080` (if using Durable Task Scheduler) - -## Health Check - -Check the health endpoint to see which agents have which triggers enabled: - -```bash -curl http://localhost:7071/api/health -``` - -Expected response: - -```json -{ - "status": "healthy", - "agents": [ - { - "name": "Joker", - "type": "Agent", - "http_endpoint_enabled": true, - "mcp_tool_enabled": false - }, - { - "name": "StockAdvisor", - "type": "Agent", - "http_endpoint_enabled": false, - "mcp_tool_enabled": true - }, - { - "name": "PlantAdvisor", - "type": "Agent", - "http_endpoint_enabled": true, - "mcp_tool_enabled": true - } - ], - "agent_count": 3 -} -``` - -## Code Structure - -The sample shows how to enable MCP tool triggers with flexible agent configuration: - -```python -import os - -from agent_framework import Agent -from agent_framework.azure import AgentFunctionApp -from agent_framework.foundry import FoundryChatClient -from azure.identity.aio import AzureCliCredential - -# Create Foundry chat client -client = FoundryChatClient( - project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], - model=os.environ["FOUNDRY_MODEL"], - credential=AzureCliCredential(), -) - -# Define agents with different roles -joker_agent = Agent( - client=client, - name="Joker", - instructions="You are good at telling jokes.", -) - -stock_agent = Agent( - client=client, - name="StockAdvisor", - instructions="Check stock prices.", -) - -plant_agent = Agent( - client=client, - name="PlantAdvisor", - instructions="Recommend plants.", - description="Get plant recommendations.", -) - -# Create the AgentFunctionApp -app = AgentFunctionApp(enable_health_check=True) - -# Configure agents with different trigger combinations: -# HTTP trigger only (default) -app.add_agent(joker_agent) - -# MCP tool trigger only (HTTP disabled) -app.add_agent(stock_agent, enable_http_endpoint=False, enable_mcp_tool_trigger=True) - -# Both HTTP and MCP tool triggers enabled -app.add_agent(plant_agent, enable_http_endpoint=True, enable_mcp_tool_trigger=True) -``` - -This automatically creates the following endpoints based on agent configuration: -- `POST /api/agents/{AgentName}/run` - HTTP endpoint (when `enable_http_endpoint=True`) -- MCP tool triggers for agents with `enable_mcp_tool_trigger=True` -- `GET /api/health` - Health check endpoint showing agent configurations - -## Learn More - -- [Model Context Protocol Documentation](https://modelcontextprotocol.io/) -- [Microsoft Agent Framework Documentation](https://github.com/microsoft/agent-framework) -- [Azure Functions Documentation](https://learn.microsoft.com/azure/azure-functions/) diff --git a/python/samples/04-hosting/azure_functions/08_mcp_server/function_app.py b/python/samples/04-hosting/azure_functions/08_mcp_server/function_app.py deleted file mode 100644 index 2662fae6191..00000000000 --- a/python/samples/04-hosting/azure_functions/08_mcp_server/function_app.py +++ /dev/null @@ -1,79 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Example showing how to configure AI agents with different trigger configurations. - -This sample demonstrates how to configure agents to be accessible as both HTTP endpoints -and Model Context Protocol (MCP) 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 -- Flexible Agent Registration: Register agents with customizable trigger configurations - -This sample creates three agents with different trigger configurations: -- Joker: HTTP trigger only (default) -- StockAdvisor: MCP tool trigger only (HTTP disabled) -- PlantAdvisor: Both HTTP and MCP tool triggers enabled - -Required environment variables: -- FOUNDRY_PROJECT_ENDPOINT: Your Microsoft Foundry project endpoint -- FOUNDRY_MODEL: Your Microsoft Foundry deployment name - -Authentication uses AzureCliCredential (Azure Identity). -""" - -import os - -from agent_framework import Agent -from agent_framework.azure import AgentFunctionApp -from agent_framework.foundry import FoundryChatClient -from azure.identity.aio import AzureCliCredential -from dotenv import load_dotenv - -load_dotenv() - -# Create Foundry chat client -# This uses AzureCliCredential for authentication (requires 'az login') -client = FoundryChatClient( - project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], - model=os.environ["FOUNDRY_MODEL"], - credential=AzureCliCredential(), -) - -# Define three AI agents with different roles -# Agent 1: Joker - HTTP trigger only (default) -agent1 = Agent( - client=client, - name="Joker", - instructions="You are good at telling jokes.", -) - -# Agent 2: StockAdvisor - MCP tool trigger only -agent2 = Agent( - client=client, - name="StockAdvisor", - instructions="Check stock prices.", -) - -# Agent 3: PlantAdvisor - Both HTTP and MCP tool triggers -agent3 = Agent( - client=client, - name="PlantAdvisor", - instructions="Recommend plants.", - description="Get plant recommendations.", -) - -# Create the AgentFunctionApp with selective trigger configuration -app = AgentFunctionApp( - enable_health_check=True, -) - -# Agent 1: HTTP trigger only (default) -app.add_agent(agent1) - -# Agent 2: Disable HTTP trigger, enable MCP tool trigger only -app.add_agent(agent2, enable_http_endpoint=False, enable_mcp_tool_trigger=True) - -# Agent 3: Enable both HTTP and MCP tool triggers -app.add_agent(agent3, enable_http_endpoint=True, enable_mcp_tool_trigger=True) diff --git a/python/samples/04-hosting/azure_functions/08_mcp_server/host.json b/python/samples/04-hosting/azure_functions/08_mcp_server/host.json deleted file mode 100644 index b7e5ad1c0b7..00000000000 --- a/python/samples/04-hosting/azure_functions/08_mcp_server/host.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "version": "2.0", - "extensionBundle": { - "id": "Microsoft.Azure.Functions.ExtensionBundle", - "version": "[4.*, 5.0.0)" - } -} diff --git a/python/samples/04-hosting/azure_functions/08_mcp_server/local.settings.json.template b/python/samples/04-hosting/azure_functions/08_mcp_server/local.settings.json.template deleted file mode 100644 index f9d63b3273e..00000000000 --- a/python/samples/04-hosting/azure_functions/08_mcp_server/local.settings.json.template +++ /dev/null @@ -1,10 +0,0 @@ -{ - "IsEncrypted": false, - "Values": { - "FUNCTIONS_WORKER_RUNTIME": "python", - "AzureWebJobsStorage": "UseDevelopmentStorage=true", - "DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None", - "FOUNDRY_PROJECT_ENDPOINT": "", - "FOUNDRY_MODEL": "" - } -} diff --git a/python/samples/04-hosting/azure_functions/08_mcp_server/requirements.txt b/python/samples/04-hosting/azure_functions/08_mcp_server/requirements.txt deleted file mode 100644 index b71a6092a70..00000000000 --- a/python/samples/04-hosting/azure_functions/08_mcp_server/requirements.txt +++ /dev/null @@ -1,12 +0,0 @@ -# Agent Framework packages -# To use the deployed version, uncomment the lines below and comment out the local installation lines -# agent-framework-foundry -# agent-framework-azurefunctions - -# Local installation (for development and testing) -# Each package must be listed explicitly because pip doesn't resolve uv workspace sources. -# Without explicit entries, pip would fetch transitive dependencies from PyPI instead of local source. --e ../../../../packages/core # Core framework - base dependency for all packages --e ../../../../packages/foundry # Foundry support - dependency for hosted chat/agent samples --e ../../../../packages/durabletask # Durable Task support - dependency of azurefunctions --e ../../../../packages/azurefunctions # Azure Functions integration - the main package for this sample diff --git a/python/samples/04-hosting/azure_functions/09_workflow_shared_state/.gitignore b/python/samples/04-hosting/azure_functions/09_workflow_shared_state/.gitignore deleted file mode 100644 index 560ff95106b..00000000000 --- a/python/samples/04-hosting/azure_functions/09_workflow_shared_state/.gitignore +++ /dev/null @@ -1,18 +0,0 @@ -# Local settings -local.settings.json -.env - -# Python -__pycache__/ -*.py[cod] -.venv/ -venv/ - -# Azure Functions -bin/ -obj/ -.python_packages/ - -# IDE -.vscode/ -.idea/ diff --git a/python/samples/04-hosting/azure_functions/09_workflow_shared_state/README.md b/python/samples/04-hosting/azure_functions/09_workflow_shared_state/README.md deleted file mode 100644 index 6fd2a6b4a9c..00000000000 --- a/python/samples/04-hosting/azure_functions/09_workflow_shared_state/README.md +++ /dev/null @@ -1,99 +0,0 @@ -# Workflow with SharedState Sample - -This sample demonstrates running **Agent Framework workflows with SharedState** in Azure Durable Functions. - -## Overview - -This sample shows how to use `AgentFunctionApp` to execute a `WorkflowBuilder` workflow that uses SharedState to pass data between executors. SharedState is a local dictionary maintained by the orchestration that allows executors to share data across workflow steps. - -## What This Sample Demonstrates - -1. **Workflow Execution** - Running `WorkflowBuilder` workflows in Azure Durable Functions -2. **State APIs** - Using `ctx.set_state()` and `ctx.get_state()` to share data -3. **Conditional Routing** - Routing messages based on spam detection results -4. **Agent + Executor Composition** - Combining AI agents with non-AI function executors - -## Workflow Architecture - -``` -store_email → spam_detector (agent) → to_detection_result → [branch]: - ├── If spam: handle_spam → yield "Email marked as spam: {reason}" - └── If not spam: submit_to_email_assistant → email_assistant (agent) → finalize_and_send → yield "Email sent: {response}" -``` - -### SharedState Usage by Executor - -| Executor | SharedState Operations | -|----------|----------------------| -| `store_email` | `set_state("email:{id}", email)`, `set_state("current_email_id", id)` | -| `to_detection_result` | `get_state("current_email_id")` | -| `submit_to_email_assistant` | `get_state("email:{id}")` | - -SharedState allows executors to pass large payloads (like email content) by reference rather than through message routing. - -## Prerequisites - -1. **Azure OpenAI** - Endpoint and deployment configured -2. **Azurite** - For local storage emulation - -## Setup - -1. Copy `local.settings.json.sample` to `local.settings.json` and configure: - ```json - { - "Values": { - "FOUNDRY_PROJECT_ENDPOINT": "https://your-project.services.ai.azure.com/api/projects/your-project", - "FOUNDRY_MODEL": "gpt-4o" - } - } - ``` - -2. Install dependencies: - ```bash - pip install -r requirements.txt - ``` - -3. Start Azurite: - ```bash - azurite --silent - ``` - -4. Run the function app: - ```bash - func start - ``` - -## Testing - -Use the `demo.http` file with REST Client extension or curl: - -### Test Spam Email -```bash -curl -X POST http://localhost:7071/api/workflow/email_triage_shared_state/run \ - -H "Content-Type: application/json" \ - -d '"URGENT! You have won $1,000,000! Click here to claim!"' -``` - -### Test Legitimate Email -```bash -curl -X POST http://localhost:7071/api/workflow/email_triage_shared_state/run \ - -H "Content-Type: application/json" \ - -d '"Hi team, reminder about our meeting tomorrow at 10 AM."' -``` - -## Expected Output - -**Spam email:** -``` -Email marked as spam: This email exhibits spam characteristics including urgent language, unrealistic claims of monetary winnings, and requests to click suspicious links. -``` - -**Legitimate email:** -``` -Email sent: Hi, Thank you for the reminder about the sprint planning meeting tomorrow at 10 AM. I will review the agenda and come prepared with my updates. See you then! -``` - -## Related Samples - -- `10_workflow_no_shared_state` - Workflow execution without SharedState usage -- `06_multi_agent_orchestration_conditionals` - Manual Durable Functions orchestration with agents diff --git a/python/samples/04-hosting/azure_functions/09_workflow_shared_state/demo.http b/python/samples/04-hosting/azure_functions/09_workflow_shared_state/demo.http deleted file mode 100644 index 50f0ef0a68c..00000000000 --- a/python/samples/04-hosting/azure_functions/09_workflow_shared_state/demo.http +++ /dev/null @@ -1,31 +0,0 @@ -@endpoint = http://localhost:7071 - -### Start the workflow with a spam email -POST {{endpoint}}/api/workflow/email_triage_shared_state/run -Content-Type: application/json - -"URGENT! You have won $1,000,000! Click here to claim your prize now before it expires!" - -### Start the workflow with a legitimate email -POST {{endpoint}}/api/workflow/email_triage_shared_state/run -Content-Type: application/json - -"Hi team, just a reminder about the sprint planning meeting tomorrow at 10 AM. Please review the agenda items in Jira before the call." - -### Start the workflow with another legitimate email -POST {{endpoint}}/api/workflow/email_triage_shared_state/run -Content-Type: application/json - -"Hello, I wanted to follow up on our conversation from last week regarding the project timeline. Could we schedule a brief call this afternoon to discuss the next steps?" - -### Start the workflow with a phishing attempt -POST {{endpoint}}/api/workflow/email_triage_shared_state/run -Content-Type: application/json - -"Dear Customer, Your account has been compromised! Click this link immediately to secure your account: http://totallylegit.suspicious.com/secure" - -### Check workflow status (replace {instanceId} with actual instance ID from response) -GET {{endpoint}}/runtime/webhooks/durabletask/instances/{instanceId} - -### Purge all orchestration instances (use for cleanup) -POST {{endpoint}}/runtime/webhooks/durabletask/instances/purge?createdTimeFrom=2020-01-01T00:00:00Z&createdTimeTo=2030-12-31T23:59:59Z diff --git a/python/samples/04-hosting/azure_functions/09_workflow_shared_state/function_app.py b/python/samples/04-hosting/azure_functions/09_workflow_shared_state/function_app.py deleted file mode 100644 index 585296cf031..00000000000 --- a/python/samples/04-hosting/azure_functions/09_workflow_shared_state/function_app.py +++ /dev/null @@ -1,288 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. -""" -Sample: Shared state with agents and conditional routing. - -Store an email once by id, classify it with a detector agent, then either draft a reply with an assistant -agent or finish with a spam notice. Stream events as the workflow runs. - -Purpose: -Show how to: -- Use shared state to decouple large payloads from messages and pass around lightweight references. -- Enforce structured agent outputs with Pydantic models via response_format for robust parsing. -- Route using conditional edges based on a typed intermediate DetectionResult. -- Compose agent backed executors with function style executors and yield the final output when the workflow completes. - -Prerequisites: -- Configure `FOUNDRY_PROJECT_ENDPOINT` and `FOUNDRY_MODEL` for FoundryChatClient. -- Authentication uses `AzureCliCredential`; run `az login` before executing the sample. -- Familiarity with WorkflowBuilder, executors, conditional edges, and streaming runs. -""" - -import logging -import os -from dataclasses import dataclass -from typing import Any -from uuid import uuid4 - -from agent_framework import ( - Agent, - AgentExecutorRequest, - AgentExecutorResponse, - Message, - Workflow, - WorkflowBuilder, - WorkflowContext, - executor, -) -from agent_framework.foundry import FoundryChatClient -from agent_framework.openai import OpenAIChatOptions -from agent_framework_azurefunctions import AgentFunctionApp -from azure.identity.aio import AzureCliCredential -from pydantic import BaseModel, ValidationError -from typing_extensions import Never - -logger = logging.getLogger(__name__) - -# Environment variable names -FOUNDRY_PROJECT_ENDPOINT_ENV = "FOUNDRY_PROJECT_ENDPOINT" -AZURE_OPENAI_DEPLOYMENT_ENV = "FOUNDRY_MODEL" - -EMAIL_STATE_PREFIX = "email:" -CURRENT_EMAIL_ID_KEY = "current_email_id" - - -class DetectionResultAgent(BaseModel): - """Structured output returned by the spam detection agent.""" - - is_spam: bool - reason: str - - -class EmailResponse(BaseModel): - """Structured output returned by the email assistant agent.""" - - response: str - - -@dataclass -class DetectionResult: - """Internal detection result enriched with the shared state email_id for later lookups.""" - - is_spam: bool - reason: str - email_id: str - - -@dataclass -class Email: - """In memory record stored in shared state to avoid re-sending large bodies on edges.""" - - email_id: str - email_content: str - - -def get_condition(expected_result: bool): - """Create a condition predicate for DetectionResult.is_spam. - - Contract: - - If the message is not a DetectionResult, allow it to pass to avoid accidental dead ends. - - Otherwise, return True only when is_spam matches expected_result. - """ - - def condition(message: Any) -> bool: - if not isinstance(message, DetectionResult): - return True - return message.is_spam == expected_result - - return condition - - -@executor(id="store_email") -async def store_email(email_text: str, ctx: WorkflowContext[AgentExecutorRequest]) -> None: - """Persist the raw email content in shared state and trigger spam detection. - - Responsibilities: - - Generate a unique email_id (UUID) for downstream retrieval. - - Store the Email object under a namespaced key and set the current id pointer. - - Emit an AgentExecutorRequest asking the detector to respond. - """ - new_email = Email(email_id=str(uuid4()), email_content=email_text) - ctx.set_state(f"{EMAIL_STATE_PREFIX}{new_email.email_id}", new_email) - ctx.set_state(CURRENT_EMAIL_ID_KEY, new_email.email_id) - - await ctx.send_message( - AgentExecutorRequest(messages=[Message(role="user", contents=[new_email.email_content])], should_respond=True) - ) - - -@executor(id="to_detection_result") -async def to_detection_result(response: AgentExecutorResponse, ctx: WorkflowContext[DetectionResult]) -> None: - """Parse spam detection JSON into a structured model and enrich with email_id. - - Steps: - 1) Validate the agent's JSON output into DetectionResultAgent. - 2) Retrieve the current email_id from shared state. - 3) Send a typed DetectionResult for conditional routing. - """ - try: - parsed = DetectionResultAgent.model_validate_json(response.agent_response.text) - except ValidationError: - # Fallback for empty or invalid response (e.g. due to content filtering) - parsed = DetectionResultAgent(is_spam=True, reason="Agent execution failed or yielded invalid JSON.") - - email_id: str = ctx.get_state(CURRENT_EMAIL_ID_KEY) - await ctx.send_message(DetectionResult(is_spam=parsed.is_spam, reason=parsed.reason, email_id=email_id)) - - -@executor(id="submit_to_email_assistant") -async def submit_to_email_assistant(detection: DetectionResult, ctx: WorkflowContext[AgentExecutorRequest]) -> None: - """Forward non spam email content to the drafting agent. - - Guard: - - This path should only receive non spam. Raise if misrouted. - """ - if detection.is_spam: - raise RuntimeError("This executor should only handle non-spam messages.") - - # Load the original content by id from shared state and forward it to the assistant. - email: Email = ctx.get_state(f"{EMAIL_STATE_PREFIX}{detection.email_id}") - await ctx.send_message( - AgentExecutorRequest(messages=[Message(role="user", contents=[email.email_content])], should_respond=True) - ) - - -@executor(id="finalize_and_send") -async def finalize_and_send(response: AgentExecutorResponse, ctx: WorkflowContext[Never, str]) -> None: - """Validate the drafted reply and yield the final output.""" - parsed = EmailResponse.model_validate_json(response.agent_response.text) - await ctx.yield_output(f"Email sent: {parsed.response}") - - -@executor(id="handle_spam") -async def handle_spam(detection: DetectionResult, ctx: WorkflowContext[Never, str]) -> None: - """Yield output describing why the email was marked as spam.""" - if detection.is_spam: - await ctx.yield_output(f"Email marked as spam: {detection.reason}") - else: - raise RuntimeError("This executor should only handle spam messages.") - - -# ============================================================================ -# Workflow Creation -# ============================================================================ - - -def _build_client_kwargs() -> dict[str, Any]: - """Build Foundry chat client configuration from environment variables.""" - project_endpoint = os.getenv(FOUNDRY_PROJECT_ENDPOINT_ENV) - if not project_endpoint: - raise RuntimeError(f"{FOUNDRY_PROJECT_ENDPOINT_ENV} environment variable is required.") - - model = os.getenv(AZURE_OPENAI_DEPLOYMENT_ENV) - if not model: - raise RuntimeError(f"{AZURE_OPENAI_DEPLOYMENT_ENV} environment variable is required.") - - return { - "project_endpoint": project_endpoint, - "model": model, - "credential": AzureCliCredential(), - } - - -def _create_workflow() -> Workflow: - """Create the email classification workflow with conditional routing.""" - client_kwargs = _build_client_kwargs() - chat_client = FoundryChatClient(**client_kwargs) - - spam_detection_agent = Agent( - client=chat_client, - instructions=( - "You are a spam detection assistant that identifies spam emails. " - "Always return JSON with fields is_spam (bool) and reason (string)." - ), - default_options=OpenAIChatOptions[Any](response_format=DetectionResultAgent), - name="spam_detection_agent", - ) - - email_assistant_agent = Agent( - client=chat_client, - instructions=( - "You are an email assistant that helps users draft responses to emails with professionalism. " - "Return JSON with a single field 'response' containing the drafted reply." - ), - default_options=OpenAIChatOptions[Any](response_format=EmailResponse), - name="email_assistant_agent", - ) - - # Build the workflow graph with conditional edges. - # Flow: - # store_email -> spam_detection_agent -> to_detection_result -> branch: - # False -> submit_to_email_assistant -> email_assistant_agent -> finalize_and_send - # True -> handle_spam - return ( - WorkflowBuilder(name="email_triage_shared_state", start_executor=store_email) - .add_edge(store_email, spam_detection_agent) - .add_edge(spam_detection_agent, to_detection_result) - .add_edge(to_detection_result, submit_to_email_assistant, condition=get_condition(False)) - .add_edge(to_detection_result, handle_spam, condition=get_condition(True)) - .add_edge(submit_to_email_assistant, email_assistant_agent) - .add_edge(email_assistant_agent, finalize_and_send) - .build() - ) - - -# ============================================================================ -# Application Entry Point -# ============================================================================ - - -def launch(durable: bool = True) -> AgentFunctionApp | None: - """Launch the function app or DevUI. - - Args: - durable: If True, returns AgentFunctionApp for Azure Functions. - If False, launches DevUI for local MAF development. - """ - if durable: - # Azure Functions mode with Durable Functions - # SharedState is enabled by default, which this sample requires for storing emails - workflow = _create_workflow() - return AgentFunctionApp(workflow=workflow, enable_health_check=True) - # Pure MAF mode with DevUI for local development - from pathlib import Path - - from agent_framework.devui import serve - from dotenv import load_dotenv - - env_path = Path(__file__).parent / ".env" - load_dotenv(dotenv_path=env_path) - - logger.info("Starting Workflow Shared State Sample in MAF mode") - logger.info("Available at: http://localhost:8096") - logger.info("\nThis workflow demonstrates:") - logger.info("- Shared state to decouple large payloads from messages") - logger.info("- Structured agent outputs with Pydantic models") - logger.info("- Conditional routing based on detection results") - logger.info("\nFlow: store_email -> spam_detection -> branch (spam/not spam)") - - workflow = _create_workflow() - serve(entities=[workflow], port=8096, auto_open=True) - - return None - - -# Default: Azure Functions mode -# Run with `python function_app.py --maf` for pure MAF mode with DevUI -app = launch(durable=True) - - -if __name__ == "__main__": - import sys - - if "--maf" in sys.argv: - # Run in pure MAF mode with DevUI - launch(durable=False) - else: - print("Usage: python function_app.py --maf") - print(" --maf Run in pure MAF mode with DevUI (http://localhost:8096)") - print("\nFor Azure Functions mode, use: func start") diff --git a/python/samples/04-hosting/azure_functions/09_workflow_shared_state/host.json b/python/samples/04-hosting/azure_functions/09_workflow_shared_state/host.json deleted file mode 100644 index 9e7fd873dda..00000000000 --- a/python/samples/04-hosting/azure_functions/09_workflow_shared_state/host.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "version": "2.0", - "extensionBundle": { - "id": "Microsoft.Azure.Functions.ExtensionBundle", - "version": "[4.*, 5.0.0)" - }, - "extensions": { - "durableTask": { - "hubName": "%TASKHUB_NAME%" - } - } -} diff --git a/python/samples/04-hosting/azure_functions/09_workflow_shared_state/local.settings.json.sample b/python/samples/04-hosting/azure_functions/09_workflow_shared_state/local.settings.json.sample deleted file mode 100644 index eed2a5b6c0f..00000000000 --- a/python/samples/04-hosting/azure_functions/09_workflow_shared_state/local.settings.json.sample +++ /dev/null @@ -1,11 +0,0 @@ -{ - "IsEncrypted": false, - "Values": { - "AzureWebJobsStorage": "UseDevelopmentStorage=true", - "DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None", - "TASKHUB_NAME": "default", - "FUNCTIONS_WORKER_RUNTIME": "python", - "FOUNDRY_PROJECT_ENDPOINT": "", - "FOUNDRY_MODEL": "" - } -} diff --git a/python/samples/04-hosting/azure_functions/09_workflow_shared_state/requirements.txt b/python/samples/04-hosting/azure_functions/09_workflow_shared_state/requirements.txt deleted file mode 100644 index b71a6092a70..00000000000 --- a/python/samples/04-hosting/azure_functions/09_workflow_shared_state/requirements.txt +++ /dev/null @@ -1,12 +0,0 @@ -# Agent Framework packages -# To use the deployed version, uncomment the lines below and comment out the local installation lines -# agent-framework-foundry -# agent-framework-azurefunctions - -# Local installation (for development and testing) -# Each package must be listed explicitly because pip doesn't resolve uv workspace sources. -# Without explicit entries, pip would fetch transitive dependencies from PyPI instead of local source. --e ../../../../packages/core # Core framework - base dependency for all packages --e ../../../../packages/foundry # Foundry support - dependency for hosted chat/agent samples --e ../../../../packages/durabletask # Durable Task support - dependency of azurefunctions --e ../../../../packages/azurefunctions # Azure Functions integration - the main package for this sample diff --git a/python/samples/04-hosting/azure_functions/10_workflow_no_shared_state/.env.sample b/python/samples/04-hosting/azure_functions/10_workflow_no_shared_state/.env.sample deleted file mode 100644 index 53a691a97a7..00000000000 --- a/python/samples/04-hosting/azure_functions/10_workflow_no_shared_state/.env.sample +++ /dev/null @@ -1,3 +0,0 @@ -# Foundry Configuration -FOUNDRY_PROJECT_ENDPOINT=https://your-project.services.ai.azure.com/api/projects/your-project -FOUNDRY_MODEL= diff --git a/python/samples/04-hosting/azure_functions/10_workflow_no_shared_state/.gitignore b/python/samples/04-hosting/azure_functions/10_workflow_no_shared_state/.gitignore deleted file mode 100644 index 1d5b48c35f1..00000000000 --- a/python/samples/04-hosting/azure_functions/10_workflow_no_shared_state/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -.env -local.settings.json diff --git a/python/samples/04-hosting/azure_functions/10_workflow_no_shared_state/README.md b/python/samples/04-hosting/azure_functions/10_workflow_no_shared_state/README.md deleted file mode 100644 index 2de3569f33a..00000000000 --- a/python/samples/04-hosting/azure_functions/10_workflow_no_shared_state/README.md +++ /dev/null @@ -1,159 +0,0 @@ -# Workflow Execution Sample - -This sample demonstrates running **Agent Framework workflows** in Azure Durable Functions without using SharedState. - -## Overview - -This sample shows how to use `AgentFunctionApp` with a `WorkflowBuilder` workflow. The workflow is passed directly to `AgentFunctionApp`, which orchestrates execution using Durable Functions: - -```python -workflow = _create_workflow() # Build the workflow graph -app = AgentFunctionApp(workflow=workflow) -``` - -This approach provides durable, fault-tolerant workflow execution with minimal code. - -## What This Sample Demonstrates - -1. **Workflow Registration** - Pass a `Workflow` directly to `AgentFunctionApp` -2. **Durable Execution** - Workflow executes with Durable Functions durability and scalability -3. **Conditional Routing** - Route messages based on spam detection (is_spam → spam handler, not spam → email assistant) -4. **Agent + Executor Composition** - Combine AI agents with non-AI executor classes - -## Workflow Architecture - -``` -SpamDetectionAgent → [branch based on is_spam]: - ├── If spam: SpamHandlerExecutor → yield "Email marked as spam: {reason}" - └── If not spam: EmailAssistantAgent → EmailSenderExecutor → yield "Email sent: {response}" -``` - -### Components - -| Component | Type | Description | -|-----------|------|-------------| -| `SpamDetectionAgent` | AI Agent | Analyzes emails for spam indicators | -| `EmailAssistantAgent` | AI Agent | Drafts professional email responses | -| `SpamHandlerExecutor` | Executor | Handles spam emails (non-AI) | -| `EmailSenderExecutor` | Executor | Sends email responses (non-AI) | - -## Prerequisites - -1. **Azure OpenAI** - Endpoint and deployment configured -2. **Azurite** - For local storage emulation - -## Setup - -1. Copy configuration files: - ```bash - cp local.settings.json.sample local.settings.json - ``` - -2. Configure `local.settings.json`: - -3. Install dependencies: - ```bash - pip install -r requirements.txt - ``` - -4. Start Azurite: - ```bash - azurite --silent - ``` - -5. Run the function app: - ```bash - func start - ``` - -## Testing - -Use the `demo.http` file with REST Client extension or curl: - -### Test Spam Email -```bash -curl -X POST http://localhost:7071/api/workflow/email_triage/run \ - -H "Content-Type: application/json" \ - -d '{"email_id": "test-001", "email_content": "URGENT! You have won $1,000,000! Click here!"}' -``` - -### Test Legitimate Email -```bash -curl -X POST http://localhost:7071/api/workflow/email_triage/run \ - -H "Content-Type: application/json" \ - -d '{"email_id": "test-002", "email_content": "Hi team, reminder about our meeting tomorrow at 10 AM."}' -``` - -### Check Status -```bash -curl http://localhost:7071/api/workflow/email_triage/status/{instanceId} -``` - -## Expected Output - -**Spam email:** -``` -Email marked as spam: This email exhibits spam characteristics including urgent language, unrealistic claims of monetary winnings, and requests to click suspicious links. -``` - -**Legitimate email:** -``` -Email sent: Hi, Thank you for the reminder about the sprint planning meeting tomorrow at 10 AM. I will be there. -``` - -## Code Highlights - -### Creating the Workflow - -```python -workflow = ( - WorkflowBuilder() - .set_start_executor(spam_agent) - .add_switch_case_edge_group( - spam_agent, - [ - Case(condition=is_spam_detected, target=spam_handler), - Default(target=email_agent), - ], - ) - .add_edge(email_agent, email_sender) - .build() -) -``` - -### Registering with AgentFunctionApp - -```python -app = AgentFunctionApp(workflow=workflow, enable_health_check=True) -``` - -### Executor Classes - -```python -class SpamHandlerExecutor(Executor): - @handler - async def handle_spam_result( - self, - agent_response: AgentExecutorResponse, - ctx: WorkflowContext[Never, str], - ) -> None: - spam_result = SpamDetectionResult.model_validate_json(agent_response.agent_run_response.text) - await ctx.yield_output(f"Email marked as spam: {spam_result.reason}") -``` - -## Standalone Mode (DevUI) - -This sample also supports running standalone for local development: - -```python -# Change launch(durable=True) to launch(durable=False) in function_app.py -# Then run: -python function_app.py -``` - -This starts the DevUI at `http://localhost:8094` for interactive testing. - -## Related Samples - -- `09_workflow_shared_state` - Workflow with SharedState for passing data between executors -- `06_multi_agent_orchestration_conditionals` - Manual Durable Functions orchestration with agents diff --git a/python/samples/04-hosting/azure_functions/10_workflow_no_shared_state/demo.http b/python/samples/04-hosting/azure_functions/10_workflow_no_shared_state/demo.http deleted file mode 100644 index 42f24e38b14..00000000000 --- a/python/samples/04-hosting/azure_functions/10_workflow_no_shared_state/demo.http +++ /dev/null @@ -1,32 +0,0 @@ -### Start Workflow Orchestration - Spam Email -POST http://localhost:7071/api/workflow/email_triage/run -Content-Type: application/json - -{ - "email_id": "email-001", - "email_content": "URGENT! You've won $1,000,000! Click here immediately to claim your prize! Limited time offer - act now!" -} - -### - -### Start Workflow Orchestration - Legitimate Email -POST http://localhost:7071/api/workflow/email_triage/run -Content-Type: application/json - -{ - "email_id": "email-002", - "email_content": "Hi team, just a reminder about our sprint planning meeting tomorrow at 10 AM. Please review the agenda in Jira." -} - -### - -### Get Workflow Status -# Replace {instanceId} with the actual instance ID from the start response -GET http://localhost:7071/api/workflow/email_triage/status/{instanceId} - -### - -### Health Check -GET http://localhost:7071/api/health - -### diff --git a/python/samples/04-hosting/azure_functions/10_workflow_no_shared_state/function_app.py b/python/samples/04-hosting/azure_functions/10_workflow_no_shared_state/function_app.py deleted file mode 100644 index 3b75fea04e0..00000000000 --- a/python/samples/04-hosting/azure_functions/10_workflow_no_shared_state/function_app.py +++ /dev/null @@ -1,246 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. -"""Workflow Execution within Durable Functions Orchestrator. - -This sample demonstrates running agent framework WorkflowBuilder workflows inside -a Durable Functions orchestrator by manually traversing the workflow graph and -delegating execution to Durable Entities (for agents) and Activities (for other logic). - -Key architectural points: -- AgentFunctionApp registers agents as DurableAIAgents. -- WorkflowBuilder uses `DurableAgentDefinition` (a placeholder) to define the graph. -- The orchestrator (`workflow_orchestration`) iterates through the workflow graph. -- When an agent node is encountered, it calls the corresponding `DurableAIAgent` entity. -- When a standard executor node is encountered, it calls an Activity (`ExecuteExecutor`). - -This approach allows using the rich structure of `WorkflowBuilder` while leveraging -the statefulness and durability of `DurableAIAgent`s. - -Prerequisites: -- Configure `FOUNDRY_PROJECT_ENDPOINT` and `FOUNDRY_MODEL` -- Sign in with Azure CLI (`az login`) for `AzureCliCredential` -- Ensure Azurite and the Durable Task Scheduler emulator are running -""" - -import logging -import os -from pathlib import Path -from typing import Any - -from agent_framework import ( - Agent, - AgentExecutorResponse, - Case, - Default, - Executor, - Workflow, - WorkflowBuilder, - WorkflowContext, - handler, -) -from agent_framework.foundry import FoundryChatClient -from agent_framework.openai import OpenAIChatOptions -from agent_framework_azurefunctions import AgentFunctionApp -from azure.identity.aio import AzureCliCredential -from pydantic import BaseModel, ValidationError -from typing_extensions import Never - -logger = logging.getLogger(__name__) - -FOUNDRY_PROJECT_ENDPOINT_ENV = "FOUNDRY_PROJECT_ENDPOINT" -AZURE_OPENAI_DEPLOYMENT_ENV = "FOUNDRY_MODEL" -SPAM_AGENT_NAME = "SpamDetectionAgent" -EMAIL_AGENT_NAME = "EmailAssistantAgent" - -SPAM_DETECTION_INSTRUCTIONS = ( - "You are a spam detection assistant that identifies spam emails.\n\n" - "Analyze the email content for spam indicators including:\n" - "1. Suspicious language (urgent, limited time, act now, free money, etc.)\n" - "2. Suspicious links or requests for personal information\n" - "3. Poor grammar or spelling\n" - "4. Requests for money or financial information\n" - "5. Impersonation attempts\n\n" - "Return a JSON response with:\n" - "- is_spam: boolean indicating if it's spam\n" - "- confidence: float between 0.0 and 1.0\n" - "- reason: detailed explanation of your classification" -) - -EMAIL_ASSISTANT_INSTRUCTIONS = ( - "You are an email assistant that helps users draft responses to legitimate emails.\n\n" - "When you receive an email that has been verified as legitimate:\n" - "1. Draft a professional and appropriate response\n" - "2. Match the tone and formality of the original email\n" - "3. Be helpful and courteous\n" - "4. Keep the response concise but complete\n\n" - "Return a JSON response with:\n" - "- response: the drafted email response" -) - - -class SpamDetectionResult(BaseModel): - is_spam: bool - confidence: float - reason: str - - -class EmailResponse(BaseModel): - response: str - - -class EmailPayload(BaseModel): - email_id: str - email_content: str - - -def _build_client_kwargs() -> dict[str, Any]: - """Build Foundry chat client configuration from environment variables.""" - project_endpoint = os.getenv(FOUNDRY_PROJECT_ENDPOINT_ENV) - if not project_endpoint: - raise RuntimeError(f"{FOUNDRY_PROJECT_ENDPOINT_ENV} environment variable is required.") - - model = os.getenv(AZURE_OPENAI_DEPLOYMENT_ENV) - if not model: - raise RuntimeError(f"{AZURE_OPENAI_DEPLOYMENT_ENV} environment variable is required.") - - return { - "project_endpoint": project_endpoint, - "model": model, - "credential": AzureCliCredential(), - } - - -# Executors for non-AI activities (defined at module level) -class SpamHandlerExecutor(Executor): - """Executor that handles spam emails (non-AI activity).""" - - @handler - async def handle_spam_result( - self, - agent_response: AgentExecutorResponse, - ctx: WorkflowContext[Never, str], - ) -> None: - """Mark email as spam and log the reason.""" - text = agent_response.agent_response.text - try: - spam_result = SpamDetectionResult.model_validate_json(text) - except ValidationError: - spam_result = SpamDetectionResult(is_spam=True, reason="Invalid JSON from agent", confidence=0.0) - - message = f"Email marked as spam: {spam_result.reason}" - await ctx.yield_output(message) - - -class EmailSenderExecutor(Executor): - """Executor that sends email responses (non-AI activity).""" - - @handler - async def handle_email_response( - self, - agent_response: AgentExecutorResponse, - ctx: WorkflowContext[Never, str], - ) -> None: - """Send the drafted email response.""" - text = agent_response.agent_response.text - try: - email_response = EmailResponse.model_validate_json(text) - except ValidationError: - email_response = EmailResponse(response="Error generating response.") - - message = f"Email sent: {email_response.response}" - await ctx.yield_output(message) - - -# Condition function for routing -def is_spam_detected(message: Any) -> bool: - """Check if spam was detected in the email.""" - if not isinstance(message, AgentExecutorResponse): - return False - try: - result = SpamDetectionResult.model_validate_json(message.agent_response.text) - return result.is_spam - except Exception: - return False - - -def _create_workflow() -> Workflow: - """Create the workflow definition.""" - client_kwargs = _build_client_kwargs() - chat_client = FoundryChatClient(**client_kwargs) - - spam_agent = Agent( - client=chat_client, - name=SPAM_AGENT_NAME, - instructions=SPAM_DETECTION_INSTRUCTIONS, - default_options=OpenAIChatOptions[Any](response_format=SpamDetectionResult), - ) - - email_agent = Agent( - client=chat_client, - name=EMAIL_AGENT_NAME, - instructions=EMAIL_ASSISTANT_INSTRUCTIONS, - default_options=OpenAIChatOptions[Any](response_format=EmailResponse), - ) - - # Executors - spam_handler = SpamHandlerExecutor(id="spam_handler") - email_sender = EmailSenderExecutor(id="email_sender") - - # Build workflow - return ( - WorkflowBuilder(name="email_triage", start_executor=spam_agent) - .add_switch_case_edge_group( - spam_agent, - [ - Case(condition=is_spam_detected, target=spam_handler), - Default(target=email_agent), - ], - ) - .add_edge(email_agent, email_sender) - .build() - ) - - -def launch(durable: bool = True) -> AgentFunctionApp | None: - workflow: Workflow | None = None - - if durable: - # Initialize app - workflow = _create_workflow() - return AgentFunctionApp(workflow=workflow) - # Launch the spam detection workflow in DevUI - from agent_framework.devui import serve - from dotenv import load_dotenv - - # Load environment variables from .env file - env_path = Path(__file__).parent / ".env" - load_dotenv(dotenv_path=env_path) - - logger.info("Starting Multi-Agent Spam Detection Workflow") - logger.info("Available at: http://localhost:8094") - logger.info("\nThis workflow demonstrates:") - logger.info("- Conditional routing based on spam detection") - logger.info("- Mixing AI agents with non-AI executors (like activity functions)") - logger.info("- Path 1 (spam): SpamDetector Agent → SpamHandler Executor") - logger.info("- Path 2 (legitimate): SpamDetector Agent → EmailAssistant Agent → EmailSender Executor") - - workflow = _create_workflow() - serve(entities=[workflow], port=8094, auto_open=True) - - return None - - -# Default: Azure Functions mode -# Run with `python function_app.py --maf` for pure MAF mode with DevUI -app = launch(durable=True) - - -if __name__ == "__main__": - import sys - - if "--maf" in sys.argv: - # Run in pure MAF mode with DevUI - launch(durable=False) - else: - print("Usage: python function_app.py --maf") - print(" --maf Run in pure MAF mode with DevUI (http://localhost:8094)") - print("\nFor Azure Functions mode, use: func start") diff --git a/python/samples/04-hosting/azure_functions/10_workflow_no_shared_state/host.json b/python/samples/04-hosting/azure_functions/10_workflow_no_shared_state/host.json deleted file mode 100644 index 9e7fd873dda..00000000000 --- a/python/samples/04-hosting/azure_functions/10_workflow_no_shared_state/host.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "version": "2.0", - "extensionBundle": { - "id": "Microsoft.Azure.Functions.ExtensionBundle", - "version": "[4.*, 5.0.0)" - }, - "extensions": { - "durableTask": { - "hubName": "%TASKHUB_NAME%" - } - } -} diff --git a/python/samples/04-hosting/azure_functions/10_workflow_no_shared_state/local.settings.json.sample b/python/samples/04-hosting/azure_functions/10_workflow_no_shared_state/local.settings.json.sample deleted file mode 100644 index 1d8bc82e392..00000000000 --- a/python/samples/04-hosting/azure_functions/10_workflow_no_shared_state/local.settings.json.sample +++ /dev/null @@ -1,11 +0,0 @@ -{ - "IsEncrypted": false, - "Values": { - "FUNCTIONS_WORKER_RUNTIME": "python", - "AzureWebJobsStorage": "UseDevelopmentStorage=true", - "DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None", - "TASKHUB_NAME": "default", - "FOUNDRY_PROJECT_ENDPOINT": "", - "FOUNDRY_MODEL": "" - } -} diff --git a/python/samples/04-hosting/azure_functions/10_workflow_no_shared_state/requirements.txt b/python/samples/04-hosting/azure_functions/10_workflow_no_shared_state/requirements.txt deleted file mode 100644 index b71a6092a70..00000000000 --- a/python/samples/04-hosting/azure_functions/10_workflow_no_shared_state/requirements.txt +++ /dev/null @@ -1,12 +0,0 @@ -# Agent Framework packages -# To use the deployed version, uncomment the lines below and comment out the local installation lines -# agent-framework-foundry -# agent-framework-azurefunctions - -# Local installation (for development and testing) -# Each package must be listed explicitly because pip doesn't resolve uv workspace sources. -# Without explicit entries, pip would fetch transitive dependencies from PyPI instead of local source. --e ../../../../packages/core # Core framework - base dependency for all packages --e ../../../../packages/foundry # Foundry support - dependency for hosted chat/agent samples --e ../../../../packages/durabletask # Durable Task support - dependency of azurefunctions --e ../../../../packages/azurefunctions # Azure Functions integration - the main package for this sample diff --git a/python/samples/04-hosting/azure_functions/11_workflow_parallel/.env.template b/python/samples/04-hosting/azure_functions/11_workflow_parallel/.env.template deleted file mode 100644 index f39a3a29332..00000000000 --- a/python/samples/04-hosting/azure_functions/11_workflow_parallel/.env.template +++ /dev/null @@ -1,13 +0,0 @@ -# Azure Functions Runtime Configuration -FUNCTIONS_WORKER_RUNTIME=python -AzureWebJobsStorage=UseDevelopmentStorage=true - -# Durable Task Scheduler Configuration -# For local development with DTS emulator: Endpoint=http://localhost:8080;TaskHub=default;Authentication=None -# For Azure: Get connection string from Azure portal -DURABLE_TASK_SCHEDULER_CONNECTION_STRING=Endpoint=http://localhost:8080;TaskHub=default;Authentication=None -TASKHUB_NAME=default - -# Azure OpenAI Configuration -AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com/ -AZURE_OPENAI_MODEL=your-deployment-name diff --git a/python/samples/04-hosting/azure_functions/11_workflow_parallel/.gitignore b/python/samples/04-hosting/azure_functions/11_workflow_parallel/.gitignore deleted file mode 100644 index 41f350a67c5..00000000000 --- a/python/samples/04-hosting/azure_functions/11_workflow_parallel/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -.venv/ -__pycache__/ -local.settings.json -.env diff --git a/python/samples/04-hosting/azure_functions/11_workflow_parallel/README.md b/python/samples/04-hosting/azure_functions/11_workflow_parallel/README.md deleted file mode 100644 index 669f2579844..00000000000 --- a/python/samples/04-hosting/azure_functions/11_workflow_parallel/README.md +++ /dev/null @@ -1,194 +0,0 @@ -# Parallel Workflow Execution Sample - -This sample demonstrates **parallel execution** of executors and agents in Azure Durable Functions workflows. - -## Overview - -This sample showcases three different parallel execution patterns: - -1. **Two Executors in Parallel** - Fan-out to multiple activities -2. **Two Agents in Parallel** - Fan-out to multiple entities -3. **Mixed Execution** - Agents and executors can run concurrently - -## Workflow Architecture - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ PARALLEL WORKFLOW │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ Pattern 1: Two Executors in Parallel (Activities) │ -│ ───────────────────────────────────────────────── │ -│ │ -│ input_router ──┬──> [word_count_processor] ────┐ │ -│ │ │ │ -│ └──> [format_analyzer_processor]┴──> [aggregator] │ -│ │ -│ Pattern 2: Two Agents in Parallel (Entities) │ -│ ───────────────────────────────────────────── │ -│ │ -│ [prepare_for_agents] ──┬──> [SentimentAgent] ──────┐ │ -│ │ │ │ -│ └──> [KeywordAgent] ────────┴──> [prepare_for_│ -│ mixed] │ -│ │ -│ Pattern 3: Mixed Agent + Executor in Parallel │ -│ ──────────────────────────────────────────────── │ -│ │ -│ [prepare_for_mixed] ──┬──> [SummaryAgent] ─────────┐ │ -│ │ │ │ -│ └──> [statistics_processor] ─┴──> [final_report│ -│ _executor] │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - -## How Parallel Execution Works - -### Activities (Executors) -When multiple executors are pending in the same iteration (e.g., after a fan-out edge), they are batched and executed using `task_all()`: - -```python -# In _workflow.py - activities execute in parallel -activity_tasks = [context.call_activity("ExecuteExecutor", input) for ...] -results = yield context.task_all(activity_tasks) # All run concurrently! -``` - -### Agents (Entities) -Different agents can also run in parallel when they're pending in the same iteration: - -```python -# Different agents run in parallel -agent_tasks = [agent_a.run(...), agent_b.run(...)] -responses = yield context.task_all(agent_tasks) # Both agents run concurrently! -``` - -**Note:** Multiple messages to the *same* agent are processed sequentially to maintain conversation coherence. - -## Components - -| Component | Type | Description | -|-----------|------|-------------| -| `input_router` | Executor | Routes input JSON to parallel processors | -| `word_count_processor` | Executor | Counts words and characters | -| `format_analyzer_processor` | Executor | Analyzes document format | -| `aggregator` | Executor | Combines results from parallel processors | -| `prepare_for_agents` | Executor | Prepares content for agent analysis | -| `SentimentAnalysisAgent` | AI Agent | Analyzes text sentiment | -| `KeywordExtractionAgent` | AI Agent | Extracts keywords and categories | -| `prepare_for_mixed` | Executor | Prepares content for mixed parallel execution | -| `SummaryAgent` | AI Agent | Summarizes the document | -| `statistics_processor` | Executor | Computes document statistics | -| `FinalReportExecutor` | Executor | Compiles final report from all analyses | - -## Prerequisites - -1. **Azure OpenAI** - Endpoint and deployment configured -2. **DTS Emulator** - For durable task scheduling (recommended) -3. **Azurite** - For Azure Functions internal storage - -## Setup - -### Option 1: DevUI Mode (Local Development - No Durable Functions) - -The sample can run locally without Azure Functions infrastructure using DevUI: - -1. Copy the environment template: - ```bash - cp .env.template .env - ``` - -2. Configure `.env` with your Azure OpenAI credentials (`AZURE_OPENAI_ENDPOINT` and - `AZURE_OPENAI_MODEL`) - -3. Install dependencies: - ```bash - pip install -r requirements.txt - ``` - -4. Run in DevUI mode (set `durable=False` in `function_app.py`): - ```bash - python function_app.py - ``` - -5. Open `http://localhost:8095` and provide input: - ```json - { - "document_id": "doc-001", - "content": "Your document text here..." - } - ``` - -### Option 2: Durable Functions Mode (Full Azure Functions) - -1. Copy configuration files: - ```bash - cp .env.template .env - cp local.settings.json.sample local.settings.json - ``` - -2. Configure `local.settings.json` with your Azure OpenAI credentials - -3. Install dependencies: - ```bash - pip install -r requirements.txt - ``` - -4. Start DTS Emulator: - ```bash - docker run -d --name dts-emulator -p 8080:8080 -p 8082:8082 mcr.microsoft.com/dts/dts-emulator:latest - ``` - -5. Start Azurite (or use VS Code extension): - ```bash - azurite --silent - ``` - -6. Run the function app (ensure `durable=True` in `function_app.py`): - ```bash - func start - ``` - -## Testing - -Use the `demo.http` file with REST Client extension or curl: - -### Analyze a Document -```bash -curl -X POST http://localhost:7071/api/workflow/parallel_review/run \ - -H "Content-Type: application/json" \ - -d '{ - "document_id": "doc-001", - "content": "The quarterly earnings report shows strong growth in cloud services. Revenue increased by 25%." - }' -``` - -### Check Status -```bash -curl http://localhost:7071/api/workflow/parallel_review/status/{instanceId} -``` - -## Observing Parallel Execution - -Open the DTS Dashboard at `http://localhost:8082` to observe: - -1. **Activity Execution Timeline** - You'll see `word_count_processor` and `format_analyzer_processor` starting at approximately the same time -2. **Agent Execution Timeline** - `SentimentAnalysisAgent` and `KeywordExtractionAgent` also start concurrently -3. **Sequential vs Parallel** - Compare with non-parallel samples to see the time savings - -## Expected Output - -```json -{ - "output": [ - "=== Document Analysis Report ===\n\n--- SentimentAnalysisAgent ---\n{\"sentiment\": \"positive\", \"confidence\": 0.85, \"explanation\": \"...\"}\n\n--- KeywordExtractionAgent ---\n{\"keywords\": [\"earnings\", \"growth\", \"cloud\"], \"categories\": [\"finance\", \"technology\"]}" - ] -} -``` - -## Key Takeaways - -1. **Parallel execution is automatic** - When multiple executors/agents are pending in the same iteration, they run in parallel -2. **Workflow graph determines parallelism** - Fan-out edges create parallel execution opportunities -3. **Mixed parallelism** - Agents and executors can run concurrently if they're in the same iteration -4. **Same-agent messages are sequential** - To maintain conversation coherence diff --git a/python/samples/04-hosting/azure_functions/11_workflow_parallel/demo.http b/python/samples/04-hosting/azure_functions/11_workflow_parallel/demo.http deleted file mode 100644 index 065faf6c742..00000000000 --- a/python/samples/04-hosting/azure_functions/11_workflow_parallel/demo.http +++ /dev/null @@ -1,29 +0,0 @@ -### Analyze a document (triggers parallel workflow) -POST http://localhost:7071/api/workflow/parallel_review/run -Content-Type: application/json - -{ - "document_id": "doc-001", - "content": "The quarterly earnings report shows strong growth in our cloud services division. Revenue increased by 25% compared to last year, driven by enterprise adoption. Customer satisfaction remains high at 92%. However, we face challenges in the mobile segment where competition is intense. Overall, the outlook is positive with expected continued growth in the coming quarters." -} - -### - -### Short document test -POST http://localhost:7071/api/workflow/parallel_review/run -Content-Type: application/json - -{ - "document_id": "doc-002", - "content": "Quick update: Project completed successfully. Team performance exceeded expectations." -} - -### - -### Check workflow status -GET http://localhost:7071/api/workflow/parallel_review/status/{{instanceId}} - -### - -### Health check -GET http://localhost:7071/api/health diff --git a/python/samples/04-hosting/azure_functions/11_workflow_parallel/function_app.py b/python/samples/04-hosting/azure_functions/11_workflow_parallel/function_app.py deleted file mode 100644 index 4da134124ac..00000000000 --- a/python/samples/04-hosting/azure_functions/11_workflow_parallel/function_app.py +++ /dev/null @@ -1,491 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. -"""Parallel Workflow Execution Sample. - -This sample demonstrates parallel execution of executors and agents in Azure Durable Functions. -It showcases three different parallel execution patterns: - -1. Two executors running concurrently (fan-out to activities) -2. Two agents running concurrently (fan-out to entities) -3. One executor and one agent running concurrently (mixed fan-out) - -The workflow simulates a document processing pipeline where: -- A document is analyzed by multiple processors in parallel -- Results are aggregated and then processed by agents -- A summary agent and statistics executor run in parallel -- Finally, combined into a single output - -Key architectural points: -- FanOut edges enable parallel execution -- Different agents run in parallel when they're in the same iteration -- Activities (executors) also run in parallel when pending together -- Mixed agent/executor fan-outs execute concurrently - -Prerequisites: -- Configure `AZURE_OPENAI_ENDPOINT` and `AZURE_OPENAI_MODEL` -- Sign in with Azure CLI (`az login`) for `AzureCliCredential` -- Ensure Azurite and the Durable Task Scheduler emulator are running -""" - -import logging -import os -from dataclasses import dataclass -from typing import Any - -from agent_framework import ( - Agent, - AgentExecutorResponse, - Executor, - Workflow, - WorkflowBuilder, - WorkflowContext, - executor, - handler, -) -from agent_framework.azure import AgentFunctionApp -from agent_framework.openai import OpenAIChatCompletionClient, OpenAIChatCompletionOptions -from azure.identity.aio import AzureCliCredential, get_bearer_token_provider -from pydantic import BaseModel -from typing_extensions import Never - -logger = logging.getLogger(__name__) - -# Agent names -SENTIMENT_AGENT_NAME = "SentimentAnalysisAgent" -KEYWORD_AGENT_NAME = "KeywordExtractionAgent" -SUMMARY_AGENT_NAME = "SummaryAgent" -RECOMMENDATION_AGENT_NAME = "RecommendationAgent" - - -# ============================================================================ -# Pydantic Models for structured outputs -# ============================================================================ - - -class SentimentResult(BaseModel): - """Result from sentiment analysis.""" - - sentiment: str # positive, negative, neutral - confidence: float - explanation: str - - -class KeywordResult(BaseModel): - """Result from keyword extraction.""" - - keywords: list[str] - categories: list[str] - - -class SummaryResult(BaseModel): - """Result from summarization.""" - - summary: str - key_points: list[str] - - -class RecommendationResult(BaseModel): - """Result from recommendation engine.""" - - recommendations: list[str] - priority: str - - -@dataclass -class DocumentInput: - """Input document to be processed.""" - - document_id: str - content: str - - -@dataclass -class ProcessorResult: - """Result from a document processor (executor).""" - - processor_name: str - document_id: str - content: str - word_count: int - char_count: int - has_numbers: bool - - -@dataclass -class AggregatedResults: - """Aggregated results from parallel processors.""" - - document_id: str - content: str - processor_results: list[ProcessorResult] - - -@dataclass -class AgentAnalysis: - """Analysis result from an agent.""" - - agent_name: str - result: str - - -@dataclass -class FinalReport: - """Final combined report.""" - - document_id: str - analyses: list[AgentAnalysis] - - -# ============================================================================ -# Executor Definitions (Activities - run in parallel when pending together) -# ============================================================================ - - -@executor(id="input_router") -async def input_router(document: DocumentInput, ctx: WorkflowContext[DocumentInput]) -> None: - """Route the input document to the parallel processors. - - The durable engine reconstructs ``DocumentInput`` from the client's JSON - payload before delivery, mirroring in-process execution. - """ - logger.info("[input_router] Routing document: %s", document.document_id) - await ctx.send_message(document) - - -@executor(id="word_count_processor") -async def word_count_processor(doc: DocumentInput, ctx: WorkflowContext[ProcessorResult]) -> None: - """Process document and count words - runs as an activity.""" - logger.info("[word_count_processor] Processing document: %s", doc.document_id) - - word_count = len(doc.content.split()) - char_count = len(doc.content) - has_numbers = any(c.isdigit() for c in doc.content) - - result = ProcessorResult( - processor_name="word_count", - document_id=doc.document_id, - content=doc.content, - word_count=word_count, - char_count=char_count, - has_numbers=has_numbers, - ) - - await ctx.send_message(result) - - -@executor(id="format_analyzer_processor") -async def format_analyzer_processor(doc: DocumentInput, ctx: WorkflowContext[ProcessorResult]) -> None: - """Analyze document format - runs as an activity in parallel with word_count.""" - logger.info("[format_analyzer_processor] Processing document: %s", doc.document_id) - - # Simple format analysis - lines = doc.content.split("\n") - word_count = len(lines) # Using line count as "word count" for this processor - char_count = sum(len(line) for line in lines) - has_numbers = doc.content.count(".") > 0 # Check for sentences - - result = ProcessorResult( - processor_name="format_analyzer", - document_id=doc.document_id, - content=doc.content, - word_count=word_count, - char_count=char_count, - has_numbers=has_numbers, - ) - - await ctx.send_message(result) - - -@executor(id="aggregator") -async def aggregator(results: list[ProcessorResult], ctx: WorkflowContext[AggregatedResults]) -> None: - """Aggregate results from parallel processors - receives fan-in input.""" - logger.info("[aggregator] Aggregating %d results", len(results)) - - # Extract document info from the first result (all have the same content) - document_id = results[0].document_id if results else "unknown" - content = results[0].content if results else "" - - aggregated = AggregatedResults( - document_id=document_id, - content=content, - processor_results=results, - ) - - await ctx.send_message(aggregated) - - -@executor(id="prepare_for_agents") -async def prepare_for_agents(aggregated: AggregatedResults, ctx: WorkflowContext[str]) -> None: - """Prepare content for agent analysis - broadcasts to multiple agents.""" - logger.info("[prepare_for_agents] Preparing content for agents") - - # Send the original content to agents for analysis - await ctx.send_message(aggregated.content) - - -@executor(id="prepare_for_mixed") -async def prepare_for_mixed(analyses: list[AgentExecutorResponse], ctx: WorkflowContext[str]) -> None: - """Prepare results for mixed agent+executor parallel processing. - - Combines agent analysis results into a string that can be consumed by - both the SummaryAgent and the statistics_processor in parallel. - """ - logger.info("[prepare_for_mixed] Preparing for mixed parallel pattern") - - sentiment_text = "" - keyword_text = "" - - for analysis in analyses: - executor_id = analysis.executor_id - text = analysis.agent_response.text if analysis.agent_response else "" - - if executor_id == SENTIMENT_AGENT_NAME: - sentiment_text = text - elif executor_id == KEYWORD_AGENT_NAME: - keyword_text = text - - # Combine into a string that both agent and executor can process - combined = f"Sentiment Analysis: {sentiment_text}\n\nKeyword Extraction: {keyword_text}" - await ctx.send_message(combined) - - -@executor(id="statistics_processor") -async def statistics_processor(analysis_text: str, ctx: WorkflowContext[ProcessorResult]) -> None: - """Calculate statistics from the analysis - runs in parallel with SummaryAgent.""" - logger.info("[statistics_processor] Calculating statistics") - - # Calculate some statistics from the combined analysis - word_count = len(analysis_text.split()) - char_count = len(analysis_text) - has_numbers = any(c.isdigit() for c in analysis_text) - - result = ProcessorResult( - processor_name="statistics", - document_id="analysis", - content=analysis_text, - word_count=word_count, - char_count=char_count, - has_numbers=has_numbers, - ) - await ctx.send_message(result) - - -class FinalReportExecutor(Executor): - """Executor that compiles the final report from agent analyses.""" - - @handler - async def compile_report( - self, - analyses: list[AgentExecutorResponse | ProcessorResult], - ctx: WorkflowContext[Never, str], - ) -> None: - """Compile final report from mixed agent + processor results.""" - logger.info("[final_report] Compiling report from %d analyses", len(analyses)) - - report_parts = ["=== Document Analysis Report ===\n"] - - for analysis in analyses: - if isinstance(analysis, AgentExecutorResponse): - agent_name = analysis.executor_id - text = analysis.agent_response.text if analysis.agent_response else "No response" - elif isinstance(analysis, ProcessorResult): - agent_name = f"Processor: {analysis.processor_name}" - text = f"Words: {analysis.word_count}, Chars: {analysis.char_count}" - else: - continue - - report_parts.append(f"\n--- {agent_name} ---") - report_parts.append(text) - - final_report = "\n".join(report_parts) - await ctx.yield_output(final_report) - - -class MixedResultCollector(Executor): - """Collector for mixed agent/executor results.""" - - @handler - async def collect_mixed_results( - self, - results: list[Any], - ctx: WorkflowContext[Never, str], - ) -> None: - """Collect and format results from mixed parallel execution.""" - logger.info("[mixed_collector] Collecting %d mixed results", len(results)) - - output_parts = ["=== Mixed Parallel Execution Results ===\n"] - - for result in results: - if isinstance(result, AgentExecutorResponse): - output_parts.append(f"[Agent: {result.executor_id}]") - output_parts.append(result.agent_response.text if result.agent_response else "No response") - elif isinstance(result, ProcessorResult): - output_parts.append(f"[Processor: {result.processor_name}]") - output_parts.append(f" Words: {result.word_count}, Chars: {result.char_count}") - - await ctx.yield_output("\n".join(output_parts)) - - -# ============================================================================ -# Workflow Construction -# ============================================================================ - - -def _create_workflow() -> Workflow: - """Create the parallel workflow definition. - - Workflow structure demonstrating three parallel patterns: - - Pattern 1: Two Executors in Parallel (Fan-out/Fan-in to activities) - ──────────────────────────────────────────────────────────────────── - ┌─> word_count_processor ─────┐ - input_router ──┤ ├──> aggregator - └─> format_analyzer_processor ─┘ - - Pattern 2: Two Agents in Parallel (Fan-out to entities) - ──────────────────────────────────────────────────────── - prepare_for_agents ─┬─> SentimentAgent ──┐ - └─> KeywordAgent ────┤ - └──> prepare_for_mixed - - Pattern 3: Mixed Agent + Executor in Parallel - ────────────────────────────────────────────── - prepare_for_mixed ─┬─> SummaryAgent ────────┐ - └─> statistics_processor ─┤ - └──> final_report - """ - credential = AzureCliCredential() - - chat_client = OpenAIChatCompletionClient( - model=os.environ["AZURE_OPENAI_MODEL"], - credential=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default"), - ) - - # Create agents for parallel analysis - sentiment_agent = Agent( - client=chat_client, - name=SENTIMENT_AGENT_NAME, - instructions=( - "You are a sentiment analysis expert. Analyze the sentiment of the given text. " - "Return JSON with fields: sentiment (positive/negative/neutral), " - "confidence (0.0-1.0), and explanation (brief reasoning)." - ), - default_options=OpenAIChatCompletionOptions[Any](response_format=SentimentResult), - ) - - keyword_agent = Agent( - client=chat_client, - name=KEYWORD_AGENT_NAME, - instructions=( - "You are a keyword extraction expert. Extract important keywords and categories " - "from the given text. Return JSON with fields: keywords (list of strings), " - "and categories (list of topic categories)." - ), - default_options=OpenAIChatCompletionOptions[Any](response_format=KeywordResult), - ) - - # Create summary agent for Pattern 3 (mixed parallel) - summary_agent = Agent( - client=chat_client, - name=SUMMARY_AGENT_NAME, - instructions=( - "You are a summarization expert. Given analysis results (sentiment and keywords), " - "provide a concise summary. Return JSON with fields: summary (brief text), " - "and key_points (list of main takeaways)." - ), - default_options=OpenAIChatCompletionOptions[Any](response_format=SummaryResult), - ) - - # Create executor instances - final_report_executor = FinalReportExecutor(id="final_report") - - # Build workflow with parallel patterns - return ( - WorkflowBuilder(name="parallel_review", start_executor=input_router) - # Pattern 1: Fan-out to two executors (run in parallel) - .add_fan_out_edges( - source=input_router, - targets=[word_count_processor, format_analyzer_processor], - ) - # Fan-in: Both processors send results to aggregator - .add_fan_in_edges( - sources=[word_count_processor, format_analyzer_processor], - target=aggregator, - ) - # Prepare content for agent analysis - .add_edge(aggregator, prepare_for_agents) - # Pattern 2: Fan-out to two agents (run in parallel) - .add_fan_out_edges( - source=prepare_for_agents, - targets=[sentiment_agent, keyword_agent], - ) - # Fan-in: Collect agent results into prepare_for_mixed - .add_fan_in_edges( - sources=[sentiment_agent, keyword_agent], - target=prepare_for_mixed, - ) - # Pattern 3: Fan-out to one agent + one executor (mixed parallel) - .add_fan_out_edges( - source=prepare_for_mixed, - targets=[summary_agent, statistics_processor], - ) - # Final fan-in: Collect mixed results - .add_fan_in_edges( - sources=[summary_agent, statistics_processor], - target=final_report_executor, - ) - .build() - ) - - -# ============================================================================ -# Application Entry Point -# ============================================================================ - - -def launch(durable: bool = True) -> AgentFunctionApp | None: - """Launch the function app or DevUI.""" - workflow: Workflow | None = None - - if durable: - workflow = _create_workflow() - return AgentFunctionApp( - workflow=workflow, - enable_health_check=True, - ) - from pathlib import Path - - from agent_framework.devui import serve - from dotenv import load_dotenv - - env_path = Path(__file__).parent / ".env" - load_dotenv(dotenv_path=env_path) - - logger.info("Starting Parallel Workflow Sample") - logger.info("Available at: http://localhost:8095") - logger.info("\nThis workflow demonstrates:") - logger.info("- Pattern 1: Two executors running in parallel") - logger.info("- Pattern 2: Two agents running in parallel") - logger.info("- Pattern 3: Mixed agent + executor running in parallel") - logger.info("- Fan-in aggregation of parallel results") - - workflow = _create_workflow() - serve(entities=[workflow], port=8095, auto_open=True) - - return None - - -# Default: Azure Functions mode -# Run with `python function_app.py --maf` for pure MAF mode with DevUI -app = launch(durable=True) - - -if __name__ == "__main__": - import sys - - if "--maf" in sys.argv: - # Run in pure MAF mode with DevUI - launch(durable=False) - else: - print("Usage: python function_app.py --maf") - print(" --maf Run in pure MAF mode with DevUI (http://localhost:8095)") - print("\nFor Azure Functions mode, use: func start") diff --git a/python/samples/04-hosting/azure_functions/11_workflow_parallel/host.json b/python/samples/04-hosting/azure_functions/11_workflow_parallel/host.json deleted file mode 100644 index 9e7fd873dda..00000000000 --- a/python/samples/04-hosting/azure_functions/11_workflow_parallel/host.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "version": "2.0", - "extensionBundle": { - "id": "Microsoft.Azure.Functions.ExtensionBundle", - "version": "[4.*, 5.0.0)" - }, - "extensions": { - "durableTask": { - "hubName": "%TASKHUB_NAME%" - } - } -} diff --git a/python/samples/04-hosting/azure_functions/11_workflow_parallel/local.settings.json.sample b/python/samples/04-hosting/azure_functions/11_workflow_parallel/local.settings.json.sample deleted file mode 100644 index 5b65dd278f2..00000000000 --- a/python/samples/04-hosting/azure_functions/11_workflow_parallel/local.settings.json.sample +++ /dev/null @@ -1,11 +0,0 @@ -{ - "IsEncrypted": false, - "Values": { - "FUNCTIONS_WORKER_RUNTIME": "python", - "AzureWebJobsStorage": "UseDevelopmentStorage=true", - "DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None", - "TASKHUB_NAME": "default", - "AZURE_OPENAI_ENDPOINT": "", - "AZURE_OPENAI_MODEL": "" - } -} diff --git a/python/samples/04-hosting/azure_functions/11_workflow_parallel/requirements.txt b/python/samples/04-hosting/azure_functions/11_workflow_parallel/requirements.txt deleted file mode 100644 index b40fb787064..00000000000 --- a/python/samples/04-hosting/azure_functions/11_workflow_parallel/requirements.txt +++ /dev/null @@ -1,15 +0,0 @@ -# Agent Framework packages -# To use the deployed version, uncomment the lines below and comment out the local installation lines -# agent-framework-openai -# agent-framework-azurefunctions - -# Local installation (for development and testing) -# Each package must be listed explicitly because pip doesn't resolve uv workspace sources. -# Without explicit entries, pip would fetch transitive dependencies from PyPI instead of local source. --e ../../../../packages/core # Core framework - base dependency for all packages --e ../../../../packages/openai # OpenAI support - dependency for Azure OpenAI chat samples --e ../../../../packages/durabletask # Durable Task support - dependency of azurefunctions --e ../../../../packages/azurefunctions # Azure Functions integration - the main package for this sample - -# Azure authentication -azure-identity diff --git a/python/samples/04-hosting/azure_functions/12_workflow_hitl/.gitignore b/python/samples/04-hosting/azure_functions/12_workflow_hitl/.gitignore deleted file mode 100644 index 7097fe01703..00000000000 --- a/python/samples/04-hosting/azure_functions/12_workflow_hitl/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -# Local settings - copy from local.settings.json.sample and fill in your values -local.settings.json -__pycache__/ -*.pyc -.venv/ diff --git a/python/samples/04-hosting/azure_functions/12_workflow_hitl/README.md b/python/samples/04-hosting/azure_functions/12_workflow_hitl/README.md deleted file mode 100644 index d7fa9ca7351..00000000000 --- a/python/samples/04-hosting/azure_functions/12_workflow_hitl/README.md +++ /dev/null @@ -1,171 +0,0 @@ -# 12. Workflow with Human-in-the-Loop (HITL) - -This sample demonstrates how to integrate human approval into a MAF workflow running on Azure Durable Functions using the MAF `request_info` and `@response_handler` pattern. - -## Overview - -The sample implements a content moderation pipeline: - -1. **User starts workflow** with content for publication via HTTP endpoint -2. **AI Agent analyzes** the content for policy compliance -3. **Workflow pauses** and requests human reviewer approval -4. **Human responds** via HTTP endpoint with approval/rejection -5. **Workflow resumes** and publishes or rejects the content - -## Key Concepts - -### MAF HITL Pattern - -This sample uses MAF's built-in human-in-the-loop pattern: - -```python -# In an executor, request human input -await ctx.request_info( - request_data=HumanApprovalRequest(...), - response_type=HumanApprovalResponse, -) - -# Handle the response in a separate method -@response_handler -async def handle_approval_response( - self, - original_request: HumanApprovalRequest, - response: HumanApprovalResponse, - ctx: WorkflowContext, -) -> None: - # Process the human's decision - ... -``` - -### Notifying a reviewer from inside the workflow - -This sample also shows how a workflow notifies a human itself (for example to email an approval link) instead of relying on the caller to poll the status endpoint. It uses a two step pattern so the reviewer gets a working respond URL. - -```python -# Pause the workflow. request_info generates the request id internally. -await ctx.request_info( - request_data=HumanApprovalRequest(...), - response_type=HumanApprovalResponse, -) - -# Read that id back, then build the respond URL with WorkflowHitlContext. -request_id = await WorkflowHitlContext.pending_request_id(ctx) -hitl = WorkflowHitlContext.from_context(ctx) -if hitl and request_id: - respond_url = hitl.build_respond_url(request_id) # email this to the reviewer -``` - -Two things make this safe and worth understanding. - -**You never generate the id.** `request_info` already creates one and stores it on the context before it returns, so `pending_request_id(ctx)` reads it straight back. You must call `pending_request_id` immediately after `request_info`, because it returns the newest pending request, which is the one you just emitted. On the durable host this is reliable. Every executor runs in its own Durable Functions activity with its own runner context, so that pending set only ever holds this executor's own requests. If a single executor emits several requests in one turn, read the id after each call, or pass your own `request_id` to `request_info`. - -**The notification runs in a separate executor.** The review executor sends the id to a downstream `NotifyExecutor` that builds the URL, rather than emailing from the executor that generated the id. Because activities are at least once, an executor can be retried, and each retry mints a fresh id. Keeping the email in a downstream executor means only the committed attempt's id ever reaches the notifier, so a retried review executor never emails a dead link. `WorkflowHitlContext.from_context(ctx)` returns `None` when the executor is not on the durable host (for example under `--maf` DevUI), so the notify step skips cleanly there. - -The base URL comes from the `WEBSITE_HOSTNAME` app setting, which Azure Functions sets automatically in the cloud. For a custom domain or an API Management gateway, pass `base_url=...` to `from_context`, because `WEBSITE_HOSTNAME` still reports the default `*.azurewebsites.net` host. - -### Automatic HITL Endpoints - -`AgentFunctionApp` automatically provides all the HTTP endpoints needed for HITL: - -| Endpoint | Description | -|----------|-------------| -| `POST /api/workflow/content_moderation/run` | Start the workflow | -| `GET /api/workflow/content_moderation/status/{instanceId}` | Check status and pending HITL requests | -| `POST /api/workflow/content_moderation/respond/{instanceId}/{requestId}` | Send human response | -| `GET /api/health` | Health check | - -These routes expose workflow status and human-response operations. In production, put them behind your application's authentication and authorization layer and verify that the caller is allowed to inspect or resume the targeted workflow before returning status or accepting a response. - -Treat `instanceId` and `requestId` as correlation handles only. They help locate workflow state, but they are not secrets or proof that a caller is authorized to act on that workflow. - -### Durable Functions Integration - -When running on Durable Functions, the HITL pattern maps to: - -| MAF Concept | Durable Functions | -|-------------|-------------------| -| `ctx.request_info()` | Workflow pauses, custom status updated | -| `RequestInfoEvent` | Exposed via status endpoint | -| HTTP response | `client.raise_event(instance_id, request_id, data)` | -| `@response_handler` | Workflow resumes, handler invoked | - -## Workflow Architecture - -``` -┌─────────────────┐ ┌──────────────────────┐ ┌────────────────────────┐ -│ Input Router │ ──► │ Content Analyzer │ ──► │ Content Analyzer │ -│ Executor │ │ Agent (AI) │ │ Executor (Parse JSON) │ -└─────────────────┘ └──────────────────────┘ └────────────────────────┘ - │ - ▼ -┌─────────────────┐ ┌──────────────────────┐ -│ Publish │ ◄── │ Human Review │ ◄── HITL PAUSE -│ Executor │ │ Executor │ (wait for external event) -└─────────────────┘ └──────────────────────┘ -``` - -## Prerequisites - -1. **Azure OpenAI** - Access to Azure OpenAI with a deployed chat model -2. **Durable Task Scheduler** - Local emulator or Azure deployment -3. **Azurite** - Local Azure Storage emulator -4. **Azure CLI** - For authentication (`az login`) - -## Setup - -1. Copy the sample settings file: - ```bash - cp local.settings.json.sample local.settings.json - ``` - -2. Update `local.settings.json` with your Foundry project settings: - ```json - { - "Values": { - "FOUNDRY_PROJECT_ENDPOINT": "https://your-project.services.ai.azure.com/api/projects/your-project", - "FOUNDRY_MODEL": "gpt-4o" - } - } - ``` - -3. Start the local emulators: - ```bash - # Terminal 1: Start Azurite - azurite --silent --location . - - # Terminal 2: Start Durable Task Scheduler (if using local emulator) - # Follow Durable Task Scheduler setup instructions - ``` - -4. Start the Function App: - ```bash - func start - ``` - -## Running in Pure MAF Mode - -You can also run this sample in pure MAF mode (without Durable Functions) using the DevUI: - -```bash -python function_app.py --maf -``` - -This launches the DevUI at http://localhost:8096 where you can interact with the workflow directly. This is useful for: -- Local development and debugging -- Testing the HITL pattern without Durable Functions infrastructure -- Comparing behavior between MAF and Durable modes - -## Testing - -Use the `demo.http` file with the VS Code REST Client extension: - -1. **Start workflow** - `POST /api/workflow/content_moderation/run` with content payload -2. **Check status** - `GET /api/workflow/content_moderation/status/{instanceId}` to see pending HITL requests -3. **Send response** - `POST /api/workflow/content_moderation/respond/{instanceId}/{requestId}` with approval -4. **Check result** - `GET /api/workflow/content_moderation/status/{instanceId}` to see final output - -## Related Samples - -- [07_single_agent_orchestration_hitl](../07_single_agent_orchestration_hitl/) - HITL at orchestrator level (not using MAF pattern) -- [09_workflow_shared_state](../09_workflow_shared_state/) - Workflow with shared state -- [guessing_game_with_human_input](../../../03-workflows/human-in-the-loop/guessing_game_with_human_input.py) - MAF HITL pattern (non-durable) diff --git a/python/samples/04-hosting/azure_functions/12_workflow_hitl/demo.http b/python/samples/04-hosting/azure_functions/12_workflow_hitl/demo.http deleted file mode 100644 index 423254964a0..00000000000 --- a/python/samples/04-hosting/azure_functions/12_workflow_hitl/demo.http +++ /dev/null @@ -1,123 +0,0 @@ -### ============================================================================ -### Workflow HITL Sample - Content Moderation with Human Approval -### ============================================================================ -### This sample demonstrates MAF workflows with human-in-the-loop using the -### request_info / @response_handler pattern on Azure Durable Functions. -### -### The AgentFunctionApp automatically provides all HITL endpoints. -### -### Prerequisites: -### 1. Start Azurite: azurite --silent --location . -### 2. Start Durable Task Scheduler emulator -### 3. Configure local.settings.json with Azure OpenAI credentials -### 4. Run: func start -### ============================================================================ - - -### ============================================================================ -### 1. Start the Workflow with Content for Moderation -### ============================================================================ -### This starts the workflow. The AI will analyze the content, then the workflow -### will pause waiting for human approval. - -POST http://localhost:7071/api/workflow/content_moderation/run -Content-Type: application/json - -{ - "content_id": "article-001", - "title": "Introduction to AI in Healthcare", - "body": "Artificial intelligence is revolutionizing healthcare by enabling faster diagnosis, personalized treatment plans, and improved patient outcomes. Machine learning algorithms can analyze medical images with remarkable accuracy, often detecting issues that human radiologists might miss.", - "author": "Dr. Jane Smith" -} - - -### ============================================================================ -### 2. Start Workflow with Potentially Problematic Content -### ============================================================================ -### This content should trigger higher risk assessment from the AI analyzer. - -POST http://localhost:7071/api/workflow/content_moderation/run -Content-Type: application/json - -{ - "content_id": "article-002", - "title": "Get Rich Quick Scheme", - "body": "Click here NOW to make $10,000 overnight! This SECRET method is GUARANTEED to work! Limited time offer - act NOW before it's too late! Send your bank details immediately!", - "author": "Definitely Not Spam" -} - - -### ============================================================================ -### 3. Check Workflow Status -### ============================================================================ -### Replace INSTANCE_ID with the value returned from the run call. -### The status will show pending HITL requests if waiting for human approval. - -@instanceId = 3130c486c9374e4e87125cbd9a238dfc - -GET http://localhost:7071/api/workflow/content_moderation/status/{{instanceId}} - - -### ============================================================================ -### 4. Send Human Approval -### ============================================================================ -### Approve the content for publication. -### Replace INSTANCE_ID and REQUEST_ID with values from the status response. - -@requestId = 1682e5f8-0917-4b68-aa04-d4688cfa2e69 - -POST http://localhost:7071/api/workflow/content_moderation/respond/{{instanceId}}/{{requestId}} -Content-Type: application/json - -{ - "approved": true, - "reviewer_notes": "Content is appropriate and well-written. Approved for publication." -} - - -### ============================================================================ -### 5. Send Human Rejection -### ============================================================================ -### Reject the content with feedback. - -POST http://localhost:7071/api/workflow/content_moderation/respond/{{instanceId}}/{{requestId}} -Content-Type: application/json - -{ - "approved": false, - "reviewer_notes": "Content appears to be spam. Contains multiple spam indicators including urgency language, promises of easy money, and requests for personal information." -} - - -### ============================================================================ -### Example Workflow - Complete Happy Path -### ============================================================================ -### -### Step 1: Start workflow with content -### POST http://localhost:7071/api/workflow/content_moderation/run -### -> Returns instanceId: "abc123..." -### -### Step 2: Check status (workflow is waiting for human input) -### GET http://localhost:7071/api/workflow/content_moderation/status/abc123 -### -> Returns pendingHumanInputRequests with requestId: "req-456..." -### -### Step 3: Approve content -### POST http://localhost:7071/api/workflow/content_moderation/respond/abc123/req-456 -### { -### "approved": true, -### "reviewer_notes": "Looks good!" -### } -### -> Returns success -### -### Step 4: Check final status -### GET http://localhost:7071/api/workflow/content_moderation/status/abc123 -### -> Returns runtimeStatus: "Completed", output: "✅ Content approved..." -### -### ============================================================================ - - -### ============================================================================ -### Health Check -### ============================================================================ - -GET http://localhost:7071/api/health diff --git a/python/samples/04-hosting/azure_functions/12_workflow_hitl/function_app.py b/python/samples/04-hosting/azure_functions/12_workflow_hitl/function_app.py deleted file mode 100644 index 37ea8fdf1a4..00000000000 --- a/python/samples/04-hosting/azure_functions/12_workflow_hitl/function_app.py +++ /dev/null @@ -1,549 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. -"""Workflow with Human-in-the-Loop (HITL) using MAF request_info Pattern. - -This sample demonstrates how to integrate human approval into a MAF workflow -running on Azure Durable Functions. It uses the MAF `request_info` and -`@response_handler` pattern for structured HITL interactions. - -The workflow simulates a content moderation pipeline: -1. User submits content for publication -2. An AI agent analyzes the content for policy compliance -3. A human reviewer is emailed an approval link and prompted to approve/reject -4. Based on approval, content is either published or rejected - -Key architectural points: -- Uses MAF's `ctx.request_info()` to pause workflow and request human input -- A `NotifyExecutor` builds the respond URL via `WorkflowHitlContext` and notifies the - reviewer out-of-band (email) -- the caller never threads instanceId / requestId by hand -- Uses `@response_handler` decorator to handle the human's response -- AgentFunctionApp automatically provides HITL endpoints for status and response -- Durable Functions provides durability while waiting for human input - -Prerequisites: -- Configure `FOUNDRY_PROJECT_ENDPOINT` and `FOUNDRY_MODEL` -- Durable Task Scheduler connection string -- Authentication via Azure CLI (az login) -""" - -import logging -import os -from dataclasses import dataclass -from typing import Any - -from agent_framework import ( - Agent, - AgentExecutorRequest, - AgentExecutorResponse, - Executor, - Message, - Workflow, - WorkflowBuilder, - WorkflowContext, - handler, - response_handler, -) -from agent_framework.foundry import FoundryChatClient -from agent_framework.openai import OpenAIChatOptions -from agent_framework_azurefunctions import AgentFunctionApp, WorkflowHitlContext -from azure.identity.aio import AzureCliCredential -from pydantic import BaseModel, ValidationError -from typing_extensions import Never - -logger = logging.getLogger(__name__) - -# Environment variable names -FOUNDRY_PROJECT_ENDPOINT_ENV = "FOUNDRY_PROJECT_ENDPOINT" -AZURE_OPENAI_DEPLOYMENT_ENV = "FOUNDRY_MODEL" - -# Agent names -CONTENT_ANALYZER_AGENT_NAME = "ContentAnalyzerAgent" - - -# ============================================================================ -# Data Models -# ============================================================================ - - -class ContentAnalysisResult(BaseModel): - """Structured output from the content analysis agent.""" - - is_appropriate: bool - risk_level: str # low, medium, high - concerns: list[str] - recommendation: str - - -@dataclass -class ContentSubmission: - """Content submitted for moderation.""" - - content_id: str - title: str - body: str - author: str - - -@dataclass -class HumanApprovalRequest: - """Request sent to human reviewer for approval. - - This is the payload passed to ctx.request_info() and will be - exposed via the orchestration status for external systems to retrieve. - """ - - content_id: str - title: str - body: str - author: str - ai_analysis: ContentAnalysisResult - prompt: str - - -class HumanApprovalResponse(BaseModel): - """Response from human reviewer. - - This is what the external system must send back via the HITL response endpoint. - """ - - approved: bool - reviewer_notes: str = "" - - -@dataclass -class ModerationResult: - """Final result of the moderation workflow.""" - - content_id: str - status: str # "approved", "rejected" - ai_analysis: ContentAnalysisResult | None - reviewer_notes: str - - -@dataclass -class HumanReviewNotification: - """Payload handed to the notifier so it can build the respond URL and alert a human. - - Carries the ``request_id`` the review executor passed to ``ctx.request_info`` so the - notifier addresses the exact pending request the workflow is waiting on. - """ - - request_id: str - content_id: str - prompt: str - - -# ============================================================================ -# Agent Instructions -# ============================================================================ - -CONTENT_ANALYZER_INSTRUCTIONS = """You are a content moderation assistant that analyzes user-submitted content -for policy compliance. Evaluate the content for: - -1. Appropriateness - Is the content suitable for a general audience? -2. Risk level - Rate as 'low', 'medium', or 'high' based on potential issues -3. Concerns - List any specific issues found (empty list if none) -4. Recommendation - Provide a brief recommendation for human reviewers - -Return a JSON response with: -- is_appropriate: boolean -- risk_level: string ('low', 'medium', 'high') -- concerns: list of strings -- recommendation: string - -Be thorough but fair in your analysis.""" - - -# ============================================================================ -# Executors -# ============================================================================ - - -@dataclass -class AnalysisWithSubmission: - """Combines the AI analysis with the original submission for downstream processing.""" - - submission: ContentSubmission - analysis: ContentAnalysisResult - - -class ContentAnalyzerExecutor(Executor): - """Parses the AI agent's response and prepares for human review.""" - - def __init__(self): - super().__init__(id="content_analyzer_executor") - - @handler - async def handle_analysis( - self, - response: AgentExecutorResponse, - ctx: WorkflowContext[AnalysisWithSubmission], - ) -> None: - """Parse the AI analysis and forward with submission context.""" - try: - analysis = ContentAnalysisResult.model_validate_json(response.agent_response.text) - except ValidationError: - analysis = ContentAnalysisResult( - is_appropriate=False, - risk_level="high", - concerns=["Agent execution failed or yielded invalid JSON (possible content filter)."], - recommendation="Manual review required", - ) - - # Retrieve the original submission from shared state - submission: ContentSubmission = ctx.get_state("current_submission") - - await ctx.send_message(AnalysisWithSubmission(submission=submission, analysis=analysis)) - - -class HumanReviewExecutor(Executor): - """Requests human approval using MAF's request_info pattern. - - This executor demonstrates the core HITL pattern: - 1. Receives the AI analysis result - 2. Calls ctx.request_info() to pause and request human input - 3. The @response_handler method processes the human's response - """ - - def __init__(self): - super().__init__(id="human_review_executor") - - @handler - async def request_review( - self, - data: AnalysisWithSubmission, - ctx: WorkflowContext[HumanReviewNotification], - ) -> None: - """Request human review for the content. - - This method: - 1. Constructs the approval request with all context - 2. Sends the request id to the notifier so it can email the reviewer a link - 3. Calls request_info to pause the workflow until a response arrives - """ - submission = data.submission - analysis = data.analysis - - # Construct the human-readable prompt - prompt = ( - f"Please review the following content for publication:\n\n" - f"Title: {submission.title}\n" - f"Author: {submission.author}\n" - f"Content: {submission.body}\n\n" - f"AI Analysis:\n" - f"- Appropriate: {analysis.is_appropriate}\n" - f"- Risk Level: {analysis.risk_level}\n" - f"- Concerns: {', '.join(analysis.concerns) if analysis.concerns else 'None'}\n" - f"- Recommendation: {analysis.recommendation}\n\n" - f"Please approve or reject this content." - ) - - approval_request = HumanApprovalRequest( - content_id=submission.content_id, - title=submission.title, - body=submission.body, - author=submission.author, - ai_analysis=analysis, - prompt=prompt, - ) - - # Store analysis in shared state for the response handler - ctx.set_state("pending_analysis", data) - - # Pause the workflow for human input. request_info generates the request id; - # read it back so a downstream NotifyExecutor can build the respond URL -- no - # need to mint an id by hand. - await ctx.request_info( - request_data=approval_request, - response_type=HumanApprovalResponse, - ) - request_id = await WorkflowHitlContext.pending_request_id(ctx) - assert request_id is not None # always set immediately after request_info - - # Side-branch: hand the request id to the notifier so it can build the respond - # URL and alert the reviewer. The email is sent by the downstream NotifyExecutor - # (a separate activity), so only this activity's committed id ever reaches the - # notifier -- retries of this executor notify no one. The orchestrator drains - # this message before entering the HITL wait. - await ctx.send_message( - HumanReviewNotification( - request_id=request_id, - content_id=submission.content_id, - prompt=prompt, - ), - target_id="notify_executor", - ) - - @response_handler - async def handle_approval_response( - self, - original_request: HumanApprovalRequest, - response: HumanApprovalResponse, - ctx: WorkflowContext[ModerationResult], - ) -> None: - """Process the human reviewer's decision. - - This method is called automatically when a response to request_info is received. - The original_request contains the HumanApprovalRequest we sent. - The response contains the HumanApprovalResponse from the reviewer. - """ - logger.info( - "Human review received for content %s: approved=%s, notes=%s", - original_request.content_id, - response.approved, - response.reviewer_notes, - ) - - # Create the final moderation result - status = "approved" if response.approved else "rejected" - result = ModerationResult( - content_id=original_request.content_id, - status=status, - ai_analysis=original_request.ai_analysis, - reviewer_notes=response.reviewer_notes, - ) - - await ctx.send_message(result, target_id="publish_executor") - - -class NotifyExecutor(Executor): - """Notifies a human reviewer with a respond link, without pausing the workflow. - - Reached by an edge from ``HumanReviewExecutor`` in the same superstep that raises the - ``request_info`` request. It builds the canonical respond URL with - :class:`WorkflowHitlContext` and (in a real app) emails it to the reviewer. Because it - runs as a durable activity *downstream* of the id-generating executor, the id it emails - is always the one the orchestrator ends up waiting on -- retries of the upstream - executor never produce a dead link. - """ - - def __init__(self): - super().__init__(id="notify_executor") - - @handler - async def notify( - self, - notification: HumanReviewNotification, - ctx: WorkflowContext, - ) -> None: - """Build the respond URL and notify the reviewer (logged here for the sample).""" - hitl = WorkflowHitlContext.from_context(ctx) - if hitl is None: - # Not running on the Azure Functions durable host (e.g. in-process DevUI): - # there is no respond endpoint to address, so skip notification. - logger.info( - "Not on the durable host; skipping reviewer notification for content %s.", - notification.content_id, - ) - return - - try: - respond_url = hitl.build_respond_url(notification.request_id) - except RuntimeError: - # No base URL configured (WEBSITE_HOSTNAME unset and no override). Notifying - # is a best-effort side effect -- the request is still reachable via the - # status endpoint -- so warn and continue rather than failing the workflow. - logger.warning( - "Cannot build a respond URL (set WEBSITE_HOSTNAME, e.g. in " - "local.settings.json). Skipping reviewer notification for content %s.", - notification.content_id, - ) - return - - # In a real application, send this URL to the reviewer (email, Teams, etc.). The - # reviewer POSTs {"approved": bool, "reviewer_notes": str} to it to resume the run. - logger.info( - "Human review needed for content %s.\n Respond at: %s\n Details: %s", - notification.content_id, - respond_url, - notification.prompt, - ) - - -class PublishExecutor(Executor): - """Handles the final publication or rejection of content.""" - - def __init__(self): - super().__init__(id="publish_executor") - - @handler - async def handle_result( - self, - result: ModerationResult, - ctx: WorkflowContext[Never, str], - ) -> None: - """Finalize the moderation and yield output.""" - if result.status == "approved": - message = ( - f"✅ Content '{result.content_id}' has been APPROVED and published.\n" - f"Reviewer notes: {result.reviewer_notes or 'None'}" - ) - else: - message = ( - f"❌ Content '{result.content_id}' has been REJECTED.\n" - f"Reviewer notes: {result.reviewer_notes or 'None'}" - ) - - logger.info(message) - await ctx.yield_output(message) - - -# ============================================================================ -# Input Router Executor -# ============================================================================ - - -def _build_client_kwargs() -> dict[str, Any]: - """Build Foundry chat client configuration from environment variables.""" - project_endpoint = os.getenv(FOUNDRY_PROJECT_ENDPOINT_ENV) - if not project_endpoint: - raise RuntimeError(f"{FOUNDRY_PROJECT_ENDPOINT_ENV} environment variable is required.") - - model = os.getenv(AZURE_OPENAI_DEPLOYMENT_ENV) - if not model: - raise RuntimeError(f"{AZURE_OPENAI_DEPLOYMENT_ENV} environment variable is required.") - - return { - "project_endpoint": project_endpoint, - "model": model, - "credential": AzureCliCredential(), - } - - -class InputRouterExecutor(Executor): - """Routes incoming content submission to the analysis agent.""" - - def __init__(self): - super().__init__(id="input_router") - - @handler - async def route_input( - self, - submission: ContentSubmission, - ctx: WorkflowContext[AgentExecutorRequest], - ) -> None: - """Create the agent request from the submitted content. - - The durable engine reconstructs this ``ContentSubmission`` from the - client's JSON payload before delivery, mirroring in-process execution. - """ - # Store submission in shared state for later retrieval - ctx.set_state("current_submission", submission) - - # Create the agent request - message = ( - f"Please analyze the following content for policy compliance:\n\n" - f"Title: {submission.title}\n" - f"Author: {submission.author}\n" - f"Content:\n{submission.body}" - ) - - await ctx.send_message( - AgentExecutorRequest( - messages=[Message(role="user", contents=[message])], - should_respond=True, - ) - ) - - -# ============================================================================ -# Workflow Creation -# ============================================================================ - - -def _create_workflow() -> Workflow: - """Create the content moderation workflow with HITL.""" - client_kwargs = _build_client_kwargs() - chat_client = FoundryChatClient(**client_kwargs) - - # Create the content analysis agent - content_analyzer_agent = Agent( - client=chat_client, - name=CONTENT_ANALYZER_AGENT_NAME, - instructions=CONTENT_ANALYZER_INSTRUCTIONS, - default_options=OpenAIChatOptions[Any](response_format=ContentAnalysisResult), - ) - - # Create executors - input_router = InputRouterExecutor() - content_analyzer_executor = ContentAnalyzerExecutor() - human_review_executor = HumanReviewExecutor() - notify_executor = NotifyExecutor() - publish_executor = PublishExecutor() - - # Build the workflow graph - # Flow: - # input_router -> content_analyzer_agent -> content_analyzer_executor - # -> human_review_executor (HITL pause here) -> publish_executor - # Side-branch: human_review_executor -> notify_executor emails the reviewer a respond - # link (built from WorkflowHitlContext) in the same superstep, before the pause. - return ( - WorkflowBuilder(name="content_moderation", start_executor=input_router) - .add_edge(input_router, content_analyzer_agent) - .add_edge(content_analyzer_agent, content_analyzer_executor) - .add_edge(content_analyzer_executor, human_review_executor) - .add_edge(human_review_executor, notify_executor) - .add_edge(human_review_executor, publish_executor) - .build() - ) - - -# ============================================================================ -# Application Entry Point -# ============================================================================ - - -def launch(durable: bool = True) -> AgentFunctionApp | None: - """Launch the function app or DevUI. - - Args: - durable: If True, returns AgentFunctionApp for Azure Functions. - If False, launches DevUI for local MAF development. - """ - if durable: - # Azure Functions mode with Durable Functions - # The app automatically provides per-workflow HITL endpoints (workflow name - # "content_moderation"): - # - POST /api/workflow/content_moderation/run - Start the workflow - # - GET /api/workflow/content_moderation/status/{instanceId} - Status + pending HITL requests - # - POST /api/workflow/content_moderation/respond/{instanceId}/{requestId} - Send HITL response - # - GET /api/health - Health check - workflow = _create_workflow() - return AgentFunctionApp(workflow=workflow, enable_health_check=True) - # Pure MAF mode with DevUI for local development - from pathlib import Path - - from agent_framework.devui import serve - from dotenv import load_dotenv - - env_path = Path(__file__).parent / ".env" - load_dotenv(dotenv_path=env_path) - - logger.info("Starting Workflow HITL Sample in MAF mode") - logger.info("Available at: http://localhost:8096") - logger.info("\nThis workflow demonstrates:") - logger.info("- Human-in-the-loop using request_info / @response_handler pattern") - logger.info("- AI content analysis with structured output") - logger.info("- Human approval workflow integration") - logger.info("\nFlow: InputRouter -> ContentAnalyzer Agent -> HumanReview -> Notify/Publish") - - workflow = _create_workflow() - serve(entities=[workflow], port=8096, auto_open=True) - - return None - - -# Default: Azure Functions mode -# Run with `python function_app.py --maf` for pure MAF mode with DevUI -app = launch(durable=True) - - -if __name__ == "__main__": - import sys - - if "--maf" in sys.argv: - # Run in pure MAF mode with DevUI - launch(durable=False) - else: - print("Usage: python function_app.py --maf") - print(" --maf Run in pure MAF mode with DevUI (http://localhost:8096)") - print("\nFor Azure Functions mode, use: func start") diff --git a/python/samples/04-hosting/azure_functions/12_workflow_hitl/host.json b/python/samples/04-hosting/azure_functions/12_workflow_hitl/host.json deleted file mode 100644 index 9e7fd873dda..00000000000 --- a/python/samples/04-hosting/azure_functions/12_workflow_hitl/host.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "version": "2.0", - "extensionBundle": { - "id": "Microsoft.Azure.Functions.ExtensionBundle", - "version": "[4.*, 5.0.0)" - }, - "extensions": { - "durableTask": { - "hubName": "%TASKHUB_NAME%" - } - } -} diff --git a/python/samples/04-hosting/azure_functions/12_workflow_hitl/local.settings.json.sample b/python/samples/04-hosting/azure_functions/12_workflow_hitl/local.settings.json.sample deleted file mode 100644 index eed2a5b6c0f..00000000000 --- a/python/samples/04-hosting/azure_functions/12_workflow_hitl/local.settings.json.sample +++ /dev/null @@ -1,11 +0,0 @@ -{ - "IsEncrypted": false, - "Values": { - "AzureWebJobsStorage": "UseDevelopmentStorage=true", - "DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None", - "TASKHUB_NAME": "default", - "FUNCTIONS_WORKER_RUNTIME": "python", - "FOUNDRY_PROJECT_ENDPOINT": "", - "FOUNDRY_MODEL": "" - } -} diff --git a/python/samples/04-hosting/azure_functions/12_workflow_hitl/requirements.txt b/python/samples/04-hosting/azure_functions/12_workflow_hitl/requirements.txt deleted file mode 100644 index b71a6092a70..00000000000 --- a/python/samples/04-hosting/azure_functions/12_workflow_hitl/requirements.txt +++ /dev/null @@ -1,12 +0,0 @@ -# Agent Framework packages -# To use the deployed version, uncomment the lines below and comment out the local installation lines -# agent-framework-foundry -# agent-framework-azurefunctions - -# Local installation (for development and testing) -# Each package must be listed explicitly because pip doesn't resolve uv workspace sources. -# Without explicit entries, pip would fetch transitive dependencies from PyPI instead of local source. --e ../../../../packages/core # Core framework - base dependency for all packages --e ../../../../packages/foundry # Foundry support - dependency for hosted chat/agent samples --e ../../../../packages/durabletask # Durable Task support - dependency of azurefunctions --e ../../../../packages/azurefunctions # Azure Functions integration - the main package for this sample diff --git a/python/samples/04-hosting/azure_functions/13_subworkflow_hitl/.gitignore b/python/samples/04-hosting/azure_functions/13_subworkflow_hitl/.gitignore deleted file mode 100644 index 7097fe01703..00000000000 --- a/python/samples/04-hosting/azure_functions/13_subworkflow_hitl/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -# Local settings - copy from local.settings.json.sample and fill in your values -local.settings.json -__pycache__/ -*.pyc -.venv/ diff --git a/python/samples/04-hosting/azure_functions/13_subworkflow_hitl/README.md b/python/samples/04-hosting/azure_functions/13_subworkflow_hitl/README.md deleted file mode 100644 index 1cbfb7770d1..00000000000 --- a/python/samples/04-hosting/azure_functions/13_subworkflow_hitl/README.md +++ /dev/null @@ -1,90 +0,0 @@ -# 13. Sub-workflow Human-in-the-Loop (HITL) - -This sample demonstrates a **nested** human-in-the-loop pause: the `request_info` -happens inside an **inner workflow** that an outer workflow embeds via -`WorkflowExecutor`. It runs on Azure Durable Functions and is the Azure Functions -counterpart of the durabletask `12_subworkflow_hitl` sample. - -This sample hosts **no AI agents**, so it needs only Azurite and the Durable Task -Scheduler emulator, with no model credentials. - -## Overview - -``` -moderation_pipeline (outer) - intake (executor) - -> review_sub = WorkflowExecutor(human_review) - review_gate (executor: request_info -> response_handler) <-- HITL pause - -> publish (executor) -``` - -1. **User starts** the outer `moderation_pipeline` workflow with content. -2. **`intake`** normalizes the submission and forwards it. -3. **`review_sub`** runs the inner `human_review` workflow as a **child - orchestration**; its `review_gate` pauses via `request_info`. -4. **The status endpoint** surfaces the nested pending request with a **qualified** - id `review_sub~0~{requestId}`. -5. **The caller responds** against the *top-level* instance with that qualified id; - the host routes it to the owning child orchestration. -6. **The inner workflow resumes**, yields its decision, and the outer `publish` - executor produces the final result. - -## Key Concept: one addressing surface for nested HITL - -On the durable host each `WorkflowExecutor` node runs its inner workflow as its own -child orchestration, so a nested `request_info` is recorded on the *child* instance. -`AgentFunctionApp` bubbles those nested requests up into the top-level status with a -**qualified request id**, so the caller only ever addresses the top-level instance: - -| Part | Meaning | -|------|---------| -| `review_sub` | the `WorkflowExecutor` node id that owns the child | -| `0` | the child's ordinal (a node may dispatch several children in one superstep) | -| `{requestId}` | the inner workflow's bare request id | - -The separator is `~` (not `:`), so it never collides with framework-generated -request ids such as functional-workflow `auto::N` ids. - -## Notifying a reviewer from inside a nested workflow - -This sample also notifies the reviewer from inside the inner workflow. The `review_gate` reads the id `request_info` generated and sends it to a downstream `NotifyExecutor`, which builds the respond URL with `WorkflowHitlContext`. - -```python -# review_gate, inside the nested human_review workflow -await ctx.request_info(request_data=HumanApprovalRequest(...), response_type=HumanApprovalResponse) -request_id = await WorkflowHitlContext.pending_request_id(ctx) -# ... send request_id to the notify executor ... - -# NotifyExecutor (also inside the inner workflow) -hitl = WorkflowHitlContext.from_context(ctx) -if hitl and request_id: - respond_url = hitl.build_respond_url(request_id) # already qualified back to the root -``` - -The notify executor runs inside the child orchestration, yet `build_respond_url` returns a URL that targets the **top-level** instance with the qualified `review_sub~0~{requestId}` id. You pass only the **bare** inner id. The host propagates the address context (the root instance, the workflow name, and the `review_sub~0~` path prefix) down into the child, so the executor qualifies the id for you and never needs to know how it is embedded. - -The same two properties from the flat sample apply here. You never generate the id, because `request_info` creates it and `pending_request_id` reads it back, so call `pending_request_id` immediately after `request_info`. This is safe because each executor runs in its own Durable Functions activity with its own runner context, so the pending set only holds this executor's own requests and the newest one is the request you just emitted. And the email lives in a downstream executor, so a retried `review_gate` (activities are at least once) never emails a dead link, since only the committed attempt's id reaches the notifier. - -## Endpoints - -`AgentFunctionApp` exposes routes only for the **top-level** workflow; the inner -workflow is driven as a child orchestration, not addressed directly. - -| Endpoint | Description | -|----------|-------------| -| `POST /api/workflow/moderation_pipeline/run` | Start the workflow | -| `GET /api/workflow/moderation_pipeline/status/{instanceId}` | Status + nested pending HITL requests (qualified ids) | -| `POST /api/workflow/moderation_pipeline/respond/{instanceId}/{requestId}` | Send the human response (use the qualified id) | -| `GET /api/health` | Health check | - -## Running - -1. Start Azurite: `azurite --silent --location .` -2. Start the Durable Task Scheduler emulator on `localhost:8080`. -3. Copy `local.settings.json.sample` to `local.settings.json`. -4. `func start` -5. Drive it with [demo.http](./demo.http): start a run, GET the status to read the - qualified `review_sub~0~{requestId}`, then POST the response to the top-level - instance with that id. - -Run `python function_app.py --maf` for pure MAF mode with DevUI (no Azure Functions). diff --git a/python/samples/04-hosting/azure_functions/13_subworkflow_hitl/demo.http b/python/samples/04-hosting/azure_functions/13_subworkflow_hitl/demo.http deleted file mode 100644 index 10feb08e818..00000000000 --- a/python/samples/04-hosting/azure_functions/13_subworkflow_hitl/demo.http +++ /dev/null @@ -1,74 +0,0 @@ -### ============================================================================ -### Sub-workflow HITL Sample - Nested Human-in-the-Loop behind one surface -### ============================================================================ -### The HITL pause lives inside an inner workflow (human_review) that the outer -### workflow (moderation_pipeline) embeds via WorkflowExecutor. The nested request -### surfaces with a qualified id (review_sub~0~{requestId}); you respond against the -### top-level instance and the host routes it to the owning child orchestration. -### -### This sample hosts no AI agents, so it needs only Azurite + the DTS emulator. -### -### Prerequisites: -### 1. Start Azurite: azurite --silent --location . -### 2. Start the Durable Task Scheduler emulator (localhost:8080) -### 3. Run: func start -### ============================================================================ - - -### ============================================================================ -### 1. Start the Workflow with Content for Moderation (will approve) -### ============================================================================ -### Starts the outer workflow. It runs intake, then the embedded human_review -### sub-workflow pauses for approval as a child orchestration. - -POST http://localhost:7071/api/workflow/moderation_pipeline/run -Content-Type: application/json - -{ - "content_id": "article-001", - "title": "Introduction to AI in Healthcare", - "body": "Artificial intelligence is improving healthcare by enabling faster diagnosis, personalized treatment plans, and better patient outcomes." -} - - -### ============================================================================ -### 2. Start Workflow with Spammy Content (will reject) -### ============================================================================ - -POST http://localhost:7071/api/workflow/moderation_pipeline/run -Content-Type: application/json - -{ - "content_id": "article-002", - "title": "Get Rich Quick", - "body": "Click here NOW to make $10,000 overnight! GUARANTEED! Limited time offer!" -} - - -### ============================================================================ -### 3. Check Workflow Status (shows the nested pending HITL request) -### ============================================================================ -### Replace INSTANCE_ID with the value returned from the run call. The -### pendingHumanInputRequests entry carries a qualified requestId of the form -### "review_sub~0~" because the pause lives in the sub-workflow. - -@instanceId = REPLACE_WITH_INSTANCE_ID - -GET http://localhost:7071/api/workflow/moderation_pipeline/status/{{instanceId}} - - -### ============================================================================ -### 4. Respond to the nested HITL request (approve) -### ============================================================================ -### Use the qualified requestId from the status response verbatim. The host -### resolves it to the owning child orchestration. - -@requestId = REPLACE_WITH_QUALIFIED_REQUEST_ID - -POST http://localhost:7071/api/workflow/moderation_pipeline/respond/{{instanceId}}/{{requestId}} -Content-Type: application/json - -{ - "approved": true, - "reviewer_notes": "Looks good." -} diff --git a/python/samples/04-hosting/azure_functions/13_subworkflow_hitl/function_app.py b/python/samples/04-hosting/azure_functions/13_subworkflow_hitl/function_app.py deleted file mode 100644 index 1b6e0f9aa48..00000000000 --- a/python/samples/04-hosting/azure_functions/13_subworkflow_hitl/function_app.py +++ /dev/null @@ -1,352 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. -"""Composed workflow whose Human-in-the-Loop pause lives in a nested sub-workflow. - -This sample combines composition with human-in-the-loop on Azure Durable -Functions: the HITL ``request_info`` happens **inside an inner workflow** that an -outer workflow embeds via ``WorkflowExecutor``. On the durable host the inner -workflow runs as its own child orchestration, so its pending request is recorded on -the *child* instance. The parent records the child instance id in its custom status, -which lets the host surface the nested request behind a single top-level addressing -surface. - -``AgentFunctionApp`` walks the composition and registers a durable orchestration for -each workflow, but exposes HTTP routes only for the **top-level** workflow: - -- ``dafx-moderation_pipeline`` - the outer workflow (HTTP routes). -- ``dafx-human_review`` - the inner workflow (run as a child orchestration), which - contains the HITL pause (no direct routes). - -Composition layout:: - - moderation_pipeline (outer) - intake (executor) - -> review_sub = WorkflowExecutor(human_review) - review_gate (executor: request_info -> response_handler) - -> publish (executor) - -The status endpoint surfaces the inner pending request with a **qualified** request -id (``review_sub~0~{requestId}``); the caller posts the response back to the -*top-level* instance and the host routes it to the owning child orchestration -automatically. - -This sample hosts **no AI agents**, so it needs only the Durable Task Scheduler and -Azurite (no model credentials). - -Prerequisites: -- Start Azurite: ``azurite --silent --location .`` -- Start a Durable Task Scheduler emulator on ``localhost:8080``. -- Run: ``func start`` -""" - -import logging -from dataclasses import dataclass - -from agent_framework import ( - Executor, - Workflow, - WorkflowBuilder, - WorkflowContext, - WorkflowExecutor, - handler, - response_handler, -) -from agent_framework_azurefunctions import AgentFunctionApp, WorkflowHitlContext -from pydantic import BaseModel -from typing_extensions import Never - -logger = logging.getLogger(__name__) - -INNER_WORKFLOW_NAME = "human_review" -OUTER_WORKFLOW_NAME = "moderation_pipeline" - - -# ============================================================================ -# Data Models -# ============================================================================ - - -@dataclass -class ContentSubmission: - """Content submitted for moderation (outer workflow input).""" - - content_id: str - title: str - body: str - - -@dataclass -class HumanApprovalRequest: - """Request surfaced to the human reviewer (carried in the orchestration status).""" - - content_id: str - title: str - body: str - prompt: str - - -class HumanApprovalResponse(BaseModel): - """Response the external client sends back via the HITL response endpoint.""" - - approved: bool - reviewer_notes: str = "" - - -@dataclass -class ModerationDecision: - """The inner workflow's output: the human's decision for a submission.""" - - content_id: str - approved: bool - reviewer_notes: str - - -@dataclass -class HumanReviewNotification: - """Payload handed to the notifier so it can build the (qualified) respond URL. - - Carries the ``request_id`` the review gate passed to ``ctx.request_info`` so the - notifier addresses the exact pending request the (nested) workflow waits on. - """ - - request_id: str - content_id: str - prompt: str - - -# ============================================================================ -# Inner workflow (contains the HITL pause) -# ============================================================================ - - -class ReviewGateExecutor(Executor): - """Inner-workflow executor that pauses for human approval via request_info.""" - - def __init__(self) -> None: - super().__init__(id="review_gate") - - @handler - async def request_review( - self, submission: ContentSubmission, ctx: WorkflowContext[HumanReviewNotification] - ) -> None: - prompt = ( - f"Please review the following content for publication:\n\n" - f"Title: {submission.title}\n" - f"Content: {submission.body}\n\n" - f"Approve or reject this content." - ) - approval_request = HumanApprovalRequest( - content_id=submission.content_id, - title=submission.title, - body=submission.body, - prompt=prompt, - ) - # Pause the (inner) workflow and wait for a human response. On the durable host - # this pauses the child orchestration running this inner workflow. request_info - # generates the request id; read it back so the downstream notifier can build - # the (qualified) respond URL -- no need to mint an id by hand. - await ctx.request_info( - request_data=approval_request, - response_type=HumanApprovalResponse, - ) - request_id = await WorkflowHitlContext.pending_request_id(ctx) - assert request_id is not None # always set immediately after request_info - - # Side-branch: hand the request id to the notifier so it can build the respond - # URL. The notifier is a separate (downstream) activity, so only this activity's - # committed id reaches it -- retries notify no one. This message is drained - # before the (inner) workflow pauses. - await ctx.send_message( - HumanReviewNotification( - request_id=request_id, - content_id=submission.content_id, - prompt=prompt, - ), - target_id="notify", - ) - - @response_handler - async def handle_approval_response( - self, - original_request: HumanApprovalRequest, - response: HumanApprovalResponse, - ctx: WorkflowContext[Never, ModerationDecision], - ) -> None: - logger.info( - "Human review received for content %s: approved=%s", - original_request.content_id, - response.approved, - ) - # Yield the decision as the inner workflow's output; the WorkflowExecutor - # forwards it to the outer workflow as a message to the next node. - await ctx.yield_output( - ModerationDecision( - content_id=original_request.content_id, - approved=response.approved, - reviewer_notes=response.reviewer_notes, - ) - ) - - -class NotifyExecutor(Executor): - """Inner-workflow notifier that builds the qualified respond URL for the reviewer. - - Runs inside the nested ``human_review`` child orchestration, so the respond URL it - builds via :class:`WorkflowHitlContext` targets the **top-level** instance with a - qualified request id (``review_sub~0~{requestId}``) -- the address context is - propagated down from the parent, so this executor needs no knowledge of how it is - embedded. - """ - - def __init__(self) -> None: - super().__init__(id="notify") - - @handler - async def notify(self, notification: HumanReviewNotification, ctx: WorkflowContext) -> None: - hitl = WorkflowHitlContext.from_context(ctx) - if hitl is None: - logger.info( - "Not on the durable host; skipping reviewer notification for content %s.", - notification.content_id, - ) - return - - try: - respond_url = hitl.build_respond_url(notification.request_id) - except RuntimeError: - # No base URL configured (WEBSITE_HOSTNAME unset and no override). Notifying - # is best-effort -- the request is still reachable via the status endpoint -- - # so warn and continue rather than failing the workflow. - logger.warning( - "Cannot build a respond URL (set WEBSITE_HOSTNAME). Skipping reviewer notification for content %s.", - notification.content_id, - ) - return - - # In a real application, email/Teams this URL to the reviewer. They POST - # {"approved": bool, "reviewer_notes": str} to it to resume the run. - logger.info( - "Human review needed for content %s.\n Respond at: %s", - notification.content_id, - respond_url, - ) - - -def create_inner_workflow() -> Workflow: - """Build the inner ``human_review`` workflow (HITL gate + reviewer notifier).""" - review_gate = ReviewGateExecutor() - notify = NotifyExecutor() - # Side-branch: review_gate -> notify builds the qualified respond URL in the same - # superstep that raises the request, before the inner workflow pauses. - return WorkflowBuilder(name=INNER_WORKFLOW_NAME, start_executor=review_gate).add_edge(review_gate, notify).build() - - -# ============================================================================ -# Outer workflow (embeds the inner workflow) -# ============================================================================ - - -class IntakeExecutor(Executor): - """Outer-workflow entry point that normalizes the submission before review.""" - - def __init__(self) -> None: - super().__init__(id="intake") - - @handler - async def intake(self, submission: ContentSubmission, ctx: WorkflowContext[ContentSubmission]) -> None: - logger.info("Intake received submission %s", submission.content_id) - await ctx.send_message(submission) - - -class PublishExecutor(Executor): - """Outer-workflow executor that consumes the inner workflow's forwarded decision.""" - - def __init__(self) -> None: - super().__init__(id="publish") - - @handler - async def handle_decision(self, decision: ModerationDecision, ctx: WorkflowContext[Never, str]) -> None: - if decision.approved: - message = ( - f"Content '{decision.content_id}' APPROVED and published. " - f"Reviewer notes: {decision.reviewer_notes or 'None'}" - ) - else: - message = f"Content '{decision.content_id}' REJECTED. Reviewer notes: {decision.reviewer_notes or 'None'}" - logger.info(message) - await ctx.yield_output(message) - - -def _create_workflow() -> Workflow: - """Build the outer ``moderation_pipeline`` workflow embedding the HITL sub-workflow.""" - inner_workflow = create_inner_workflow() - - intake = IntakeExecutor() - # WorkflowExecutor embeds the inner (HITL) workflow as a single node. On the - # durable host this node runs as a child orchestration, and the inner pause - # surfaces to the client as a qualified request id (``review_sub~0~{requestId}``). - review_sub = WorkflowExecutor(inner_workflow, id="review_sub") - publish = PublishExecutor() - - return ( - WorkflowBuilder(name=OUTER_WORKFLOW_NAME, start_executor=intake) - .add_edge(intake, review_sub) - .add_edge(review_sub, publish) - .build() - ) - - -# ============================================================================ -# Application Entry Point -# ============================================================================ - - -def launch(durable: bool = True) -> AgentFunctionApp | None: - """Launch the function app or DevUI. - - Args: - durable: If True, returns AgentFunctionApp for Azure Functions. - If False, launches DevUI for local MAF development. - """ - if durable: - # Azure Functions mode. The app automatically provides per-workflow HITL - # endpoints for the top-level workflow ("moderation_pipeline"): - # - POST /api/workflow/moderation_pipeline/run - # - GET /api/workflow/moderation_pipeline/status/{instanceId} - # (surfaces the nested request as review_sub~0~{requestId}) - # - POST /api/workflow/moderation_pipeline/respond/{instanceId}/{requestId} - # - GET /api/health - workflow = _create_workflow() - return AgentFunctionApp(workflow=workflow, enable_health_check=True) - - # Pure MAF mode with DevUI for local development. - from pathlib import Path - - from agent_framework.devui import serve - from dotenv import load_dotenv - - env_path = Path(__file__).parent / ".env" - load_dotenv(dotenv_path=env_path) - - logger.info("Starting Sub-workflow HITL Sample in MAF mode") - logger.info("Available at: http://localhost:8097") - logger.info("\nThis workflow demonstrates:") - logger.info("- Human-in-the-loop inside a nested sub-workflow (WorkflowExecutor)") - logger.info("- Qualified request ids (review_sub~0~{requestId}) behind a single surface") - logger.info("\nFlow: Intake -> WorkflowExecutor(human_review: ReviewGate HITL) -> Publish") - - workflow = _create_workflow() - serve(entities=[workflow], port=8097, auto_open=True) - - return None - - -# Default: Azure Functions mode -# Run with `python function_app.py --maf` for pure MAF mode with DevUI -app = launch(durable=True) - - -if __name__ == "__main__": - import sys - - if "--maf" in sys.argv: - launch(durable=False) diff --git a/python/samples/04-hosting/azure_functions/13_subworkflow_hitl/host.json b/python/samples/04-hosting/azure_functions/13_subworkflow_hitl/host.json deleted file mode 100644 index 9e7fd873dda..00000000000 --- a/python/samples/04-hosting/azure_functions/13_subworkflow_hitl/host.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "version": "2.0", - "extensionBundle": { - "id": "Microsoft.Azure.Functions.ExtensionBundle", - "version": "[4.*, 5.0.0)" - }, - "extensions": { - "durableTask": { - "hubName": "%TASKHUB_NAME%" - } - } -} diff --git a/python/samples/04-hosting/azure_functions/13_subworkflow_hitl/local.settings.json.sample b/python/samples/04-hosting/azure_functions/13_subworkflow_hitl/local.settings.json.sample deleted file mode 100644 index 04dd252a1ab..00000000000 --- a/python/samples/04-hosting/azure_functions/13_subworkflow_hitl/local.settings.json.sample +++ /dev/null @@ -1,9 +0,0 @@ -{ - "IsEncrypted": false, - "Values": { - "AzureWebJobsStorage": "UseDevelopmentStorage=true", - "DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None", - "TASKHUB_NAME": "default", - "FUNCTIONS_WORKER_RUNTIME": "python" - } -} diff --git a/python/samples/04-hosting/azure_functions/13_subworkflow_hitl/requirements.txt b/python/samples/04-hosting/azure_functions/13_subworkflow_hitl/requirements.txt deleted file mode 100644 index 1d98dded06f..00000000000 --- a/python/samples/04-hosting/azure_functions/13_subworkflow_hitl/requirements.txt +++ /dev/null @@ -1,11 +0,0 @@ -# Agent Framework packages -# To use the deployed version, uncomment the lines below and comment out the local installation lines -# agent-framework-azurefunctions - -# Local installation (for development and testing) -# Each package must be listed explicitly because pip doesn't resolve uv workspace sources. -# Without explicit entries, pip would fetch transitive dependencies from PyPI instead of local source. -# This sample hosts no AI agents, so it needs only the core + durabletask + azurefunctions packages. --e ../../../../packages/core # Core framework - base dependency for all packages --e ../../../../packages/durabletask # Durable Task support - dependency of azurefunctions --e ../../../../packages/azurefunctions # Azure Functions integration - the main package for this sample diff --git a/python/samples/04-hosting/azure_functions/README.md b/python/samples/04-hosting/azure_functions/README.md index c1d8527fbba..dc050d93b6b 100644 --- a/python/samples/04-hosting/azure_functions/README.md +++ b/python/samples/04-hosting/azure_functions/README.md @@ -1,103 +1,3 @@ -These are common instructions for setting up your environment for every sample in this directory. -These samples illustrate the Durable extensibility for Agent Framework running in Azure Functions. +# Azure Functions Samples Have Moved -All of these samples are set up to run in Azure Functions. Azure Functions has a local development tool called [CoreTools](https://learn.microsoft.com/azure/azure-functions/functions-run-local?tabs=windows%2Cpython%2Cv2&pivots=programming-language-python#install-the-azure-functions-core-tools) which we will set up to run these samples locally. - -## Quick Prerequisites Checklist - -Install and verify these tools before [Environment Setup](#environment-setup): - -- **[Azure Functions Core Tools](https://learn.microsoft.com/azure/azure-functions/functions-run-local?tabs=windows%2Cpython%2Cv2&pivots=programming-language-python#install-the-azure-functions-core-tools)** – run samples locally with `func start` -- **[Azurite](https://learn.microsoft.com/azure/storage/common/storage-install-azurite)** – local storage emulator; must be running before `func start` -- **[uv](https://docs.astral.sh/uv/)** – create virtual environments (recommended, especially on Windows) -- **[Azure CLI](https://learn.microsoft.com/cli/azure/install-azure-cli)** – authenticate with `az login` for `AzureCliCredential` - -**Windows (PowerShell):** - -```powershell -winget install Microsoft.Azure.FunctionsCoreTools -npm install -g azurite -irm https://astral.sh/uv/install.ps1 | iex -winget install Microsoft.AzureCLI -``` - -**macOS:** - -```bash -brew tap azure/functions -brew install azure-functions-core-tools@4 -npm install -g azurite -curl -LsSf https://astral.sh/uv/install.sh | sh -# Azure CLI: https://learn.microsoft.com/cli/azure/install-azure-cli -``` - -**Linux:** - -```bash -npm install -g azure-functions-core-tools@4 --unsafe-perm true -npm install -g azurite -curl -LsSf https://astral.sh/uv/install.sh | sh -# Azure CLI: https://learn.microsoft.com/cli/azure/install-azure-cli -``` - -**Verify:** - -```bash -func --version -azurite --version -uv --version -az account show -``` - -Start Azurite in a separate terminal before `func start`: - -```bash -azurite -``` - -## Environment Setup - -### 1. Install dependencies and create appropriate services - -- Install [Azure Functions Core Tools 4.x](https://learn.microsoft.com/azure/azure-functions/functions-run-local?tabs=windows%2Cpython%2Cv2&pivots=programming-language-python#install-the-azure-functions-core-tools) - -- Install [Azurite storage emulator](https://learn.microsoft.com/en-us/azure/storage/common/storage-install-azurite?toc=%2Fazure%2Fstorage%2Fblobs%2Ftoc.json&bc=%2Fazure%2Fstorage%2Fblobs%2Fbreadcrumb%2Ftoc.json&tabs=visual-studio%2Cblob-storage) - -- Create a [Microsoft Foundry project](https://learn.microsoft.com/azure/ai-foundry/) with an OpenAI model deployment. Note the Foundry project endpoint and deployment name, and ensure you can authenticate with `AzureCliCredential`. - -- Install a tool to execute HTTP calls, for example the [REST Client extension](https://marketplace.visualstudio.com/items?itemName=humao.rest-client) - -- [Optionally] Create an [Azure Function Python app](https://learn.microsoft.com/en-us/azure/azure-functions/functions-create-function-app-portal?tabs=core-tools&pivots=flex-consumption-plan) to later deploy your app to Azure if you so desire. - -### 2. Create and activate a virtual environment - -Using [uv](https://docs.astral.sh/uv/) (recommended): - -**Windows (PowerShell):** -```powershell -uv venv .venv -.venv\Scripts\Activate.ps1 -``` - -**Linux/macOS:** -```bash -uv venv .venv -source .venv/bin/activate -``` - -> **Note:** `python -m venv .venv` also works, but can hang indefinitely on Windows with Microsoft Store Python due to a known `ensurepip` issue. Use `uv venv .venv` to avoid this. - -### 3. Running the samples - -- [Start the Azurite emulator](https://learn.microsoft.com/en-us/azure/storage/common/storage-install-azurite?tabs=npm%2Cblob-storage#run-azurite) - -- Inside each sample: - - - Install Python dependencies – from the sample directory, run `pip install -r requirements.txt` (or the equivalent in your active virtual environment). - - - Copy `local.settings.json.template` to `local.settings.json`, then update `FOUNDRY_PROJECT_ENDPOINT` and `FOUNDRY_MODEL`. The samples use `AzureCliCredential`, so ensure you're logged in via `az login`. - - Keep `TASKHUB_NAME` set to `default` unless you plan to change the durable task hub name. - - - Run the command `func start` from the root of the sample - - - Follow each sample's README for scenario-specific steps, and use its `demo.http` file (or provided curl examples) to trigger the hosted HTTP endpoints. +The Python Azure Functions samples are now maintained in the [Durable Agent Framework extension repository](https://github.com/microsoft/agent-framework-durable-extension/tree/main/python/samples/azure_functions). diff --git a/python/samples/04-hosting/durabletask/01_single_agent/.env.example b/python/samples/04-hosting/durabletask/01_single_agent/.env.example deleted file mode 100644 index 30f5c34228f..00000000000 --- a/python/samples/04-hosting/durabletask/01_single_agent/.env.example +++ /dev/null @@ -1,5 +0,0 @@ -# Azure AI Foundry project endpoint URL, e.g. https://your-project.services.ai.azure.com/api/projects/your-project -FOUNDRY_PROJECT_ENDPOINT= - -# Model deployment name in your Foundry project -FOUNDRY_MODEL= diff --git a/python/samples/04-hosting/durabletask/01_single_agent/README.md b/python/samples/04-hosting/durabletask/01_single_agent/README.md deleted file mode 100644 index 2b8ce83c130..00000000000 --- a/python/samples/04-hosting/durabletask/01_single_agent/README.md +++ /dev/null @@ -1,73 +0,0 @@ -# Single Agent - -This sample demonstrates how to create a worker-client setup that hosts a single AI agent and provides interactive conversation via the Durable Task Scheduler. - -## Key Concepts Demonstrated - -- Using the Microsoft Agent Framework to define a simple AI agent with a name and instructions. -- Registering durable agents with the worker and interacting with them via a client. -- Conversation management (via sessions) for isolated interactions. -- Worker-client architecture for distributed agent execution. - -## 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, you can run the sample using the combined approach or separate worker and client processes: - -**Option 1: Combined (Recommended for Testing)** - -```bash -cd samples/04-hosting/durabletask/01_single_agent -python sample.py -``` - -**Option 2: Separate Processes** - -Start the worker in one terminal: - -```bash -python worker.py -``` - -In a new terminal, run the client: - -```bash -python client.py -``` - -The client will interact with the Joker agent: - -``` -Starting Durable Task Agent Client... -Using taskhub: default -Using endpoint: http://localhost:8080 - -Getting reference to Joker agent... -Created conversation session: a1b2c3d4-e5f6-7890-abcd-ef1234567890 - -User: Tell me a short joke about cloud computing. - -Joker: Why did the cloud break up with the server? -Because it found someone more "uplifting"! - -User: Now tell me one about Python programming. - -Joker: Why do Python programmers prefer dark mode? -Because light attracts bugs! -``` - -## Viewing Agent State - -You can view the state of the agent in the Durable Task Scheduler dashboard: - -1. Open your browser and navigate to `http://localhost:8082` -2. In the dashboard, you can view: - - The state of the Joker agent entity (dafx-Joker) - - Conversation history and current state - - How the durable agents extension manages conversation context - - - diff --git a/python/samples/04-hosting/durabletask/01_single_agent/client.py b/python/samples/04-hosting/durabletask/01_single_agent/client.py deleted file mode 100644 index deb270e8db9..00000000000 --- a/python/samples/04-hosting/durabletask/01_single_agent/client.py +++ /dev/null @@ -1,123 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Client application for interacting with a Durable Task hosted agent. - -This client connects to the Durable Task Scheduler and sends requests to -registered agents, demonstrating how to interact with agents from external processes. - -Prerequisites: -- The worker must be running with the agent registered -- Set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL -- Sign in with Azure CLI for AzureCliCredential authentication -- Durable Task Scheduler must be running -""" - -import asyncio -import logging -import os - -from agent_framework.azure import DurableAIAgentClient -from azure.identity import AzureCliCredential -from dotenv import load_dotenv -from durabletask.azuremanaged.client import DurableTaskSchedulerClient - -# Load environment variables from .env file -load_dotenv() - -# Configure logging -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - - -def get_client( - taskhub: str | None = None, endpoint: str | None = None, log_handler: logging.Handler | None = None -) -> DurableAIAgentClient: - """Create a configured DurableAIAgentClient. - - Args: - taskhub: Task hub name (defaults to TASKHUB env var or "default") - endpoint: Scheduler endpoint (defaults to ENDPOINT env var or "http://localhost:8080") - log_handler: Optional logging handler for client logging - - Returns: - Configured DurableAIAgentClient instance - """ - taskhub_name = taskhub or os.getenv("TASKHUB", "default") - endpoint_url = endpoint or os.getenv("ENDPOINT", "http://localhost:8080") - - logger.debug(f"Using taskhub: {taskhub_name}") - logger.debug(f"Using endpoint: {endpoint_url}") - - credential = None if endpoint_url == "http://localhost:8080" else AzureCliCredential() - - dts_client = DurableTaskSchedulerClient( - host_address=endpoint_url, - secure_channel=endpoint_url != "http://localhost:8080", - taskhub=taskhub_name, - token_credential=credential, - log_handler=log_handler, - ) - - return DurableAIAgentClient(dts_client) - - -def run_client(agent_client: DurableAIAgentClient) -> None: - """Run client interactions with the Joker agent. - - Args: - agent_client: The DurableAIAgentClient instance - """ - # Get a reference to the Joker agent - logger.debug("Getting reference to Joker agent...") - joker = agent_client.get_agent("Joker") - - # Create a new session for the conversation - session = joker.create_session() - logger.debug(f"Session ID: {session.session_id}") - logger.info("Start chatting with the Joker agent! (Type 'exit' to quit)") - - # Interactive conversation loop - while True: - # Get user input - try: - user_message = input("You: ").strip() - except (EOFError, KeyboardInterrupt): - logger.info("\nExiting...") - break - - # Check for exit command - if user_message.lower() == "exit": - logger.info("Goodbye!") - break - - # Skip empty messages - if not user_message: - continue - - # Send message to agent and get response - try: - response = joker.run(user_message, session=session) - logger.info(f"Joker: {response.text} \n") - except Exception as e: - logger.error(f"Error getting response: {e}") - - logger.info("Conversation completed.") - - -async def main() -> None: - """Main entry point for the client application.""" - logger.debug("Starting Durable Task Agent Client...") - - # Create client using helper function - agent_client = get_client() - - try: - run_client(agent_client) - except Exception as e: - logger.exception(f"Error during agent interaction: {e}") - finally: - logger.debug("Client shutting down") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/04-hosting/durabletask/01_single_agent/requirements.txt b/python/samples/04-hosting/durabletask/01_single_agent/requirements.txt deleted file mode 100644 index a4a947d9b61..00000000000 --- a/python/samples/04-hosting/durabletask/01_single_agent/requirements.txt +++ /dev/null @@ -1,11 +0,0 @@ -# Agent Framework packages -# To use the deployed version, uncomment the lines below and comment out the local installation lines -# agent-framework-foundry -# agent-framework-durabletask - -# Local installation (for development and testing) -# Each package must be listed explicitly because pip doesn't resolve uv workspace sources. -# Without explicit entries, pip would fetch transitive dependencies from PyPI instead of local source. --e ../../../../packages/core # Core framework - base dependency for all packages --e ../../../../packages/foundry # Foundry support - dependency for hosted chat/agent samples --e ../../../../packages/durabletask # Durable Task support - the main package for this sample diff --git a/python/samples/04-hosting/durabletask/01_single_agent/sample.py b/python/samples/04-hosting/durabletask/01_single_agent/sample.py deleted file mode 100644 index c2397629c1c..00000000000 --- a/python/samples/04-hosting/durabletask/01_single_agent/sample.py +++ /dev/null @@ -1,51 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Single Agent Sample - Durable Task Integration (Combined Worker + Client) -This sample demonstrates running both the worker and client in a single process. -The worker is started first to register the agent, then client operations are -performed against the running worker. -Prerequisites: -- Set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL -- Sign in with Azure CLI for AzureCliCredential authentication -- Durable Task Scheduler must be running (e.g., using Docker) -To run this sample: - python sample.py -""" - -import logging - -# Import helper functions from worker and client modules -from client import get_client, run_client # pyrefly: ignore[missing-import] -from dotenv import load_dotenv -from worker import get_worker, setup_worker # pyrefly: ignore[missing-import] - -# Configure logging (must be after imports to override their basicConfig) -logging.basicConfig(level=logging.INFO, force=True) -logger = logging.getLogger(__name__) - - -def main(): - """Main entry point - runs both worker and client in single process.""" - logger.debug("Starting Durable Task Agent Sample (Combined Worker + Client)...") - silent_handler = logging.NullHandler() - # Create and start the worker using helper function and context manager - dts_worker = get_worker(log_handler=silent_handler) - with dts_worker: - # Register agents using helper function - setup_worker(dts_worker) - # Start the worker - dts_worker.start() - logger.debug("Worker started and listening for requests...") - # Create the client using helper function - agent_client = get_client(log_handler=silent_handler) - try: - # Run client interactions using helper function - run_client(agent_client) - except Exception as e: - logger.exception(f"Error during agent interaction: {e}") - logger.debug("Sample completed. Worker shutting down...") - - -if __name__ == "__main__": - load_dotenv() - main() diff --git a/python/samples/04-hosting/durabletask/01_single_agent/worker.py b/python/samples/04-hosting/durabletask/01_single_agent/worker.py deleted file mode 100644 index b48c67db8c6..00000000000 --- a/python/samples/04-hosting/durabletask/01_single_agent/worker.py +++ /dev/null @@ -1,132 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Worker process for hosting a single Azure OpenAI-powered agent using Durable Task. - -This worker registers agents as durable entities and continuously listens for requests. -The worker should run as a background service, processing incoming agent requests. - -Prerequisites: -- Set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL -- Sign in with Azure CLI for AzureCliCredential authentication -- Start a Durable Task Scheduler (e.g., using Docker) -""" - -import asyncio -import logging -import os - -from agent_framework import Agent -from agent_framework.azure import DurableAIAgentWorker -from agent_framework.foundry import FoundryChatClient -from azure.identity import AzureCliCredential -from azure.identity.aio import AzureCliCredential as AsyncAzureCliCredential -from dotenv import load_dotenv -from durabletask.azuremanaged.worker import DurableTaskSchedulerWorker - -# Load environment variables from .env file -load_dotenv() - -# Configure logging -logging.basicConfig(level=logging.WARNING) -logger = logging.getLogger(__name__) - - -def create_joker_agent() -> Agent: - """Create the Joker agent using Azure OpenAI. - - Returns: - Agent: The configured Joker agent - """ - return Agent( - client=FoundryChatClient( - project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], - model=os.environ["FOUNDRY_MODEL"], - credential=AsyncAzureCliCredential(), - ), - name="Joker", - instructions="You are good at telling jokes.", - ) - - -def get_worker( - taskhub: str | None = None, endpoint: str | None = None, log_handler: logging.Handler | None = None -) -> DurableTaskSchedulerWorker: - """Create a configured DurableTaskSchedulerWorker. - - Args: - taskhub: Task hub name (defaults to TASKHUB env var or "default") - endpoint: Scheduler endpoint (defaults to ENDPOINT env var or "http://localhost:8080") - log_handler: Optional logging handler for worker logging - - Returns: - Configured DurableTaskSchedulerWorker instance - """ - taskhub_name = taskhub or os.getenv("TASKHUB", "default") - endpoint_url = endpoint or os.getenv("ENDPOINT", "http://localhost:8080") - - logger.debug(f"Using taskhub: {taskhub_name}") - logger.debug(f"Using endpoint: {endpoint_url}") - - credential = None if endpoint_url == "http://localhost:8080" else AzureCliCredential() - - return DurableTaskSchedulerWorker( - host_address=endpoint_url, - secure_channel=endpoint_url != "http://localhost:8080", - taskhub=taskhub_name, - token_credential=credential, - log_handler=log_handler, - ) - - -def setup_worker(worker: DurableTaskSchedulerWorker) -> DurableAIAgentWorker: - """Set up the worker with agents registered. - - Args: - worker: The DurableTaskSchedulerWorker instance - - Returns: - DurableAIAgentWorker with agents registered - """ - # Wrap it with the agent worker - agent_worker = DurableAIAgentWorker(worker) - - # Create and register the Joker agent - logger.debug("Creating and registering Joker agent...") - joker_agent = create_joker_agent() - agent_worker.add_agent(joker_agent) - - logger.debug(f"✓ Registered agent: {joker_agent.name}") - logger.debug(f" Entity name: dafx-{joker_agent.name}") - - return agent_worker - - -async def main(): - """Main entry point for the worker process.""" - logger.debug("Starting Durable Task Agent Worker...") - - # Create a worker using the helper function - worker = get_worker() - - # Setup worker with agents - setup_worker(worker) - - logger.info("Worker is ready and listening for requests...") - logger.info("Press Ctrl+C to stop.") - logger.info("") - - try: - # Start the worker (this blocks until stopped) - worker.start() - - # Keep the worker running - while True: - await asyncio.sleep(1) - except KeyboardInterrupt: - logger.debug("Worker shutdown initiated") - - logger.debug("Worker stopped") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/04-hosting/durabletask/02_multi_agent/.env.example b/python/samples/04-hosting/durabletask/02_multi_agent/.env.example deleted file mode 100644 index fbb0778107d..00000000000 --- a/python/samples/04-hosting/durabletask/02_multi_agent/.env.example +++ /dev/null @@ -1,5 +0,0 @@ -# Azure OpenAI resource endpoint URL, e.g. https://your-resource.openai.azure.com/ -AZURE_OPENAI_ENDPOINT= - -# Azure OpenAI model deployment name -AZURE_OPENAI_MODEL= diff --git a/python/samples/04-hosting/durabletask/02_multi_agent/README.md b/python/samples/04-hosting/durabletask/02_multi_agent/README.md deleted file mode 100644 index b2989579e89..00000000000 --- a/python/samples/04-hosting/durabletask/02_multi_agent/README.md +++ /dev/null @@ -1,80 +0,0 @@ -# Multi-Agent - -This sample demonstrates how to host multiple AI agents with different tools in a single worker-client setup using the Durable Task Scheduler. - -## Key Concepts Demonstrated - -- Hosting multiple agents (WeatherAgent and MathAgent) in a single worker process. -- Each agent with its own specialized tools and instructions. -- Interacting with different agents using separate conversation sessions. -- Worker-client architecture for multi-agent systems. - -## 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, you can run the sample using the combined approach or separate worker and client processes: - -**Option 1: Combined (Recommended for Testing)** - -```bash -cd samples/04-hosting/durabletask/02_multi_agent -python sample.py -``` - -**Option 2: Separate Processes** - -Start the worker in one terminal: - -```bash -python worker.py -``` - -In a new terminal, run the client: - -```bash -python client.py -``` - -The client will interact with both agents: - -``` -Starting Durable Task Multi-Agent Client... -Using taskhub: default -Using endpoint: http://localhost:8080 - -================================================================================ -Testing WeatherAgent -================================================================================ - -Created weather conversation session: -User: What is the weather in Seattle? - -🔧 [TOOL CALLED] get_weather(location=Seattle) -✓ [TOOL RESULT] {'location': 'Seattle', 'temperature': 72, 'conditions': 'Sunny', 'humidity': 45} - -WeatherAgent: The current weather in Seattle is sunny with a temperature of 72°F and 45% humidity. - -================================================================================ -Testing MathAgent -================================================================================ - -Created math conversation session: -User: Calculate a 20% tip on a $50 bill - -🔧 [TOOL CALLED] calculate_tip(bill_amount=50.0, tip_percentage=20.0) -✓ [TOOL RESULT] {'bill_amount': 50.0, 'tip_percentage': 20.0, 'tip_amount': 10.0, 'total': 60.0} - -MathAgent: For a $50 bill with a 20% tip, the tip amount is $10.00 and the total is $60.00. -``` - -## Viewing Agent State - -You can view the state of both agents in the Durable Task Scheduler dashboard: - -1. Open your browser and navigate to `http://localhost:8082` -2. In the dashboard, you can view: - - The state of both WeatherAgent and MathAgent entities (dafx-WeatherAgent, dafx-MathAgent) - - Each agent's conversation state across multiple interactions diff --git a/python/samples/04-hosting/durabletask/02_multi_agent/client.py b/python/samples/04-hosting/durabletask/02_multi_agent/client.py deleted file mode 100644 index 693e6dcfe07..00000000000 --- a/python/samples/04-hosting/durabletask/02_multi_agent/client.py +++ /dev/null @@ -1,120 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Client application for interacting with multiple hosted agents. - -This client connects to the Durable Task Scheduler and interacts with two different -agents (WeatherAgent and MathAgent), demonstrating how to work with multiple agents -each with their own specialized capabilities and tools. - -Prerequisites: -- The worker must be running with both agents registered -- Set AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_MODEL when running the worker -- Sign in with Azure CLI for AzureCliCredential authentication -- Durable Task Scheduler must be running -""" - -import asyncio -import logging -import os - -from agent_framework.azure import DurableAIAgentClient -from azure.identity import AzureCliCredential -from dotenv import load_dotenv -from durabletask.azuremanaged.client import DurableTaskSchedulerClient - -# Load environment variables from .env file -load_dotenv() - -# Configure logging -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - - -def get_client( - taskhub: str | None = None, endpoint: str | None = None, log_handler: logging.Handler | None = None -) -> DurableAIAgentClient: - """Create a configured DurableAIAgentClient. - - Args: - taskhub: Task hub name (defaults to TASKHUB env var or "default") - endpoint: Scheduler endpoint (defaults to ENDPOINT env var or "http://localhost:8080") - log_handler: Optional logging handler for client logging - - Returns: - Configured DurableAIAgentClient instance - """ - taskhub_name = taskhub or os.getenv("TASKHUB", "default") - endpoint_url = endpoint or os.getenv("ENDPOINT", "http://localhost:8080") - - logger.debug(f"Using taskhub: {taskhub_name}") - logger.debug(f"Using endpoint: {endpoint_url}") - - credential = None if endpoint_url == "http://localhost:8080" else AzureCliCredential() - - dts_client = DurableTaskSchedulerClient( - host_address=endpoint_url, - secure_channel=endpoint_url != "http://localhost:8080", - taskhub=taskhub_name, - token_credential=credential, - log_handler=log_handler, - ) - - return DurableAIAgentClient(dts_client) - - -def run_client(agent_client: DurableAIAgentClient) -> None: - """Run client interactions with both WeatherAgent and MathAgent. - - Args: - agent_client: The DurableAIAgentClient instance - """ - logger.debug("Testing WeatherAgent") - - # Get reference to WeatherAgent - weather_agent = agent_client.get_agent("WeatherAgent") - weather_session = weather_agent.create_session() - - logger.debug(f"Created weather conversation session: {weather_session.session_id}") - - # Test WeatherAgent - weather_message = "What is the weather in Seattle?" - logger.info(f"User: {weather_message}") - - weather_response = weather_agent.run(weather_message, session=weather_session) - logger.info(f"WeatherAgent: {weather_response.text} \n") - - logger.debug("Testing MathAgent") - - # Get reference to MathAgent - math_agent = agent_client.get_agent("MathAgent") - math_session = math_agent.create_session() - - logger.debug(f"Created math conversation session: {math_session.session_id}") - - # Test MathAgent - math_message = "Calculate a 20% tip on a $50 bill" - logger.info(f"User: {math_message}") - - math_response = math_agent.run(math_message, session=math_session) - logger.info(f"MathAgent: {math_response.text} \n") - - logger.debug("Both agents completed successfully!") - - -async def main() -> None: - """Main entry point for the client application.""" - logger.debug("Starting Durable Task Multi-Agent Client...") - - # Create client using helper function - agent_client = get_client() - - try: - run_client(agent_client) - except Exception as e: - logger.exception(f"Error during agent interaction: {e}") - finally: - logger.debug("Client shutting down") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/04-hosting/durabletask/02_multi_agent/requirements.txt b/python/samples/04-hosting/durabletask/02_multi_agent/requirements.txt deleted file mode 100644 index a4a947d9b61..00000000000 --- a/python/samples/04-hosting/durabletask/02_multi_agent/requirements.txt +++ /dev/null @@ -1,11 +0,0 @@ -# Agent Framework packages -# To use the deployed version, uncomment the lines below and comment out the local installation lines -# agent-framework-foundry -# agent-framework-durabletask - -# Local installation (for development and testing) -# Each package must be listed explicitly because pip doesn't resolve uv workspace sources. -# Without explicit entries, pip would fetch transitive dependencies from PyPI instead of local source. --e ../../../../packages/core # Core framework - base dependency for all packages --e ../../../../packages/foundry # Foundry support - dependency for hosted chat/agent samples --e ../../../../packages/durabletask # Durable Task support - the main package for this sample diff --git a/python/samples/04-hosting/durabletask/02_multi_agent/sample.py b/python/samples/04-hosting/durabletask/02_multi_agent/sample.py deleted file mode 100644 index 3b28eeceb71..00000000000 --- a/python/samples/04-hosting/durabletask/02_multi_agent/sample.py +++ /dev/null @@ -1,51 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Multi-Agent Sample - Durable Task Integration (Combined Worker + Client) -This sample demonstrates running both the worker and client in a single process -for multiple agents with different tools. The worker registers two agents -(WeatherAgent and MathAgent), each with their own specialized capabilities. -Prerequisites: -- Set AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_MODEL -- Sign in with Azure CLI for AzureCliCredential authentication -- Durable Task Scheduler must be running (e.g., using Docker) -To run this sample: - python sample.py -""" - -import logging - -# Import helper functions from worker and client modules -from client import get_client, run_client # pyrefly: ignore[missing-import] -from dotenv import load_dotenv -from worker import get_worker, setup_worker # pyrefly: ignore[missing-import] - -# Configure logging -logging.basicConfig(level=logging.INFO, force=True) -logger = logging.getLogger(__name__) - - -def main(): - """Main entry point - runs both worker and client in single process.""" - logger.debug("Starting Durable Task Multi-Agent Sample (Combined Worker + Client)...") - silent_handler = logging.NullHandler() - # Create and start the worker using helper function and context manager - dts_worker = get_worker(log_handler=silent_handler) - with dts_worker: - # Register agents using helper function - setup_worker(dts_worker) - # Start the worker - dts_worker.start() - logger.debug("Worker started and listening for requests...") - # Create the client using helper function - agent_client = get_client(log_handler=silent_handler) - try: - # Run client interactions using helper function - run_client(agent_client) - except Exception as e: - logger.exception(f"Error during agent interaction: {e}") - logger.debug("Sample completed. Worker shutting down...") - - -if __name__ == "__main__": - load_dotenv() - main() diff --git a/python/samples/04-hosting/durabletask/02_multi_agent/worker.py b/python/samples/04-hosting/durabletask/02_multi_agent/worker.py deleted file mode 100644 index 36bb96a555d..00000000000 --- a/python/samples/04-hosting/durabletask/02_multi_agent/worker.py +++ /dev/null @@ -1,184 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Worker process for hosting multiple Azure OpenAI agents with different tools using Durable Task. - -This worker registers two agents - a weather assistant and a math assistant - each -with their own specialized tools. This demonstrates how to host multiple agents -with different capabilities in a single worker process. - -Prerequisites: -- Set AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_MODEL -- Sign in with Azure CLI for AzureCliCredential authentication -- Start a Durable Task Scheduler (e.g., using Docker) -""" - -import asyncio -import logging -import os -from typing import Any - -from agent_framework import Agent, tool -from agent_framework.azure import DurableAIAgentWorker -from agent_framework.openai import OpenAIChatCompletionClient -from azure.identity import AzureCliCredential -from azure.identity.aio import AzureCliCredential as AsyncAzureCliCredential -from dotenv import load_dotenv -from durabletask.azuremanaged.worker import DurableTaskSchedulerWorker - -# Load environment variables from .env file -load_dotenv() - -# Configure logging -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - -# Agent names -WEATHER_AGENT_NAME = "WeatherAgent" -MATH_AGENT_NAME = "MathAgent" - - -@tool -def get_weather(location: str) -> dict[str, Any]: - """Get current weather for a location.""" - logger.info(f"🔧 [TOOL CALLED] get_weather(location={location})") - result = { - "location": location, - "temperature": 72, - "conditions": "Sunny", - "humidity": 45, - } - logger.info(f"✓ [TOOL RESULT] {result}") - return result - - -@tool -def calculate_tip(bill_amount: float, tip_percentage: float = 15.0) -> dict[str, Any]: - """Calculate tip amount and total bill.""" - logger.info(f"🔧 [TOOL CALLED] calculate_tip(bill_amount={bill_amount}, tip_percentage={tip_percentage})") - tip = bill_amount * (tip_percentage / 100) - total = bill_amount + tip - result = { - "bill_amount": bill_amount, - "tip_percentage": tip_percentage, - "tip_amount": round(tip, 2), - "total": round(total, 2), - } - logger.info(f"✓ [TOOL RESULT] {result}") - return result - - -def create_weather_agent(): - """Create the Weather agent using Azure OpenAI. - - Returns: - Agent: The configured Weather agent with weather tool - """ - return Agent( - client=OpenAIChatCompletionClient( - credential=AsyncAzureCliCredential(), - ), - name=WEATHER_AGENT_NAME, - instructions="You are a helpful weather assistant. Provide current weather information.", - tools=[get_weather], - ) - - -def create_math_agent(): - """Create the Math agent using Azure OpenAI. - - Returns: - Agent: The configured Math agent with calculation tools - """ - return Agent( - client=OpenAIChatCompletionClient( - credential=AsyncAzureCliCredential(), - ), - name=MATH_AGENT_NAME, - instructions="You are a helpful math assistant. Help users with calculations like tip calculations.", - tools=[calculate_tip], - ) - - -def get_worker( - taskhub: str | None = None, endpoint: str | None = None, log_handler: logging.Handler | None = None -) -> DurableTaskSchedulerWorker: - """Create a configured DurableTaskSchedulerWorker. - - Args: - taskhub: Task hub name (defaults to TASKHUB env var or "default") - endpoint: Scheduler endpoint (defaults to ENDPOINT env var or "http://localhost:8080") - log_handler: Optional logging handler for worker logging - - Returns: - Configured DurableTaskSchedulerWorker instance - """ - taskhub_name = taskhub or os.getenv("TASKHUB", "default") - endpoint_url = endpoint or os.getenv("ENDPOINT", "http://localhost:8080") - - logger.debug(f"Using taskhub: {taskhub_name}") - logger.debug(f"Using endpoint: {endpoint_url}") - - credential = None if endpoint_url == "http://localhost:8080" else AzureCliCredential() - - return DurableTaskSchedulerWorker( - host_address=endpoint_url, - secure_channel=endpoint_url != "http://localhost:8080", - taskhub=taskhub_name, - token_credential=credential, - log_handler=log_handler, - ) - - -def setup_worker(worker: DurableTaskSchedulerWorker) -> DurableAIAgentWorker: - """Set up the worker with multiple agents registered. - - Args: - worker: The DurableTaskSchedulerWorker instance - - Returns: - DurableAIAgentWorker with agents registered - """ - # Wrap it with the agent worker - agent_worker = DurableAIAgentWorker(worker) - - # Create and register both agents - logger.debug("Creating and registering agents...") - weather_agent = create_weather_agent() - math_agent = create_math_agent() - - agent_worker.add_agent(weather_agent) - agent_worker.add_agent(math_agent) - - logger.debug(f"✓ Registered agents: {weather_agent.name}, {math_agent.name}") - - return agent_worker - - -async def main(): - """Main entry point for the worker process.""" - logger.debug("Starting Durable Task Multi-Agent Worker...") - - # Create a worker using the helper function - worker = get_worker() - - # Setup worker with agents - setup_worker(worker) - - logger.info("Worker is ready and listening for requests...") - logger.info("Press Ctrl+C to stop. \n") - - try: - # Start the worker (this blocks until stopped) - worker.start() - - # Keep the worker running - while True: - await asyncio.sleep(1) - except KeyboardInterrupt: - logger.debug("Worker shutdown initiated") - - logger.info("Worker stopped") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/04-hosting/durabletask/03_single_agent_streaming/.env.example b/python/samples/04-hosting/durabletask/03_single_agent_streaming/.env.example deleted file mode 100644 index 407e5e60ea3..00000000000 --- a/python/samples/04-hosting/durabletask/03_single_agent_streaming/.env.example +++ /dev/null @@ -1,10 +0,0 @@ -# Azure AI Foundry project endpoint URL, e.g. https://your-project.services.ai.azure.com/api/projects/your-project -FOUNDRY_PROJECT_ENDPOINT= - -# Model deployment name in your Foundry project -FOUNDRY_MODEL= - -# Redis connection string (defaults to redis://localhost:6379 if unset) -REDIS_CONNECTION_STRING=redis://localhost:6379 - -REDIS_STREAM_TTL_MINUTES=10 # Stream key TTL in minutes; use a positive integer (default: 10). Non-positive values remove keys immediately (Redis EXPIRE). diff --git a/python/samples/04-hosting/durabletask/03_single_agent_streaming/README.md b/python/samples/04-hosting/durabletask/03_single_agent_streaming/README.md deleted file mode 100644 index 8bd4191fe89..00000000000 --- a/python/samples/04-hosting/durabletask/03_single_agent_streaming/README.md +++ /dev/null @@ -1,150 +0,0 @@ -# Single Agent with Reliable Streaming - -This sample demonstrates how to use Redis Streams with agent response callbacks to enable reliable, resumable streaming for durable agents. Streaming responses are persisted to Redis, allowing clients to disconnect and reconnect without losing messages. - -## Key Concepts Demonstrated - -- Using `AgentResponseCallbackProtocol` to capture streaming agent responses. -- Persisting streaming chunks to Redis Streams for reliable delivery. -- Non-blocking agent execution with `options={"wait_for_response": False}` (fire-and-forget mode). -- Cursor-based resumption for disconnected clients. -- Decoupling agent execution from response streaming. - -## Prerequisites - -In addition to the common setup in the parent [README.md](../README.md), this sample requires Redis: - -```bash -docker run -d --name redis -p 6379:6379 redis:latest -``` - -## 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. - -Additional environment variables for this sample: - -```bash -# Optional: Redis Configuration -REDIS_CONNECTION_STRING=redis://localhost:6379 -REDIS_STREAM_TTL_MINUTES=10 -``` - -## Running the Sample - -With the environment setup, you can run the sample using the combined approach or separate worker and client processes: - -**Option 1: Combined (Recommended for Testing)** - -```bash -cd samples/04-hosting/durabletask/03_single_agent_streaming -python sample.py -``` - -**Option 2: Separate Processes** - -Start the worker in one terminal: - -```bash -python worker.py -``` - -In a new terminal, run the client: - -```bash -python client.py -``` - -The client will send a travel planning request to the TravelPlanner agent and stream the response from Redis in real-time: - -``` -================================================================================ -TravelPlanner Agent - Redis Streaming Demo -================================================================================ - -You: Plan a 3-day trip to Tokyo with emphasis on culture and food - -TravelPlanner (streaming from Redis): --------------------------------------------------------------------------------- -# Your Amazing 3-Day Tokyo Adventure! 🗾 - -Let me create the perfect cultural and culinary journey through Tokyo... - -## Day 1: Traditional Tokyo & First Impressions -... -(continues streaming) -... - -✓ Response complete! -``` - - -## How It Works - -### Redis Streaming Callback - -The `RedisStreamCallback` class implements `AgentResponseCallbackProtocol` to capture streaming updates and persist them to Redis: - -```python -class RedisStreamCallback(AgentResponseCallbackProtocol): - async def on_streaming_response_update(self, update, context): - # Write chunk to Redis Stream - async with await get_stream_handler() as handler: - await handler.write_chunk(thread_id, update.text, sequence) - - async def on_agent_response(self, response, context): - # Write end-of-stream marker - async with await get_stream_handler() as handler: - await handler.write_completion(thread_id, sequence) -``` - -### Worker Registration - -The worker registers the agent with the Redis streaming callback: - -```python -redis_callback = RedisStreamCallback() -agent_worker = DurableAIAgentWorker(worker, callback=redis_callback) -agent_worker.add_agent(create_travel_agent()) -``` - -### Client Streaming - -The client uses fire-and-forget mode to start the agent and streams from Redis: - -```python -# Start agent run with wait_for_response=False for non-blocking execution -travel_planner.run(user_message, thread=thread, options={"wait_for_response": False}) - -# Stream response from Redis while the agent is processing -async with await get_stream_handler() as stream_handler: - async for chunk in stream_handler.read_stream(thread_id): - if chunk.text: - print(chunk.text, end="", flush=True) - elif chunk.is_done: - break -``` - -**Fire-and-Forget Mode**: Use `options={"wait_for_response": False}` to enable non-blocking execution. The `run()` method signals the agent and returns immediately, allowing the client to stream from Redis without blocking. - -### Cursor-Based Resumption - -Clients can resume streaming from any point after disconnection: - -```python -cursor = "1734649123456-0" # Entry ID from previous stream -async with await get_stream_handler() as stream_handler: - async for chunk in stream_handler.read_stream(thread_id, cursor=cursor): - # Process chunk -``` - -## Viewing Agent State - -You can view the state of the TravelPlanner agent in the Durable Task Scheduler dashboard: - -1. Open your browser and navigate to `http://localhost:8082` -2. In the dashboard, you can view: - - The state of the TravelPlanner agent entity (dafx-TravelPlanner) - - Conversation history and current state - - How the durable agents extension manages conversation context with streaming - diff --git a/python/samples/04-hosting/durabletask/03_single_agent_streaming/client.py b/python/samples/04-hosting/durabletask/03_single_agent_streaming/client.py deleted file mode 100644 index 2232ba3cc6d..00000000000 --- a/python/samples/04-hosting/durabletask/03_single_agent_streaming/client.py +++ /dev/null @@ -1,186 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Client application for interacting with the TravelPlanner agent and streaming from Redis. - -This client demonstrates: -1. Sending a travel planning request to the durable agent -2. Streaming the response from Redis in real-time -3. Handling reconnection and cursor-based resumption - -Prerequisites: -- The worker must be running with the TravelPlanner agent registered -- Set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL -- Redis must be running -- Durable Task Scheduler must be running -""" - -import asyncio -import logging -import os -from datetime import timedelta - -import redis.asyncio as aioredis -from agent_framework.azure import DurableAIAgentClient -from azure.identity import AzureCliCredential -from dotenv import load_dotenv -from durabletask.azuremanaged.client import DurableTaskSchedulerClient -from redis_stream_response_handler import RedisStreamResponseHandler # pyrefly: ignore[missing-import] - -# Load environment variables from .env file -load_dotenv() - -# Configure logging -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - -# Configuration -REDIS_CONNECTION_STRING = os.environ.get("REDIS_CONNECTION_STRING", "redis://localhost:6379") -REDIS_STREAM_TTL_MINUTES = int(os.environ.get("REDIS_STREAM_TTL_MINUTES", "10")) - - -async def get_stream_handler() -> RedisStreamResponseHandler: - """Create a new Redis stream handler for each request. - - This avoids event loop conflicts by creating a fresh Redis client - in the current event loop context. - """ - # Create a new Redis client in the current event loop - redis_client = aioredis.from_url( # type: ignore[reportUnknownMemberType] - REDIS_CONNECTION_STRING, - encoding="utf-8", - decode_responses=False, - ) - - return RedisStreamResponseHandler( - redis_client=redis_client, - stream_ttl=timedelta(minutes=REDIS_STREAM_TTL_MINUTES), - ) - - -def get_client( - taskhub: str | None = None, endpoint: str | None = None, log_handler: logging.Handler | None = None -) -> DurableAIAgentClient: - """Create a configured DurableAIAgentClient. - - Args: - taskhub: Task hub name (defaults to TASKHUB env var or "default") - endpoint: Scheduler endpoint (defaults to ENDPOINT env var or "http://localhost:8080") - log_handler: Optional log handler for client logging - - Returns: - Configured DurableAIAgentClient instance - """ - taskhub_name = taskhub or os.getenv("TASKHUB", "default") - endpoint_url = endpoint or os.getenv("ENDPOINT", "http://localhost:8080") - - logger.debug(f"Using taskhub: {taskhub_name}") - logger.debug(f"Using endpoint: {endpoint_url}") - - credential = None if endpoint_url == "http://localhost:8080" else AzureCliCredential() - - dts_client = DurableTaskSchedulerClient( - host_address=endpoint_url, - secure_channel=endpoint_url != "http://localhost:8080", - taskhub=taskhub_name, - token_credential=credential, - log_handler=log_handler, - ) - - return DurableAIAgentClient(dts_client) - - -async def stream_from_redis(thread_id: str, cursor: str | None = None) -> None: - """Stream agent responses from Redis. - - Args: - thread_id: The conversation/thread ID to stream from - cursor: Optional cursor to resume from. If None, starts from beginning. - """ - stream_key = f"agent-stream:{thread_id}" - logger.info(f"Streaming response from Redis (thread: {thread_id[:8]}...)") - logger.debug(f"To manually check Redis, run: redis-cli XLEN {stream_key}") - if cursor: - logger.info(f"Resuming from cursor: {cursor}") - - async with await get_stream_handler() as stream_handler: - logger.info("Stream handler created, starting to read...") - try: - chunk_count = 0 - async for chunk in stream_handler.read_stream(thread_id, cursor): - chunk_count += 1 - logger.debug( - f"Received chunk #{chunk_count}: error={chunk.error}, is_done={chunk.is_done}, text_len={len(chunk.text) if chunk.text else 0}" - ) - - if chunk.error: - logger.error(f"Stream error: {chunk.error}") - break - - if chunk.is_done: - print("\n✓ Response complete!", flush=True) - logger.info(f"Stream completed after {chunk_count} chunks") - break - - if chunk.text: - # Print directly to console with flush for immediate display - print(chunk.text, end="", flush=True) - - if chunk_count == 0: - logger.warning("No chunks received from Redis stream!") - logger.warning(f"Check Redis manually: redis-cli XLEN {stream_key}") - logger.warning(f"View stream contents: redis-cli XREAD STREAMS {stream_key} 0") - - except Exception as ex: - logger.error(f"Error reading from Redis: {ex}", exc_info=True) - - -def run_client(agent_client: DurableAIAgentClient) -> None: - """Run client interactions with the TravelPlanner agent. - - Args: - agent_client: The DurableAIAgentClient instance - """ - # Get a reference to the TravelPlanner agent - logger.debug("Getting reference to TravelPlanner agent...") - travel_planner = agent_client.get_agent("TravelPlanner") - - # Create a new session for the conversation - session = travel_planner.create_session() - if not session.session_id: - logger.error("Failed to create a new session with session ID!") - return - - key = session.session_id - logger.info(f"Session ID: {key}") - - # Get user input - print("\nEnter your travel planning request:") - user_message = input("> ").strip() - - if not user_message: - logger.warning("No input provided. Using default message.") - user_message = "Plan a 3-day trip to Tokyo with emphasis on culture and food" - - logger.info(f"\nYou: {user_message}\n") - logger.info("TravelPlanner (streaming from Redis):") - logger.info("-" * 80) - - # Start the agent run with wait_for_response=False for non-blocking execution - # This signals the agent to start processing without waiting for completion - # The agent will execute in the background and write chunks to Redis - travel_planner.run(user_message, session=session, options={"wait_for_response": False}) - - # Stream the response from Redis - # This demonstrates that the client can stream from Redis while - # the agent is still processing (or after it completes) - asyncio.run(stream_from_redis(str(key))) - - logger.info("\nDemo completed!") - - -if __name__ == "__main__": - # Create the client - client = get_client() - - # Run the demo - run_client(client) diff --git a/python/samples/04-hosting/durabletask/03_single_agent_streaming/redis_stream_response_handler.py b/python/samples/04-hosting/durabletask/03_single_agent_streaming/redis_stream_response_handler.py deleted file mode 100644 index eab02145e4b..00000000000 --- a/python/samples/04-hosting/durabletask/03_single_agent_streaming/redis_stream_response_handler.py +++ /dev/null @@ -1,203 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Redis-based streaming response handler for durable agents. - -This module provides reliable, resumable streaming of agent responses using Redis Streams -as a message broker. It enables clients to disconnect and reconnect without losing messages. -""" - -import asyncio -import time -from collections.abc import AsyncIterator -from dataclasses import dataclass -from datetime import timedelta - -import redis.asyncio as aioredis - - -@dataclass -class StreamChunk: - """Represents a chunk of streamed data from Redis. - - Attributes: - entry_id: The Redis stream entry ID (used as cursor for resumption). - text: The text content of the chunk, if any. - is_done: Whether this is the final chunk in the stream. - error: Error message if an error occurred, otherwise None. - """ - - entry_id: str - text: str | None = None - is_done: bool = False - error: str | None = None - - -class RedisStreamResponseHandler: - """Handles agent responses by persisting them to Redis Streams. - - This handler writes agent response updates to Redis Streams, enabling reliable, - resumable streaming delivery to clients. Clients can disconnect and reconnect - at any point using cursor-based pagination. - - Attributes: - MAX_EMPTY_READS: Maximum number of empty reads before timing out. - POLL_INTERVAL_MS: Interval in milliseconds between polling attempts. - """ - - MAX_EMPTY_READS = 300 - POLL_INTERVAL_MS = 1000 - - def __init__(self, redis_client: aioredis.Redis, stream_ttl: timedelta): - """Initialize the Redis stream response handler. - - Args: - redis_client: The async Redis client instance. - stream_ttl: Time-to-live for stream entries in Redis. - """ - self._redis = redis_client - self._stream_ttl = stream_ttl - - async def __aenter__(self): - """Enter async context manager.""" - return self - - async def __aexit__( - self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: object - ) -> None: - """Exit async context manager and close Redis connection.""" - await self._redis.aclose() - - async def write_chunk( - self, - conversation_id: str, - text: str, - sequence: int, - ) -> None: - """Write a single text chunk to the Redis Stream. - - Args: - conversation_id: The conversation ID for this agent run. - text: The text content to write. - sequence: The sequence number for ordering. - """ - stream_key = self._get_stream_key(conversation_id) - await self._redis.xadd( - stream_key, - { - "text": text, - "sequence": str(sequence), - "timestamp": str(int(time.time() * 1000)), - }, - ) - await self._redis.expire(stream_key, self._stream_ttl) - - async def write_completion( - self, - conversation_id: str, - sequence: int, - ) -> None: - """Write an end-of-stream marker to the Redis Stream. - - Args: - conversation_id: The conversation ID for this agent run. - sequence: The final sequence number. - """ - stream_key = self._get_stream_key(conversation_id) - await self._redis.xadd( - stream_key, - { - "text": "", - "sequence": str(sequence), - "timestamp": str(int(time.time() * 1000)), - "done": "true", - }, - ) - await self._redis.expire(stream_key, self._stream_ttl) - - async def read_stream( - self, - conversation_id: str, - cursor: str | None = None, - ) -> AsyncIterator[StreamChunk]: - """Read entries from a Redis Stream with cursor-based pagination. - - This method polls the Redis Stream for new entries, yielding chunks as they - become available. Clients can resume from any point using the entry_id from - a previous chunk. - - Args: - conversation_id: The conversation ID to read from. - cursor: Optional cursor to resume from. If None, starts from beginning. - - Yields: - StreamChunk instances containing text content or status markers. - """ - stream_key = self._get_stream_key(conversation_id) - start_id = cursor if cursor else "0-0" - - empty_read_count = 0 - has_seen_data = False - - while True: - try: - # Read up to 100 entries from the stream - entries = await self._redis.xread( - {stream_key: start_id}, - count=100, - block=None, - ) - - if not entries: - # No entries found - if not has_seen_data: - empty_read_count += 1 - if empty_read_count >= self.MAX_EMPTY_READS: - timeout_seconds = self.MAX_EMPTY_READS * self.POLL_INTERVAL_MS / 1000 - yield StreamChunk( - entry_id=start_id, - error=f"Stream not found or timed out after {timeout_seconds} seconds", - ) - return - - # Wait before polling again - await asyncio.sleep(self.POLL_INTERVAL_MS / 1000) - continue - - has_seen_data = True - - # Process entries from the stream - for _stream_name, stream_entries in entries: - for entry_id, entry_data in stream_entries: - start_id = entry_id.decode() if isinstance(entry_id, bytes) else entry_id - - # Decode entry data - text = entry_data.get(b"text", b"").decode() if b"text" in entry_data else None - done = entry_data.get(b"done", b"").decode() if b"done" in entry_data else None - error = entry_data.get(b"error", b"").decode() if b"error" in entry_data else None - - if error: - yield StreamChunk(entry_id=start_id, error=error) - return - - if done == "true": - yield StreamChunk(entry_id=start_id, is_done=True) - return - - if text: - yield StreamChunk(entry_id=start_id, text=text) - - except Exception as ex: - yield StreamChunk(entry_id=start_id, error=str(ex)) - return - - @staticmethod - def _get_stream_key(conversation_id: str) -> str: - """Generate the Redis key for a conversation's stream. - - Args: - conversation_id: The conversation ID. - - Returns: - The Redis stream key. - """ - return f"agent-stream:{conversation_id}" diff --git a/python/samples/04-hosting/durabletask/03_single_agent_streaming/requirements.txt b/python/samples/04-hosting/durabletask/03_single_agent_streaming/requirements.txt deleted file mode 100644 index 95b53d61ae2..00000000000 --- a/python/samples/04-hosting/durabletask/03_single_agent_streaming/requirements.txt +++ /dev/null @@ -1,15 +0,0 @@ -# Agent Framework packages -# To use the deployed version, uncomment the lines below and comment out the local installation lines -# agent-framework-foundry -# agent-framework-durabletask - -# Local installation (for development and testing) -# Each package must be listed explicitly because pip doesn't resolve uv workspace sources. -# Without explicit entries, pip would fetch transitive dependencies from PyPI instead of local source. --e ../../../../packages/core # Core framework - base dependency for all packages --e ../../../../packages/foundry # Foundry support - dependency for hosted chat/agent samples --e ../../../../packages/durabletask # Durable Task support - the main package for this sample - - -# Redis client with asyncio support (used by redis_stream_response_handler.py) -redis[asyncio] diff --git a/python/samples/04-hosting/durabletask/03_single_agent_streaming/sample.py b/python/samples/04-hosting/durabletask/03_single_agent_streaming/sample.py deleted file mode 100644 index fb596cf1da8..00000000000 --- a/python/samples/04-hosting/durabletask/03_single_agent_streaming/sample.py +++ /dev/null @@ -1,53 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Single Agent Streaming Sample - Durable Task Integration (Combined Worker + Client) -This sample demonstrates running both the worker and client in a single process -with reliable Redis-based streaming for agent responses. -The worker is started first to register the TravelPlanner agent with Redis streaming -callback, then client operations are performed against the running worker. -Prerequisites: -- Set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL -- Sign in with Azure CLI for AzureCliCredential authentication -- Durable Task Scheduler must be running (e.g., using Docker) -- Redis must be running (e.g., docker run -d --name redis -p 6379:6379 redis:latest) -To run this sample: - python sample.py -""" - -import logging - -# Import helper functions from worker and client modules -from client import get_client, run_client # pyrefly: ignore[missing-import] -from dotenv import load_dotenv -from worker import get_worker, setup_worker # pyrefly: ignore[missing-import] - -# Configure logging (must be after imports to override their basicConfig) -logging.basicConfig(level=logging.INFO, force=True) -logger = logging.getLogger(__name__) - - -def main(): - """Main entry point - runs both worker and client in single process.""" - logger.debug("Starting Durable Task Agent Sample with Redis Streaming...") - silent_handler = logging.NullHandler() - # Create and start the worker using helper function and context manager - dts_worker = get_worker(log_handler=silent_handler) - with dts_worker: - # Register agents and callbacks using helper function - setup_worker(dts_worker) - # Start the worker - dts_worker.start() - logger.debug("Worker started and listening for requests...") - # Create the client using helper function - agent_client = get_client(log_handler=silent_handler) - try: - # Run client interactions using helper function - run_client(agent_client) - except Exception as e: - logger.exception(f"Error during agent interaction: {e}") - logger.debug("Sample completed. Worker shutting down...") - - -if __name__ == "__main__": - load_dotenv() - main() diff --git a/python/samples/04-hosting/durabletask/03_single_agent_streaming/tools.py b/python/samples/04-hosting/durabletask/03_single_agent_streaming/tools.py deleted file mode 100644 index dc231752e05..00000000000 --- a/python/samples/04-hosting/durabletask/03_single_agent_streaming/tools.py +++ /dev/null @@ -1,168 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Mock travel tools for demonstration purposes. - -In a real application, these would call actual weather and events APIs. -""" - -from typing import Annotated - -from agent_framework import tool - - -@tool -def get_weather_forecast( - destination: Annotated[str, "The destination city or location"], - date: Annotated[str, 'The date for the forecast (e.g., "2025-01-15" or "next Monday")'], -) -> str: - """Get the weather forecast for a destination on a specific date. - - Use this to provide weather-aware recommendations in the itinerary. - - Args: - destination: The destination city or location. - date: The date for the forecast. - - Returns: - A weather forecast summary. - """ - # Mock weather data based on destination for realistic responses - weather_by_region = { - "Tokyo": ("Partly cloudy with a chance of light rain", 58, 45), - "Paris": ("Overcast with occasional drizzle", 52, 41), - "New York": ("Clear and cold", 42, 28), - "London": ("Foggy morning, clearing in afternoon", 48, 38), - "Sydney": ("Sunny and warm", 82, 68), - "Rome": ("Sunny with light breeze", 62, 48), - "Barcelona": ("Partly sunny", 59, 47), - "Amsterdam": ("Cloudy with light rain", 46, 38), - "Dubai": ("Sunny and hot", 85, 72), - "Singapore": ("Tropical thunderstorms in afternoon", 88, 77), - "Bangkok": ("Hot and humid, afternoon showers", 91, 78), - "Los Angeles": ("Sunny and pleasant", 72, 55), - "San Francisco": ("Morning fog, afternoon sun", 62, 52), - "Seattle": ("Rainy with breaks", 48, 40), - "Miami": ("Warm and sunny", 78, 65), - "Honolulu": ("Tropical paradise weather", 82, 72), - } - - # Find a matching destination or use a default - forecast = ("Partly cloudy", 65, 50) - for city, weather in weather_by_region.items(): - if city.lower() in destination.lower(): - forecast = weather - break - - condition, high_f, low_f = forecast - high_c = (high_f - 32) * 5 // 9 - low_c = (low_f - 32) * 5 // 9 - - recommendation = _get_weather_recommendation(condition) - - return f"""Weather forecast for {destination} on {date}: -Conditions: {condition} -High: {high_f}°F ({high_c}°C) -Low: {low_f}°F ({low_c}°C) - -Recommendation: {recommendation}""" - - -@tool -def get_local_events( - destination: Annotated[str, "The destination city or location"], - date: Annotated[str, 'The date to search for events (e.g., "2025-01-15" or "next week")'], -) -> str: - """Get local events and activities happening at a destination around a specific date. - - Use this to suggest timely activities and experiences. - - Args: - destination: The destination city or location. - date: The date to search for events. - - Returns: - A list of local events and activities. - """ - # Mock events data based on destination - events_by_city = { - "Tokyo": [ - "🎭 Kabuki Theater Performance at Kabukiza Theatre - Traditional Japanese drama", - "🌸 Winter Illuminations at Yoyogi Park - Spectacular light displays", - "🍜 Ramen Festival at Tokyo Station - Sample ramen from across Japan", - "🎮 Gaming Expo at Tokyo Big Sight - Latest video games and technology", - ], - "Paris": [ - "🎨 Impressionist Exhibition at Musée d'Orsay - Extended evening hours", - "🍷 Wine Tasting Tour in Le Marais - Local sommelier guided", - "🎵 Jazz Night at Le Caveau de la Huchette - Historic jazz club", - "🥐 French Pastry Workshop - Learn from master pâtissiers", - ], - "New York": [ - "🎭 Broadway Show: Hamilton - Limited engagement performances", - "🏀 Knicks vs Lakers at Madison Square Garden", - "🎨 Modern Art Exhibit at MoMA - New installations", - "🍕 Pizza Walking Tour of Brooklyn - Artisan pizzerias", - ], - "London": [ - "👑 Royal Collection Exhibition at Buckingham Palace", - "🎭 West End Musical: The Phantom of the Opera", - "🍺 Craft Beer Festival at Brick Lane", - "🎪 Winter Wonderland at Hyde Park - Rides and markets", - ], - "Sydney": [ - "🏄 Pro Surfing Competition at Bondi Beach", - "🎵 Opera at Sydney Opera House - La Bohème", - "🦘 Wildlife Night Safari at Taronga Zoo", - "🍽️ Harbor Dinner Cruise with fireworks", - ], - "Rome": [ - "🏛️ After-Hours Vatican Tour - Skip the crowds", - "🍝 Pasta Making Class in Trastevere", - "🎵 Classical Concert at Borghese Gallery", - "🍷 Wine Tasting in Roman Cellars", - ], - } - - # Find events for the destination or use generic events - events = [ - "🎭 Local theater performance", - "🍽️ Food and wine festival", - "🎨 Art gallery opening", - "🎵 Live music at local venues", - ] - - for city, city_events in events_by_city.items(): - if city.lower() in destination.lower(): - events = city_events - break - - event_list = "\n• ".join(events) - return f"""Local events in {destination} around {date}: - -• {event_list} - -💡 Tip: Book popular events in advance as they may sell out quickly!""" - - -def _get_weather_recommendation(condition: str) -> str: - """Get a recommendation based on weather conditions. - - Args: - condition: The weather condition description. - - Returns: - A recommendation string. - """ - condition_lower = condition.lower() - - if "rain" in condition_lower or "drizzle" in condition_lower: - return "Bring an umbrella and waterproof jacket. Consider indoor activities for backup." - if "fog" in condition_lower: - return "Morning visibility may be limited. Plan outdoor sightseeing for afternoon." - if "cold" in condition_lower: - return "Layer up with warm clothing. Hot drinks and cozy cafés recommended." - if "hot" in condition_lower or "warm" in condition_lower: - return "Stay hydrated and use sunscreen. Plan strenuous activities for cooler morning hours." - if "thunder" in condition_lower or "storm" in condition_lower: - return "Keep an eye on weather updates. Have indoor alternatives ready." - return "Pleasant conditions expected. Great day for outdoor exploration!" diff --git a/python/samples/04-hosting/durabletask/03_single_agent_streaming/worker.py b/python/samples/04-hosting/durabletask/03_single_agent_streaming/worker.py deleted file mode 100644 index b07d1318f3e..00000000000 --- a/python/samples/04-hosting/durabletask/03_single_agent_streaming/worker.py +++ /dev/null @@ -1,263 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Worker process for hosting a TravelPlanner agent with reliable Redis streaming. - -This worker registers the TravelPlanner agent with the Durable Task Scheduler -and uses RedisStreamCallback to persist streaming responses to Redis for reliable delivery. - -Prerequisites: -- Set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL -- Sign in with Azure CLI for AzureCliCredential authentication -- Start a Durable Task Scheduler (e.g., using Docker) -- Start Redis (e.g., docker run -d --name redis -p 6379:6379 redis:latest) -""" - -import asyncio -import logging -import os -from datetime import timedelta - -import redis.asyncio as aioredis -from agent_framework import Agent, AgentResponseUpdate -from agent_framework.azure import ( - AgentCallbackContext, - AgentResponseCallbackProtocol, - DurableAIAgentWorker, -) -from agent_framework.foundry import FoundryChatClient -from azure.identity import AzureCliCredential -from azure.identity.aio import AzureCliCredential as AsyncAzureCliCredential -from dotenv import load_dotenv -from durabletask.azuremanaged.worker import DurableTaskSchedulerWorker -from redis_stream_response_handler import RedisStreamResponseHandler # pyrefly: ignore[missing-import] -from tools import get_local_events, get_weather_forecast # pyrefly: ignore[missing-import] - -# Load environment variables from .env file -load_dotenv() - -# Configure logging -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - -# Configuration -REDIS_CONNECTION_STRING = os.environ.get("REDIS_CONNECTION_STRING", "redis://localhost:6379") -REDIS_STREAM_TTL_MINUTES = int(os.environ.get("REDIS_STREAM_TTL_MINUTES", "10")) - - -async def get_stream_handler() -> RedisStreamResponseHandler: - """Create a new Redis stream handler for each request. - - This avoids event loop conflicts by creating a fresh Redis client - in the current event loop context. - """ - # Create a new Redis client in the current event loop - redis_client = aioredis.from_url( # type: ignore[reportUnknownMemberType] - REDIS_CONNECTION_STRING, - encoding="utf-8", - decode_responses=False, - ) - - return RedisStreamResponseHandler( - redis_client=redis_client, - stream_ttl=timedelta(minutes=REDIS_STREAM_TTL_MINUTES), - ) - - -class RedisStreamCallback(AgentResponseCallbackProtocol): - """Callback that writes streaming updates to Redis Streams for reliable delivery. - - This enables clients to disconnect and reconnect without losing messages. - """ - - def __init__(self) -> None: - self._sequence_numbers: dict[str, int] = {} # Track sequence per thread - - async def on_streaming_response_update( - self, - update: AgentResponseUpdate, - context: AgentCallbackContext, - ) -> None: - """Write streaming update to Redis Stream. - - Args: - update: The streaming response update chunk. - context: The callback context with thread_id, agent_name, etc. - """ - thread_id = context.thread_id - if not thread_id: - logger.warning("No thread_id available for streaming update") - return - - if not update.text: - return - - text = update.text - - # Get or initialize sequence number for this thread - if thread_id not in self._sequence_numbers: - self._sequence_numbers[thread_id] = 0 - - sequence = self._sequence_numbers[thread_id] - - try: - # Use context manager to ensure Redis client is properly closed - async with await get_stream_handler() as stream_handler: - # Write chunk to Redis Stream using public API - await stream_handler.write_chunk(thread_id, text, sequence) - - self._sequence_numbers[thread_id] += 1 - - logger.debug( - "[%s][%s] Wrote chunk to Redis: seq=%d, text=%s", - context.agent_name, - thread_id[:8], - sequence, - text, - ) - except Exception as ex: - logger.error(f"Error writing to Redis stream: {ex}", exc_info=True) - - async def on_agent_response(self, response: object, context: AgentCallbackContext) -> None: - """Write end-of-stream marker when agent completes. - - Args: - response: The final agent response. - context: The callback context. - """ - thread_id = context.thread_id - if not thread_id: - return - - sequence = self._sequence_numbers.get(thread_id, 0) - - try: - # Use context manager to ensure Redis client is properly closed - async with await get_stream_handler() as stream_handler: - # Write end-of-stream marker using public API - await stream_handler.write_completion(thread_id, sequence) - - logger.info( - "[%s][%s] Agent completed, wrote end-of-stream marker", - context.agent_name, - thread_id[:8], - ) - - # Clean up sequence tracker - self._sequence_numbers.pop(thread_id, None) - except Exception as ex: - logger.error(f"Error writing end-of-stream marker: {ex}", exc_info=True) - - -def create_travel_agent() -> "Agent": - """Create the TravelPlanner agent using Azure OpenAI. - - Returns: - Agent: The configured TravelPlanner agent with travel planning tools. - """ - _client = FoundryChatClient( - project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], - model=os.environ["FOUNDRY_MODEL"], - credential=AsyncAzureCliCredential(), - ) - return Agent( - client=_client, - name="TravelPlanner", - instructions="""You are an expert travel planner who creates detailed, personalized travel itineraries. -When asked to plan a trip, you should: -1. Create a comprehensive day-by-day itinerary -2. Include specific recommendations for activities, restaurants, and attractions -3. Provide practical tips for each destination -4. Consider weather and local events when making recommendations -5. Include estimated times and logistics between activities - -Always use the available tools to get current weather forecasts and local events -for the destination to make your recommendations more relevant and timely. - -Format your response with clear headings for each day and include emoji icons -to make the itinerary easy to scan and visually appealing.""", - tools=[get_weather_forecast, get_local_events], - ) - - -def get_worker( - taskhub: str | None = None, endpoint: str | None = None, log_handler: logging.Handler | None = None -) -> DurableTaskSchedulerWorker: - """Create a configured DurableTaskSchedulerWorker. - - Args: - taskhub: Task hub name (defaults to TASKHUB env var or "default") - endpoint: Scheduler endpoint (defaults to ENDPOINT env var or "http://localhost:8080") - log_handler: Optional log handler for worker logging - - Returns: - Configured DurableTaskSchedulerWorker instance - """ - taskhub_name = taskhub or os.getenv("TASKHUB", "default") - endpoint_url = endpoint or os.getenv("ENDPOINT", "http://localhost:8080") - - logger.debug(f"Using taskhub: {taskhub_name}") - logger.debug(f"Using endpoint: {endpoint_url}") - - credential = None if endpoint_url == "http://localhost:8080" else AzureCliCredential() - - return DurableTaskSchedulerWorker( - host_address=endpoint_url, - secure_channel=endpoint_url != "http://localhost:8080", - taskhub=taskhub_name, - token_credential=credential, - log_handler=log_handler, - ) - - -def setup_worker(worker: DurableTaskSchedulerWorker) -> DurableAIAgentWorker: - """Set up the worker with the TravelPlanner agent and Redis streaming callback. - - Args: - worker: The DurableTaskSchedulerWorker instance - - Returns: - DurableAIAgentWorker with agent and callback registered - """ - # Create the Redis streaming callback - redis_callback = RedisStreamCallback() - - # Wrap it with the agent worker - agent_worker = DurableAIAgentWorker(worker, callback=redis_callback) - - # Create and register the TravelPlanner agent - logger.debug("Creating and registering TravelPlanner agent...") - travel_agent = create_travel_agent() - agent_worker.add_agent(travel_agent) - - logger.debug(f"✓ Registered agent: {travel_agent.name}") - - return agent_worker - - -async def main(): - """Main entry point for the worker process.""" - logger.debug("Starting Durable Task Agent Worker with Redis Streaming...") - - # Create a worker using the helper function - worker = get_worker() - - # Setup worker with agent and callback - setup_worker(worker) - - # Start the worker - logger.debug("Worker started and listening for requests...") - worker.start() - - try: - # Keep the worker running - while True: - await asyncio.sleep(1) - except KeyboardInterrupt: - logger.debug("Worker shutting down...") - finally: - worker.stop() - logger.debug("Worker stopped") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/04-hosting/durabletask/04_single_agent_orchestration_chaining/.env.example b/python/samples/04-hosting/durabletask/04_single_agent_orchestration_chaining/.env.example deleted file mode 100644 index 30f5c34228f..00000000000 --- a/python/samples/04-hosting/durabletask/04_single_agent_orchestration_chaining/.env.example +++ /dev/null @@ -1,5 +0,0 @@ -# Azure AI Foundry project endpoint URL, e.g. https://your-project.services.ai.azure.com/api/projects/your-project -FOUNDRY_PROJECT_ENDPOINT= - -# Model deployment name in your Foundry project -FOUNDRY_MODEL= diff --git a/python/samples/04-hosting/durabletask/04_single_agent_orchestration_chaining/README.md b/python/samples/04-hosting/durabletask/04_single_agent_orchestration_chaining/README.md deleted file mode 100644 index 4d015c28dca..00000000000 --- a/python/samples/04-hosting/durabletask/04_single_agent_orchestration_chaining/README.md +++ /dev/null @@ -1,68 +0,0 @@ -# Single Agent Orchestration Chaining - -This sample demonstrates how to chain multiple invocations of the same agent using a durable orchestration while preserving conversation state between runs. - -## Key Concepts Demonstrated - -- Using durable orchestrations to coordinate sequential agent invocations. -- Chaining agent calls where the output of one run becomes input to the next. -- Maintaining conversation context across sequential runs using a shared session. -- Using `DurableAIAgentOrchestrationContext` to access agents within 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, you can run the sample using the combined approach or separate worker and client processes: - -**Option 1: Combined (Recommended for Testing)** - -```bash -cd samples/04-hosting/durabletask/04_single_agent_orchestration_chaining -python sample.py -``` - -**Option 2: Separate Processes** - -Start the worker in one terminal: - -```bash -python worker.py -``` - -In a new terminal, run the client: - -```bash -python client.py -``` - -The orchestration will execute the writer agent twice sequentially: - -``` -[Orchestration] Starting single agent chaining... -[Orchestration] Created session: abc-123 -[Orchestration] First agent run: Generating initial sentence... -[Orchestration] Initial response: Every small step forward is progress toward mastery. -[Orchestration] Second agent run: Refining the sentence... -[Orchestration] Refined response: Each small step forward brings you closer to mastery and growth. -[Orchestration] Chaining complete - -================================================================================ -Orchestration Result -================================================================================ -Each small step forward brings you closer to mastery and growth. -``` - -## Viewing Orchestration State - -You can view the state of the orchestration in the Durable Task Scheduler dashboard: - -1. Open your browser and navigate to `http://localhost:8082` -2. In the dashboard, you can view: - - The sequential execution of both agent runs - - The conversation session shared between runs - - Input and output at each step - - Overall orchestration state and history - diff --git a/python/samples/04-hosting/durabletask/04_single_agent_orchestration_chaining/client.py b/python/samples/04-hosting/durabletask/04_single_agent_orchestration_chaining/client.py deleted file mode 100644 index 2d7c3857938..00000000000 --- a/python/samples/04-hosting/durabletask/04_single_agent_orchestration_chaining/client.py +++ /dev/null @@ -1,114 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Client application for starting a single agent chaining orchestration. - -This client connects to the Durable Task Scheduler and starts an orchestration -that runs a writer agent twice sequentially on the same thread, demonstrating -how conversation context is maintained across multiple agent invocations. - -Prerequisites: -- The worker must be running with the writer agent and orchestration registered -- Set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL -- Sign in with Azure CLI for AzureCliCredential authentication -- Durable Task Scheduler must be running -""" - -import asyncio -import json -import logging -import os - -from azure.identity import AzureCliCredential -from durabletask.azuremanaged.client import DurableTaskSchedulerClient - -# Configure logging -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - - -def get_client( - taskhub: str | None = None, endpoint: str | None = None, log_handler: logging.Handler | None = None -) -> DurableTaskSchedulerClient: - """Create a configured DurableTaskSchedulerClient. - - Args: - taskhub: Task hub name (defaults to TASKHUB env var or "default") - endpoint: Scheduler endpoint (defaults to ENDPOINT env var or "http://localhost:8080") - log_handler: Optional logging handler for client logging - - Returns: - Configured DurableTaskSchedulerClient instance - """ - taskhub_name = taskhub or os.getenv("TASKHUB", "default") - endpoint_url = endpoint or os.getenv("ENDPOINT", "http://localhost:8080") - - logger.debug(f"Using taskhub: {taskhub_name}") - logger.debug(f"Using endpoint: {endpoint_url}") - - credential = None if endpoint_url == "http://localhost:8080" else AzureCliCredential() - - return DurableTaskSchedulerClient( - host_address=endpoint_url, - secure_channel=endpoint_url != "http://localhost:8080", - taskhub=taskhub_name, - token_credential=credential, - log_handler=log_handler, - ) - - -def run_client(client: DurableTaskSchedulerClient) -> None: - """Run client to start and monitor the orchestration. - - Args: - client: The DurableTaskSchedulerClient instance - """ - logger.debug("Starting single agent chaining orchestration...") - - # Start the orchestration - instance_id = client.schedule_new_orchestration( # type: ignore - orchestrator="single_agent_chaining_orchestration", - input="", - ) - - logger.info(f"Orchestration started with instance ID: {instance_id}") - logger.debug("Waiting for orchestration to complete...") - - # Retrieve the final state - metadata = client.wait_for_orchestration_completion(instance_id=instance_id, timeout=300) - - if metadata and metadata.runtime_status.name == "COMPLETED": - result = metadata.serialized_output - - logger.debug("Orchestration completed successfully!") - - # Parse and display the result - if result: - final_text = json.loads(result) - logger.info("Final refined sentence: %s \n", final_text) - - elif metadata: - logger.error(f"Orchestration ended with status: {metadata.runtime_status.name}") - if metadata.serialized_output: - logger.error(f"Output: {metadata.serialized_output}") - else: - logger.error("Orchestration did not complete within the timeout period") - - -async def main() -> None: - """Main entry point for the client application.""" - logger.debug("Starting Durable Task Single Agent Chaining Orchestration Client...") - - # Create client using helper function - client = get_client() - - try: - run_client(client) - except Exception as e: - logger.exception(f"Error during orchestration: {e}") - finally: - logger.debug("") - logger.debug("Client shutting down") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/04-hosting/durabletask/04_single_agent_orchestration_chaining/requirements.txt b/python/samples/04-hosting/durabletask/04_single_agent_orchestration_chaining/requirements.txt deleted file mode 100644 index a4a947d9b61..00000000000 --- a/python/samples/04-hosting/durabletask/04_single_agent_orchestration_chaining/requirements.txt +++ /dev/null @@ -1,11 +0,0 @@ -# Agent Framework packages -# To use the deployed version, uncomment the lines below and comment out the local installation lines -# agent-framework-foundry -# agent-framework-durabletask - -# Local installation (for development and testing) -# Each package must be listed explicitly because pip doesn't resolve uv workspace sources. -# Without explicit entries, pip would fetch transitive dependencies from PyPI instead of local source. --e ../../../../packages/core # Core framework - base dependency for all packages --e ../../../../packages/foundry # Foundry support - dependency for hosted chat/agent samples --e ../../../../packages/durabletask # Durable Task support - the main package for this sample diff --git a/python/samples/04-hosting/durabletask/04_single_agent_orchestration_chaining/sample.py b/python/samples/04-hosting/durabletask/04_single_agent_orchestration_chaining/sample.py deleted file mode 100644 index 8397a391426..00000000000 --- a/python/samples/04-hosting/durabletask/04_single_agent_orchestration_chaining/sample.py +++ /dev/null @@ -1,62 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Single Agent Orchestration Chaining Sample - Durable Task Integration -This sample demonstrates chaining two invocations of the same agent inside a Durable Task -orchestration while preserving the conversation state between runs. The orchestration -runs the writer agent sequentially on a shared thread to refine text iteratively. -Components used: -- FoundryChatClient to construct the writer agent -- DurableTaskSchedulerWorker and DurableAIAgentWorker for agent hosting -- DurableTaskSchedulerClient and orchestration for sequential agent invocations -- Thread management to maintain conversation context across invocations -Prerequisites: -- Set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL -- Sign in with Azure CLI for AzureCliCredential authentication -- Durable Task Scheduler must be running (e.g., using Docker emulator) -To run this sample: - python sample.py -""" - -import logging - -# Import helper functions from worker and client modules -from client import get_client, run_client # pyrefly: ignore[missing-import] -from dotenv import load_dotenv -from worker import get_worker, setup_worker # pyrefly: ignore[missing-import] - -# Configure logging -logging.basicConfig(level=logging.INFO, force=True) -logger = logging.getLogger(__name__) - - -def main(): - """Main entry point - runs both worker and client in single process.""" - logger.debug("Starting Single Agent Orchestration Chaining Sample...") - silent_handler = logging.NullHandler() - # Create and start the worker using helper function and context manager - dts_worker = get_worker(log_handler=silent_handler) - with dts_worker: - # Register agents and orchestrations using helper function - setup_worker(dts_worker) - # Start the worker - dts_worker.start() - logger.debug("Worker started and listening for requests...") - # Create the client using helper function - client = get_client(log_handler=silent_handler) - logger.debug("CLIENT: Starting orchestration...") - # Run the client in the same process - try: - run_client(client) - except KeyboardInterrupt: - logger.debug("Sample interrupted by user") - except Exception as e: - logger.exception(f"Error during orchestration: {e}") - finally: - logger.debug("Worker stopping...") - logger.debug("") - logger.debug("Sample completed") - - -if __name__ == "__main__": - load_dotenv() - main() diff --git a/python/samples/04-hosting/durabletask/04_single_agent_orchestration_chaining/worker.py b/python/samples/04-hosting/durabletask/04_single_agent_orchestration_chaining/worker.py deleted file mode 100644 index 6ade7df6802..00000000000 --- a/python/samples/04-hosting/durabletask/04_single_agent_orchestration_chaining/worker.py +++ /dev/null @@ -1,215 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Worker process for hosting a single agent with chaining orchestration using Durable Task. - -This worker registers a writer agent and an orchestration function that demonstrates -chaining behavior by running the agent twice sequentially on the same thread, -preserving conversation context between invocations. - -Prerequisites: -- Set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL -- Sign in with Azure CLI for AzureCliCredential authentication -- Start a Durable Task Scheduler (e.g., using Docker) -""" - -import asyncio -import logging -import os -from collections.abc import Generator - -from agent_framework import Agent, AgentResponse -from agent_framework.azure import DurableAIAgentOrchestrationContext, DurableAIAgentWorker -from agent_framework.foundry import FoundryChatClient -from azure.identity import AzureCliCredential -from azure.identity.aio import AzureCliCredential as AsyncAzureCliCredential -from dotenv import load_dotenv -from durabletask.azuremanaged.worker import DurableTaskSchedulerWorker -from durabletask.task import OrchestrationContext, Task - -# Load environment variables from .env file -load_dotenv() - -# Configure logging -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - -# Agent name -WRITER_AGENT_NAME = "WriterAgent" - - -def create_writer_agent() -> "Agent": - """Create the Writer agent using Azure OpenAI. - - This agent refines short pieces of text, enhancing initial sentences - and polishing improved versions further. - - Returns: - Agent: The configured Writer agent - """ - instructions = ( - "You refine short pieces of text. When given an initial sentence you enhance it;\n" - "when given an improved sentence you polish it further." - ) - - _client = FoundryChatClient( - project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], - model=os.environ["FOUNDRY_MODEL"], - credential=AsyncAzureCliCredential(), - ) - return Agent( - client=_client, - name=WRITER_AGENT_NAME, - instructions=instructions, - ) - - -def get_orchestration(): - """Get the orchestration function for this sample. - - Returns: - The orchestration function to register with the worker - """ - return single_agent_chaining_orchestration - - -def single_agent_chaining_orchestration( - context: OrchestrationContext, _: str -) -> Generator[Task[AgentResponse], AgentResponse, str]: - """Orchestration that runs the writer agent twice on the same thread. - - This demonstrates chaining behavior where the output of the first agent run - becomes part of the input for the second run, all while maintaining the - conversation context through a shared thread. - - Args: - context: The orchestration context - _: Input parameter (unused) - - Yields: - Task[AgentRunResponse]: Tasks that resolve to AgentRunResponse - - Returns: - str: The final refined text from the second agent run - """ - logger.debug("[Orchestration] Starting single agent chaining...") - - # Wrap the orchestration context to access agents - agent_context = DurableAIAgentOrchestrationContext(context) - - # Get the writer agent using the agent context - writer = agent_context.get_agent(WRITER_AGENT_NAME) - - # Create a new session for the conversation - this will be shared across both runs - writer_session = writer.create_session() - - logger.debug(f"[Orchestration] Created session: {writer_session.session_id}") - - prompt = "Write a concise inspirational sentence about learning." - # First run: Generate an initial inspirational sentence - logger.info("[Orchestration] First agent run: Generating initial sentence about: %s", prompt) - initial_response = yield writer.run( - messages=prompt, - session=writer_session, - ) - logger.info(f"[Orchestration] Initial response: {initial_response.text}") - - # Second run: Refine the initial response on the same thread - improved_prompt = f"Improve this further while keeping it under 25 words: {initial_response.text}" - - logger.info("[Orchestration] Second agent run: Refining the sentence: %s", improved_prompt) - refined_response = yield writer.run( - messages=improved_prompt, - session=writer_session, - ) - - logger.info(f"[Orchestration] Refined response: {refined_response.text}") - - logger.debug("[Orchestration] Chaining complete") - return refined_response.text - - -def get_worker( - taskhub: str | None = None, endpoint: str | None = None, log_handler: logging.Handler | None = None -) -> DurableTaskSchedulerWorker: - """Create a configured DurableTaskSchedulerWorker. - - Args: - taskhub: Task hub name (defaults to TASKHUB env var or "default") - endpoint: Scheduler endpoint (defaults to ENDPOINT env var or "http://localhost:8080") - log_handler: Optional logging handler for worker logging - - Returns: - Configured DurableTaskSchedulerWorker instance - """ - taskhub_name = taskhub or os.getenv("TASKHUB", "default") - endpoint_url = endpoint or os.getenv("ENDPOINT", "http://localhost:8080") - - logger.debug(f"Using taskhub: {taskhub_name}") - logger.debug(f"Using endpoint: {endpoint_url}") - - credential = None if endpoint_url == "http://localhost:8080" else AzureCliCredential() - - return DurableTaskSchedulerWorker( - host_address=endpoint_url, - secure_channel=endpoint_url != "http://localhost:8080", - taskhub=taskhub_name, - token_credential=credential, - log_handler=log_handler, - ) - - -def setup_worker(worker: DurableTaskSchedulerWorker) -> DurableAIAgentWorker: - """Set up the worker with agents and orchestrations registered. - - Args: - worker: The DurableTaskSchedulerWorker instance - - Returns: - DurableAIAgentWorker with agents and orchestrations registered - """ - # Wrap it with the agent worker - agent_worker = DurableAIAgentWorker(worker) - - # Create and register the Writer agent - logger.debug("Creating and registering Writer agent...") - writer_agent = create_writer_agent() - agent_worker.add_agent(writer_agent) - - logger.debug(f"✓ Registered agent: {writer_agent.name}") - - # Register the orchestration function - logger.debug("Registering orchestration function...") - worker.add_orchestrator(single_agent_chaining_orchestration) # type: ignore - logger.debug(f"✓ Registered orchestration: {single_agent_chaining_orchestration.__name__}") - - return agent_worker - - -async def main(): - """Main entry point for the worker process.""" - logger.debug("Starting Durable Task Single Agent Chaining Worker with Orchestration...") - - # Create a worker using the helper function - worker = get_worker() - - # Setup worker with agents and orchestrations - setup_worker(worker) - - logger.debug("Worker is ready and listening for requests...") - logger.debug("Press Ctrl+C to stop.") - - try: - # Start the worker (this blocks until stopped) - worker.start() - - # Keep the worker running - while True: - await asyncio.sleep(1) - except KeyboardInterrupt: - logger.debug("Worker shutdown initiated") - - logger.debug("Worker stopped") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/04-hosting/durabletask/05_multi_agent_orchestration_concurrency/.env.example b/python/samples/04-hosting/durabletask/05_multi_agent_orchestration_concurrency/.env.example deleted file mode 100644 index 30f5c34228f..00000000000 --- a/python/samples/04-hosting/durabletask/05_multi_agent_orchestration_concurrency/.env.example +++ /dev/null @@ -1,5 +0,0 @@ -# Azure AI Foundry project endpoint URL, e.g. https://your-project.services.ai.azure.com/api/projects/your-project -FOUNDRY_PROJECT_ENDPOINT= - -# Model deployment name in your Foundry project -FOUNDRY_MODEL= diff --git a/python/samples/04-hosting/durabletask/05_multi_agent_orchestration_concurrency/README.md b/python/samples/04-hosting/durabletask/05_multi_agent_orchestration_concurrency/README.md deleted file mode 100644 index e94602d822a..00000000000 --- a/python/samples/04-hosting/durabletask/05_multi_agent_orchestration_concurrency/README.md +++ /dev/null @@ -1,71 +0,0 @@ -# Multi-Agent Orchestration with Concurrency - -This sample demonstrates how to host multiple agents and run them concurrently using a durable orchestration, aggregating their responses into a single result. - -## Key Concepts Demonstrated - -- Running multiple specialized agents in parallel within an orchestration. -- Using `OrchestrationAgentExecutor` to get `DurableAgentTask` objects for concurrent execution. -- Aggregating results from multiple agents using `task.when_all()`. -- Creating separate conversation sessions for independent agent contexts. - -## 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, you can run the sample using the combined approach or separate worker and client processes: - -**Option 1: Combined (Recommended for Testing)** - -```bash -cd samples/04-hosting/durabletask/05_multi_agent_orchestration_concurrency -python sample.py -``` - -**Option 2: Separate Processes** - -Start the worker in one terminal: - -```bash -python worker.py -``` - -In a new terminal, run the client: - -```bash -python client.py -``` - -The orchestration will execute both agents concurrently: - -``` -Prompt: What is temperature? - -Starting multi-agent concurrent orchestration... -Orchestration started with instance ID: abc123... -⚡ Running PhysicistAgent and ChemistAgent in parallel... -Orchestration status: COMPLETED - -Results: - -Physicist's response: - Temperature measures the average kinetic energy of particles in a system... - -Chemist's response: - Temperature reflects how molecular motion influences reaction rates... -``` - -## Viewing Orchestration State - -You can view the state of the orchestration in the Durable Task Scheduler dashboard: - -1. Open your browser and navigate to `http://localhost:8082` -2. In the dashboard, you can view: - - The concurrent execution of both agents (PhysicistAgent and ChemistAgent) - - Separate conversation sessions for each agent - - Parallel task execution and completion timing - - Aggregated results from both agents - - diff --git a/python/samples/04-hosting/durabletask/05_multi_agent_orchestration_concurrency/client.py b/python/samples/04-hosting/durabletask/05_multi_agent_orchestration_concurrency/client.py deleted file mode 100644 index f507f956d0f..00000000000 --- a/python/samples/04-hosting/durabletask/05_multi_agent_orchestration_concurrency/client.py +++ /dev/null @@ -1,114 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Client application for starting a multi-agent concurrent orchestration. - -This client connects to the Durable Task Scheduler and starts an orchestration -that runs two agents (physicist and chemist) concurrently, then retrieves and -displays the aggregated results. - -Prerequisites: -- The worker must be running with both agents and orchestration registered -- Set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL -- Sign in with Azure CLI for AzureCliCredential authentication -- Durable Task Scheduler must be running -""" - -import asyncio -import json -import logging -import os - -from azure.identity import AzureCliCredential -from durabletask.azuremanaged.client import DurableTaskSchedulerClient - -# Configure logging -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - - -def get_client( - taskhub: str | None = None, endpoint: str | None = None, log_handler: logging.Handler | None = None -) -> DurableTaskSchedulerClient: - """Create a configured DurableTaskSchedulerClient. - - Args: - taskhub: Task hub name (defaults to TASKHUB env var or "default") - endpoint: Scheduler endpoint (defaults to ENDPOINT env var or "http://localhost:8080") - log_handler: Optional logging handler for client logging - - Returns: - Configured DurableTaskSchedulerClient instance - """ - taskhub_name = taskhub or os.getenv("TASKHUB", "default") - endpoint_url = endpoint or os.getenv("ENDPOINT", "http://localhost:8080") - - logger.debug(f"Using taskhub: {taskhub_name}") - logger.debug(f"Using endpoint: {endpoint_url}") - - credential = None if endpoint_url == "http://localhost:8080" else AzureCliCredential() - - return DurableTaskSchedulerClient( - host_address=endpoint_url, - secure_channel=endpoint_url != "http://localhost:8080", - taskhub=taskhub_name, - token_credential=credential, - log_handler=log_handler, - ) - - -def run_client(client: DurableTaskSchedulerClient, prompt: str = "What is temperature?") -> None: - """Run client to start and monitor the orchestration. - - Args: - client: The DurableTaskSchedulerClient instance - prompt: The prompt to send to both agents - """ - # Start the orchestration with the prompt as input - instance_id = client.schedule_new_orchestration( # type: ignore - orchestrator="multi_agent_concurrent_orchestration", - input=prompt, - ) - - logger.info(f"Orchestration started with instance ID: {instance_id}") - logger.debug("Waiting for orchestration to complete...") - - # Retrieve the final state - metadata = client.wait_for_orchestration_completion( - instance_id=instance_id, - ) - - if metadata and metadata.runtime_status.name == "COMPLETED": - result = metadata.serialized_output - - logger.debug("Orchestration completed successfully!") - - # Parse and display the result - if result: - result_json = json.loads(result) if isinstance(result, str) else result - logger.info("Orchestration Results:\n%s", json.dumps(result_json, indent=2)) - - elif metadata: - logger.error(f"Orchestration ended with status: {metadata.runtime_status.name}") - if metadata.serialized_output: - logger.error(f"Output: {metadata.serialized_output}") - else: - logger.error("Orchestration did not complete within the timeout period") - - -async def main() -> None: - """Main entry point for the client application.""" - logger.debug("Starting Durable Task Multi-Agent Orchestration Client...") - - # Create client using helper function - client = get_client() - - try: - run_client(client) - except Exception as e: - logger.exception(f"Error during orchestration: {e}") - finally: - logger.debug("Client shutting down") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/04-hosting/durabletask/05_multi_agent_orchestration_concurrency/requirements.txt b/python/samples/04-hosting/durabletask/05_multi_agent_orchestration_concurrency/requirements.txt deleted file mode 100644 index a4a947d9b61..00000000000 --- a/python/samples/04-hosting/durabletask/05_multi_agent_orchestration_concurrency/requirements.txt +++ /dev/null @@ -1,11 +0,0 @@ -# Agent Framework packages -# To use the deployed version, uncomment the lines below and comment out the local installation lines -# agent-framework-foundry -# agent-framework-durabletask - -# Local installation (for development and testing) -# Each package must be listed explicitly because pip doesn't resolve uv workspace sources. -# Without explicit entries, pip would fetch transitive dependencies from PyPI instead of local source. --e ../../../../packages/core # Core framework - base dependency for all packages --e ../../../../packages/foundry # Foundry support - dependency for hosted chat/agent samples --e ../../../../packages/durabletask # Durable Task support - the main package for this sample diff --git a/python/samples/04-hosting/durabletask/05_multi_agent_orchestration_concurrency/sample.py b/python/samples/04-hosting/durabletask/05_multi_agent_orchestration_concurrency/sample.py deleted file mode 100644 index cfe4f2c6e61..00000000000 --- a/python/samples/04-hosting/durabletask/05_multi_agent_orchestration_concurrency/sample.py +++ /dev/null @@ -1,56 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Multi-Agent Orchestration Sample - Durable Task Integration (Combined Worker + Client) -This sample demonstrates running both the worker and client in a single process for -concurrent multi-agent orchestration. The worker registers two domain-specific agents -(physicist and chemist) and an orchestration function that runs them in parallel. -The orchestration uses OrchestrationAgentExecutor to execute agents concurrently -and aggregate their responses. -Prerequisites: -- Set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL -- Sign in with Azure CLI for AzureCliCredential authentication -- Durable Task Scheduler must be running (e.g., using Docker) -To run this sample: - python sample.py -""" - -import logging - -# Import helper functions from worker and client modules -from client import get_client, run_client # pyrefly: ignore[missing-import] -from dotenv import load_dotenv -from worker import get_worker, setup_worker # pyrefly: ignore[missing-import] - -# Configure logging -logging.basicConfig(level=logging.INFO, force=True) -logger = logging.getLogger(__name__) - - -def main(): - """Main entry point - runs both worker and client in single process.""" - logger.debug("Starting Durable Task Multi-Agent Orchestration Sample (Combined Worker + Client)...") - silent_handler = logging.NullHandler() - # Create and start the worker using helper function and context manager - dts_worker = get_worker(log_handler=silent_handler) - with dts_worker: - # Register agents and orchestrations using helper function - setup_worker(dts_worker) - # Start the worker - dts_worker.start() - logger.debug("Worker started and listening for requests...") - # Create the client using helper function - client = get_client(log_handler=silent_handler) - # Define the prompt - prompt = "What is temperature?" - logger.debug("CLIENT: Starting orchestration...") - try: - # Run the client to start the orchestration - run_client(client, prompt) - except Exception as e: - logger.exception(f"Error during sample execution: {e}") - logger.debug("Sample completed. Worker shutting down...") - - -if __name__ == "__main__": - load_dotenv() - main() diff --git a/python/samples/04-hosting/durabletask/05_multi_agent_orchestration_concurrency/worker.py b/python/samples/04-hosting/durabletask/05_multi_agent_orchestration_concurrency/worker.py deleted file mode 100644 index 22517990782..00000000000 --- a/python/samples/04-hosting/durabletask/05_multi_agent_orchestration_concurrency/worker.py +++ /dev/null @@ -1,223 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Worker process for hosting multiple agents with orchestration using Durable Task. - -This worker registers two domain-specific agents (physicist and chemist) and an orchestration -function that runs them concurrently. The orchestration uses OrchestrationAgentExecutor -to execute agents in parallel and aggregate their responses. - -Prerequisites: -- Set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL -- Sign in with Azure CLI for AzureCliCredential authentication -- Start a Durable Task Scheduler (e.g., using Docker) -""" - -import asyncio -import logging -import os -from collections.abc import Generator -from typing import Any - -from agent_framework import Agent, AgentResponse -from agent_framework.azure import DurableAIAgentOrchestrationContext, DurableAIAgentWorker -from agent_framework.foundry import FoundryChatClient -from azure.identity import AzureCliCredential -from azure.identity.aio import AzureCliCredential as AsyncAzureCliCredential -from dotenv import load_dotenv -from durabletask.azuremanaged.worker import DurableTaskSchedulerWorker -from durabletask.task import OrchestrationContext, Task, when_all - -# Load environment variables from .env file -load_dotenv() - -# Configure logging -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - -# Agent names -PHYSICIST_AGENT_NAME = "PhysicistAgent" -CHEMIST_AGENT_NAME = "ChemistAgent" - - -def create_physicist_agent() -> "Agent": - """Create the Physicist agent using Azure OpenAI. - - Returns: - Agent: The configured Physicist agent - """ - _client = FoundryChatClient( - project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], - model=os.environ["FOUNDRY_MODEL"], - credential=AsyncAzureCliCredential(), - ) - return Agent( - client=_client, - name=PHYSICIST_AGENT_NAME, - instructions="You are an expert in physics. You answer questions from a physics perspective.", - ) - - -def create_chemist_agent() -> "Agent": - """Create the Chemist agent using Azure OpenAI. - - Returns: - Agent: The configured Chemist agent - """ - _client = FoundryChatClient( - project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], - model=os.environ["FOUNDRY_MODEL"], - credential=AsyncAzureCliCredential(), - ) - return Agent( - client=_client, - name=CHEMIST_AGENT_NAME, - instructions="You are an expert in chemistry. You answer questions from a chemistry perspective.", - ) - - -def multi_agent_concurrent_orchestration( - context: OrchestrationContext, prompt: str -) -> Generator[Task[Any], Any, dict[str, str]]: - """Orchestration that runs both agents in parallel and aggregates results. - - Uses DurableAIAgentOrchestrationContext to wrap the orchestration context and - access agents via the OrchestrationAgentExecutor. - - Args: - context: The orchestration context - prompt: The prompt to send to both agents - - Returns: - dict: Dictionary with 'physicist' and 'chemist' response texts - """ - - logger.info(f"[Orchestration] Starting concurrent execution for prompt: {prompt}") - - # Wrap the orchestration context to access agents - agent_context = DurableAIAgentOrchestrationContext(context) - - # Get agents using the agent context (returns DurableAIAgent proxies) - physicist = agent_context.get_agent(PHYSICIST_AGENT_NAME) - chemist = agent_context.get_agent(CHEMIST_AGENT_NAME) - - # Create separate sessions for each agent - physicist_session = physicist.create_session() - chemist_session = chemist.create_session() - - logger.debug( - f"[Orchestration] Created sessions - Physicist: {physicist_session.session_id}, Chemist: {chemist_session.session_id}" - ) - - # Create tasks from agent.run() calls - these return DurableAgentTask instances - physicist_task = physicist.run(messages=str(prompt), session=physicist_session) - chemist_task = chemist.run(messages=str(prompt), session=chemist_session) - - logger.debug("[Orchestration] Created agent tasks, executing concurrently...") - - # Execute both tasks concurrently using when_all - # The DurableAgentTask instances wrap the underlying entity calls - task_results = yield when_all([physicist_task, chemist_task]) - - logger.debug("[Orchestration] Both agents completed") - - # Extract results from the tasks - DurableAgentTask yields AgentResponse - physicist_result: AgentResponse = task_results[0] - chemist_result: AgentResponse = task_results[1] - - result = { - "physicist": physicist_result.text, - "chemist": chemist_result.text, - } - - logger.debug("[Orchestration] Aggregated results ready") - return result - - -def get_worker( - taskhub: str | None = None, endpoint: str | None = None, log_handler: logging.Handler | None = None -) -> DurableTaskSchedulerWorker: - """Create a configured DurableTaskSchedulerWorker. - - Args: - taskhub: Task hub name (defaults to TASKHUB env var or "default") - endpoint: Scheduler endpoint (defaults to ENDPOINT env var or "http://localhost:8080") - log_handler: Optional logging handler for worker logging - - Returns: - Configured DurableTaskSchedulerWorker instance - """ - taskhub_name = taskhub or os.getenv("TASKHUB", "default") - endpoint_url = endpoint or os.getenv("ENDPOINT", "http://localhost:8080") - - logger.debug(f"Using taskhub: {taskhub_name}") - logger.debug(f"Using endpoint: {endpoint_url}") - - credential = None if endpoint_url == "http://localhost:8080" else AzureCliCredential() - - return DurableTaskSchedulerWorker( - host_address=endpoint_url, - secure_channel=endpoint_url != "http://localhost:8080", - taskhub=taskhub_name, - token_credential=credential, - log_handler=log_handler, - ) - - -def setup_worker(worker: DurableTaskSchedulerWorker) -> DurableAIAgentWorker: - """Set up the worker with agents and orchestrations registered. - - Args: - worker: The DurableTaskSchedulerWorker instance - - Returns: - DurableAIAgentWorker with agents and orchestrations registered - """ - # Wrap it with the agent worker - agent_worker = DurableAIAgentWorker(worker) - - # Create and register both agents - logger.debug("Creating and registering agents...") - physicist_agent = create_physicist_agent() - chemist_agent = create_chemist_agent() - - agent_worker.add_agent(physicist_agent) - agent_worker.add_agent(chemist_agent) - - logger.debug(f"✓ Registered agents: {physicist_agent.name}, {chemist_agent.name}") - - # Register the orchestration function - logger.debug("Registering orchestration function...") - worker.add_orchestrator(multi_agent_concurrent_orchestration) # type: ignore - logger.debug(f"✓ Registered orchestration: {multi_agent_concurrent_orchestration.__name__}") - - return agent_worker - - -async def main(): - """Main entry point for the worker process.""" - logger.debug("Starting Durable Task Multi-Agent Worker with Orchestration...") - - # Create a worker using the helper function - worker = get_worker() - - # Setup worker with agents and orchestrations - setup_worker(worker) - - logger.debug("Worker is ready and listening for requests...") - logger.debug("Press Ctrl+C to stop.") - - try: - # Start the worker (this blocks until stopped) - worker.start() - - # Keep the worker running - while True: - await asyncio.sleep(1) - except KeyboardInterrupt: - logger.debug("Worker shutdown initiated") - - logger.debug("Worker stopped") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/04-hosting/durabletask/06_multi_agent_orchestration_conditionals/.env.example b/python/samples/04-hosting/durabletask/06_multi_agent_orchestration_conditionals/.env.example deleted file mode 100644 index fbb0778107d..00000000000 --- a/python/samples/04-hosting/durabletask/06_multi_agent_orchestration_conditionals/.env.example +++ /dev/null @@ -1,5 +0,0 @@ -# Azure OpenAI resource endpoint URL, e.g. https://your-resource.openai.azure.com/ -AZURE_OPENAI_ENDPOINT= - -# Azure OpenAI model deployment name -AZURE_OPENAI_MODEL= diff --git a/python/samples/04-hosting/durabletask/06_multi_agent_orchestration_conditionals/README.md b/python/samples/04-hosting/durabletask/06_multi_agent_orchestration_conditionals/README.md deleted file mode 100644 index cc6879fc763..00000000000 --- a/python/samples/04-hosting/durabletask/06_multi_agent_orchestration_conditionals/README.md +++ /dev/null @@ -1,89 +0,0 @@ -# Multi-Agent Orchestration with Conditionals - -This sample demonstrates conditional orchestration logic with two agents that analyze incoming emails and route execution based on spam detection results. - -## Key Concepts Demonstrated - -- Multi-agent orchestration with two specialized agents (SpamDetectionAgent and EmailAssistantAgent). -- Conditional branching with different execution paths based on spam detection results. -- Structured outputs using Pydantic models with `options={"response_format": ...}` for type-safe agent responses. -- Activity functions for side effects (spam handling and email sending). -- Decision-based routing where orchestration logic branches on agent output. - -## 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. - -This sample uses Azure OpenAI credentials: - -- `AZURE_OPENAI_ENDPOINT` -- `AZURE_OPENAI_MODEL` - -## Running the Sample - -With the environment setup, you can run the sample using the combined approach or separate worker and client processes: - -**Option 1: Combined (Recommended for Testing)** - -```bash -cd samples/04-hosting/durabletask/06_multi_agent_orchestration_conditionals -python sample.py -``` - -**Option 2: Separate Processes** - -Start the worker in one terminal: - -```bash -python worker.py -``` - -In a new terminal, run the client: - -```bash -python client.py -``` - -The sample runs two test cases: - -**Test 1: Legitimate Email** -``` -Email ID: email-001 -Email Content: Hello! I wanted to reach out about our upcoming project meeting... - -🔍 SpamDetectionAgent: Analyzing email... -✓ Not spam - routing to EmailAssistantAgent - -📧 EmailAssistantAgent: Drafting response... -✓ Email sent: [Professional response drafted by EmailAssistantAgent] -``` - -**Test 2: Spam Email** -``` -Email ID: email-002 -Email Content: URGENT! You've won $1,000,000! Click here now... - -🔍 SpamDetectionAgent: Analyzing email... -⚠️ Spam detected: [Reason from SpamDetectionAgent] -✓ Email marked as spam and handled -``` - -## How It Works - -1. **Input Validation**: Orchestration validates email payload using Pydantic models. -2. **Spam Detection**: SpamDetectionAgent analyzes email content. -3. **Conditional Routing**: - - If spam: Calls `handle_spam_email` activity - - If legitimate: Runs EmailAssistantAgent and calls `send_email` activity -4. **Result**: Returns confirmation message from the appropriate activity. - -## Viewing Agent State - -You can view the state of both agents and orchestration in the Durable Task Scheduler dashboard: - -1. Open your browser and navigate to `http://localhost:8082` -2. In the dashboard, you can view: - - Orchestration instance status and history - - SpamDetectionAgent and EmailAssistantAgent entity states - - Activity execution logs - - Decision branch paths taken diff --git a/python/samples/04-hosting/durabletask/06_multi_agent_orchestration_conditionals/client.py b/python/samples/04-hosting/durabletask/06_multi_agent_orchestration_conditionals/client.py deleted file mode 100644 index da42ad6d450..00000000000 --- a/python/samples/04-hosting/durabletask/06_multi_agent_orchestration_conditionals/client.py +++ /dev/null @@ -1,142 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Client application for starting a spam detection orchestration. - -This client connects to the Durable Task Scheduler and starts an orchestration -that uses conditional logic to either handle spam emails or draft professional responses. - -Prerequisites: -- The worker must be running with both agents, orchestration, and activities registered -- Set AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_MODEL -- Sign in with Azure CLI for AzureCliCredential authentication -- Durable Task Scheduler must be running -""" - -import asyncio -import logging -import os - -from azure.identity import AzureCliCredential -from durabletask.azuremanaged.client import DurableTaskSchedulerClient - -# Configure logging -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - - -def get_client( - taskhub: str | None = None, endpoint: str | None = None, log_handler: logging.Handler | None = None -) -> DurableTaskSchedulerClient: - """Create a configured DurableTaskSchedulerClient. - - Args: - taskhub: Task hub name (defaults to TASKHUB env var or "default") - endpoint: Scheduler endpoint (defaults to ENDPOINT env var or "http://localhost:8080") - log_handler: Optional logging handler for client logging - - Returns: - Configured DurableTaskSchedulerClient instance - """ - taskhub_name = taskhub or os.getenv("TASKHUB", "default") - endpoint_url = endpoint or os.getenv("ENDPOINT", "http://localhost:8080") - - logger.debug(f"Using taskhub: {taskhub_name}") - logger.debug(f"Using endpoint: {endpoint_url}") - - credential = None if endpoint_url == "http://localhost:8080" else AzureCliCredential() - - return DurableTaskSchedulerClient( - host_address=endpoint_url, - secure_channel=endpoint_url != "http://localhost:8080", - taskhub=taskhub_name, - token_credential=credential, - log_handler=log_handler, - ) - - -def run_client( - client: DurableTaskSchedulerClient, - email_id: str = "email-001", - email_content: str = "Hello! I wanted to reach out about our upcoming project meeting.", -) -> None: - """Run client to start and monitor the spam detection orchestration. - - Args: - client: The DurableTaskSchedulerClient instance - email_id: The email ID - email_content: The email content to analyze - """ - payload = { - "email_id": email_id, - "email_content": email_content, - } - - logger.debug("Starting spam detection orchestration...") - - # Start the orchestration with the email payload - instance_id = client.schedule_new_orchestration( # type: ignore - orchestrator="spam_detection_orchestration", - input=payload, - ) - - logger.debug(f"Orchestration started with instance ID: {instance_id}") - logger.debug("Waiting for orchestration to complete...") - - # Retrieve the final state - metadata = client.wait_for_orchestration_completion(instance_id=instance_id, timeout=300) - - if metadata and metadata.runtime_status.name == "COMPLETED": - result = metadata.serialized_output - - logger.debug("Orchestration completed successfully!") - - # Parse and display the result - if result: - # Remove quotes if present - if result.startswith('"') and result.endswith('"'): - result = result[1:-1] - logger.info(f"Result: {result}") - - elif metadata: - logger.error(f"Orchestration ended with status: {metadata.runtime_status.name}") - if metadata.serialized_output: - logger.error(f"Output: {metadata.serialized_output}") - else: - logger.error("Orchestration did not complete within the timeout period") - - -async def main() -> None: - """Main entry point for the client application.""" - logger.debug("Starting Durable Task Spam Detection Orchestration Client...") - - # Create client using helper function - client = get_client() - - try: - # Test with a legitimate email - logger.info("TEST 1: Legitimate Email") - - run_client( - client, - email_id="email-001", - email_content="Hello! I wanted to reach out about our upcoming project meeting scheduled for next week.", - ) - - # Test with a spam email - logger.info("TEST 2: Spam Email") - - run_client( - client, - 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!", - ) - - except Exception as e: - logger.exception(f"Error during orchestration: {e}") - finally: - logger.debug("") - logger.debug("Client shutting down") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/04-hosting/durabletask/06_multi_agent_orchestration_conditionals/requirements.txt b/python/samples/04-hosting/durabletask/06_multi_agent_orchestration_conditionals/requirements.txt deleted file mode 100644 index 4319e9bd74d..00000000000 --- a/python/samples/04-hosting/durabletask/06_multi_agent_orchestration_conditionals/requirements.txt +++ /dev/null @@ -1,14 +0,0 @@ -# Agent Framework packages -# To use the deployed version, uncomment the lines below and comment out the local installation lines -# agent-framework-openai -# agent-framework-durabletask - -# Local installation (for development and testing) -# Each package must be listed explicitly because pip doesn't resolve uv workspace sources. -# Without explicit entries, pip would fetch transitive dependencies from PyPI instead of local source. --e ../../../../packages/core # Core framework - base dependency for all packages --e ../../../../packages/openai # OpenAI support - dependency for Azure OpenAI chat samples --e ../../../../packages/durabletask # Durable Task support - the main package for this sample - -# Azure authentication -azure-identity diff --git a/python/samples/04-hosting/durabletask/06_multi_agent_orchestration_conditionals/sample.py b/python/samples/04-hosting/durabletask/06_multi_agent_orchestration_conditionals/sample.py deleted file mode 100644 index 9d3933463e5..00000000000 --- a/python/samples/04-hosting/durabletask/06_multi_agent_orchestration_conditionals/sample.py +++ /dev/null @@ -1,78 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Multi-Agent Orchestration with Conditionals Sample - Durable Task Integration - -This sample demonstrates conditional orchestration logic with two agents: -- SpamDetectionAgent: Analyzes emails for spam content -- EmailAssistantAgent: Drafts professional responses to legitimate emails - -The orchestration branches based on spam detection results, calling different -activity functions to handle spam or send legitimate email responses. - -Prerequisites: -- Set AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_MODEL -- Sign in with Azure CLI for AzureCliCredential authentication -- Durable Task Scheduler must be running (e.g., using Docker) - -To run this sample: - python sample.py -""" - -import logging - -# Import helper functions from worker and client modules -from client import get_client, run_client # pyrefly: ignore[missing-import] -from dotenv import load_dotenv -from worker import get_worker, setup_worker # pyrefly: ignore[missing-import] - -logging.basicConfig(level=logging.INFO, force=True) -logger = logging.getLogger() - - -def main(): - """Main entry point - runs both worker and client in single process.""" - logger.debug("Starting Durable Task Spam Detection Orchestration Sample (Combined Worker + Client)...") - - silent_handler = logging.NullHandler() - # Create and start the worker using helper function and context manager - dts_worker = get_worker(log_handler=silent_handler) - with dts_worker: - # Register agents, orchestrations, and activities using helper function - setup_worker(dts_worker) - - # Start the worker - dts_worker.start() - logger.debug("Worker started and listening for requests...") - - # Create the client using helper function - client = get_client(log_handler=silent_handler) - logger.debug("CLIENT: Starting orchestration tests...") - - try: - # Test 1: Legitimate email - # logger.info("TEST 1: Legitimate Email") - - run_client( - client, - email_id="email-001", - email_content="Hello! I wanted to reach out about our upcoming project meeting scheduled for next week.", - ) - - # Test 2: Spam email - logger.info("TEST 2: Spam Email") - - run_client( - client, - 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!", - ) - - except Exception as e: - logger.exception(f"Error during sample execution: {e}") - - logger.debug("Sample completed. Worker shutting down...") - - -if __name__ == "__main__": - load_dotenv() - main() diff --git a/python/samples/04-hosting/durabletask/06_multi_agent_orchestration_conditionals/worker.py b/python/samples/04-hosting/durabletask/06_multi_agent_orchestration_conditionals/worker.py deleted file mode 100644 index 2b1af9d4415..00000000000 --- a/python/samples/04-hosting/durabletask/06_multi_agent_orchestration_conditionals/worker.py +++ /dev/null @@ -1,313 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Worker process for hosting spam detection and email assistant agents with conditional orchestration. - -This worker registers two domain-specific agents (spam detector and email assistant) and an -orchestration function that routes execution based on spam detection results. Activity functions -handle side effects (spam handling and email sending). - -Prerequisites: -- Set AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_MODEL -- Sign in with Azure CLI for AzureCliCredential authentication -- Start a Durable Task Scheduler (e.g., using Docker) -""" - -import asyncio -import logging -import os -from collections.abc import Generator -from typing import Any, cast - -from agent_framework import Agent, AgentResponse -from agent_framework.azure import DurableAIAgentOrchestrationContext, DurableAIAgentWorker -from agent_framework.openai import OpenAIChatCompletionClient -from azure.identity import AzureCliCredential -from azure.identity.aio import AzureCliCredential as AsyncAzureCliCredential -from azure.identity.aio import get_bearer_token_provider as get_async_bearer_token_provider -from dotenv import load_dotenv -from durabletask.azuremanaged.worker import DurableTaskSchedulerWorker -from durabletask.task import ActivityContext, OrchestrationContext, Task -from pydantic import BaseModel, ValidationError - -# Load environment variables from .env file -load_dotenv() - -# Configure logging -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - -# Agent names -SPAM_AGENT_NAME = "SpamDetectionAgent" -EMAIL_AGENT_NAME = "EmailAssistantAgent" - - -class SpamDetectionResult(BaseModel): - """Result from spam detection agent.""" - - is_spam: bool - reason: str - - -class EmailResponse(BaseModel): - """Result from email assistant agent.""" - - response: str - - -class EmailPayload(BaseModel): - """Input payload for the orchestration.""" - - email_id: str - email_content: str - - -def create_spam_agent() -> "Agent": - """Create the Spam Detection agent using Azure OpenAI. - - Returns: - Agent: The configured Spam Detection agent - """ - return Agent( - client=OpenAIChatCompletionClient( - model=os.environ["AZURE_OPENAI_MODEL"], - credential=get_async_bearer_token_provider( - AsyncAzureCliCredential(), "https://cognitiveservices.azure.com/.default" - ), - ), - name=SPAM_AGENT_NAME, - instructions="You are a spam detection assistant that identifies spam emails.", - ) - - -def create_email_agent() -> "Agent": - """Create the Email Assistant agent using Azure OpenAI. - - Returns: - Agent: The configured Email Assistant agent - """ - return Agent( - client=OpenAIChatCompletionClient( - model=os.environ["AZURE_OPENAI_MODEL"], - credential=get_async_bearer_token_provider( - AsyncAzureCliCredential(), "https://cognitiveservices.azure.com/.default" - ), - ), - name=EMAIL_AGENT_NAME, - instructions="You are an email assistant that helps users draft responses to emails with professionalism.", - ) - - -def handle_spam_email(context: ActivityContext, reason: str) -> str: - """Activity function to handle spam emails. - - Args: - context: The activity context - reason: The reason why the email was marked as spam - - Returns: - str: Confirmation message - """ - logger.debug(f"[Activity] Handling spam email: {reason}") - return f"Email marked as spam: {reason}" - - -def send_email(context: ActivityContext, message: str) -> str: - """Activity function to send emails. - - Args: - context: The activity context - message: The email message to send - - Returns: - str: Confirmation message - """ - logger.debug(f"[Activity] Sending email: {message[:50]}...") - return f"Email sent: {message}" - - -def spam_detection_orchestration(context: OrchestrationContext, payload_raw: Any) -> Generator[Task[Any], Any, str]: - """Orchestration that detects spam and conditionally drafts email responses. - - This orchestration: - 1. Validates the input payload - 2. Runs the spam detection agent - 3. If spam: calls handle_spam_email activity - 4. If legitimate: runs email assistant agent and calls send_email activity - - Args: - context: The orchestration context - payload_raw: The input payload dictionary - - Returns: - str: Result message from activity functions - """ - logger.debug("[Orchestration] Starting spam detection orchestration") - - # Validate input - if not isinstance(payload_raw, dict): - raise ValueError("Email data is required") - - try: - payload = EmailPayload.model_validate(payload_raw) - except ValidationError as exc: - raise ValueError(f"Invalid email payload: {exc}") from exc - - logger.debug(f"[Orchestration] Processing email ID: {payload.email_id}") - - # Wrap the orchestration context to access agents - agent_context = DurableAIAgentOrchestrationContext(context) - - # Get spam detection agent - spam_agent = agent_context.get_agent(SPAM_AGENT_NAME) - - # Run spam detection - spam_prompt = ( - "Analyze this email for spam content and return a JSON response with 'is_spam' (boolean) " - "and 'reason' (string) fields:\n" - f"Email ID: {payload.email_id}\n" - f"Content: {payload.email_content}" - ) - - logger.info("[Orchestration] Running spam detection agent: %s", spam_prompt) - spam_result_task = spam_agent.run( - messages=spam_prompt, - options={"response_format": SpamDetectionResult}, - ) - - spam_result_raw: AgentResponse = yield spam_result_task - spam_result = cast(SpamDetectionResult, spam_result_raw.value) - - logger.info("[Orchestration] Spam detection result: is_spam=%s", spam_result.is_spam) - - # Branch based on spam detection result - if spam_result.is_spam: - logger.debug("[Orchestration] Email is spam, handling...") - result_task: Task[str] = context.call_activity("handle_spam_email", input=spam_result.reason) - result: str = yield result_task - return result - - # Email is legitimate - draft a response - logger.debug("[Orchestration] Email is legitimate, drafting response...") - - email_agent = agent_context.get_agent(EMAIL_AGENT_NAME) - - email_prompt = ( - "Draft a professional response to this email. Return a JSON response with a 'response' field " - "containing the reply:\n\n" - f"Email ID: {payload.email_id}\n" - f"Content: {payload.email_content}" - ) - - logger.info("[Orchestration] Running email assistant agent: %s", email_prompt) - email_result_task = email_agent.run( - messages=email_prompt, - options={"response_format": EmailResponse}, - ) - - email_result_raw: AgentResponse = yield email_result_task - email_result = cast(EmailResponse, email_result_raw.value) - - logger.debug("[Orchestration] Email response drafted, sending...") - result_task: Task[str] = context.call_activity("send_email", input=email_result.response) - result: str = yield result_task - - logger.info("Sent Email: %s", result) - - return result - - -def get_worker( - taskhub: str | None = None, endpoint: str | None = None, log_handler: logging.Handler | None = None -) -> DurableTaskSchedulerWorker: - """Create a configured DurableTaskSchedulerWorker. - - Args: - taskhub: Task hub name (defaults to TASKHUB env var or "default") - endpoint: Scheduler endpoint (defaults to ENDPOINT env var or "http://localhost:8080") - log_handler: Optional logging handler for worker logging - - Returns: - Configured DurableTaskSchedulerWorker instance - """ - taskhub_name = taskhub or os.getenv("TASKHUB", "default") - endpoint_url = endpoint or os.getenv("ENDPOINT", "http://localhost:8080") - - logger.debug(f"Using taskhub: {taskhub_name}") - logger.debug(f"Using endpoint: {endpoint_url}") - - credential = None if endpoint_url == "http://localhost:8080" else AzureCliCredential() - - return DurableTaskSchedulerWorker( - host_address=endpoint_url, - secure_channel=endpoint_url != "http://localhost:8080", - taskhub=taskhub_name, - token_credential=credential, - log_handler=log_handler, - ) - - -def setup_worker(worker: DurableTaskSchedulerWorker) -> DurableAIAgentWorker: - """Set up the worker with agents, orchestrations, and activities registered. - - Args: - worker: The DurableTaskSchedulerWorker instance - - Returns: - DurableAIAgentWorker with agents, orchestrations, and activities registered - """ - # Wrap it with the agent worker - agent_worker = DurableAIAgentWorker(worker) - - # Create and register both agents - logger.debug("Creating and registering agents...") - spam_agent = create_spam_agent() - email_agent = create_email_agent() - - agent_worker.add_agent(spam_agent) - agent_worker.add_agent(email_agent) - - logger.debug(f"✓ Registered agents: {spam_agent.name}, {email_agent.name}") - - # Register activity functions - logger.debug("Registering activity functions...") - worker.add_activity(handle_spam_email) # type: ignore[arg-type] - worker.add_activity(send_email) # type: ignore[arg-type] - logger.debug("✓ Registered activity: handle_spam_email") - logger.debug("✓ Registered activity: send_email") - - # Register the orchestration function - logger.debug("Registering orchestration function...") - worker.add_orchestrator(spam_detection_orchestration) # type: ignore[arg-type] - logger.debug(f"✓ Registered orchestration: {spam_detection_orchestration.__name__}") - - return agent_worker - - -async def main(): - """Main entry point for the worker process.""" - logger.debug("Starting Durable Task Spam Detection Worker with Orchestration...") - - # Create a worker using the helper function - worker = get_worker() - - # Setup worker with agents, orchestrations, and activities - setup_worker(worker) - - logger.debug("Worker is ready and listening for requests...") - logger.debug("Press Ctrl+C to stop.") - - try: - # Start the worker (this blocks until stopped) - worker.start() - - # Keep the worker running - while True: - await asyncio.sleep(1) - except KeyboardInterrupt: - logger.debug("Worker shutdown initiated") - - logger.debug("Worker stopped") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/04-hosting/durabletask/07_single_agent_orchestration_hitl/.env.example b/python/samples/04-hosting/durabletask/07_single_agent_orchestration_hitl/.env.example deleted file mode 100644 index 30f5c34228f..00000000000 --- a/python/samples/04-hosting/durabletask/07_single_agent_orchestration_hitl/.env.example +++ /dev/null @@ -1,5 +0,0 @@ -# Azure AI Foundry project endpoint URL, e.g. https://your-project.services.ai.azure.com/api/projects/your-project -FOUNDRY_PROJECT_ENDPOINT= - -# Model deployment name in your Foundry project -FOUNDRY_MODEL= diff --git a/python/samples/04-hosting/durabletask/07_single_agent_orchestration_hitl/README.md b/python/samples/04-hosting/durabletask/07_single_agent_orchestration_hitl/README.md deleted file mode 100644 index 9f47d510091..00000000000 --- a/python/samples/04-hosting/durabletask/07_single_agent_orchestration_hitl/README.md +++ /dev/null @@ -1,87 +0,0 @@ -# Single-Agent Orchestration with Human-in-the-Loop (HITL) - -This sample demonstrates the human-in-the-loop pattern where a WriterAgent generates content and waits for human approval before publishing. The orchestration handles external events, timeouts, and iterative refinement based on feedback. - -## Key Concepts Demonstrated - -- Human-in-the-loop workflow with orchestration pausing for external approval/rejection events. -- External event handling using `wait_for_external_event()` to receive human input. -- Timeout management with `when_any()` to race between approval event and timeout. -- Iterative refinement where agent regenerates content based on reviewer feedback. -- Structured outputs using Pydantic models with `options={"response_format": ...}` for type-safe agent responses. -- Activity functions for notifications and publishing as separate side effects. -- Long-running orchestrations maintaining state across multiple 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, you can run the sample using the combined approach or separate worker and client processes: - -**Option 1: Combined (Recommended for Testing)** - -```bash -cd samples/04-hosting/durabletask/07_single_agent_orchestration_hitl -python sample.py -``` - -**Option 2: Separate Processes** - -Start the worker in one terminal: - -```bash -python worker.py -``` - -In a new terminal, run the client: - -```bash -python client.py -``` - -The sample runs two test scenarios: - -**Test 1: Immediate Approval** -``` -Topic: The benefits of cloud computing -[WriterAgent generates content] -[Notification sent: Please review the content] -[Client sends approval] -✓ Content published successfully -``` - -**Test 2: Rejection with Feedback, Then Approval** -``` -Topic: The future of artificial intelligence -[WriterAgent generates initial content] -[Notification sent: Please review the content] -[Client sends rejection with feedback: "Make it more technical..."] -[WriterAgent regenerates content with feedback] -[Notification sent: Please review the revised content] -[Client sends approval] -✓ Revised content published successfully -``` - -## How It Works - -1. **Initial Generation**: WriterAgent creates content based on the topic. -2. **Review Loop** (up to max_review_attempts): - - Activity notifies user for approval - - Orchestration waits for approval event OR timeout - - **If approved**: Publishes content and returns - - **If rejected**: Incorporates feedback and regenerates - - **If timeout**: Raises TimeoutError -3. **Completion**: Returns published content or error. - -## Viewing Agent State - -You can view the state of the WriterAgent and orchestration in the Durable Task Scheduler dashboard: - -1. Open your browser and navigate to `http://localhost:8082` -2. In the dashboard, you can view: - - Orchestration instance status and pending events - - WriterAgent entity state and conversation sessions - - Activity execution logs - - External event history diff --git a/python/samples/04-hosting/durabletask/07_single_agent_orchestration_hitl/client.py b/python/samples/04-hosting/durabletask/07_single_agent_orchestration_hitl/client.py deleted file mode 100644 index 0a4efc12e2b..00000000000 --- a/python/samples/04-hosting/durabletask/07_single_agent_orchestration_hitl/client.py +++ /dev/null @@ -1,284 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Client application for starting a human-in-the-loop content generation orchestration. - -This client connects to the Durable Task Scheduler and demonstrates the HITL pattern -by starting an orchestration, sending approval/rejection events, and monitoring progress. - -Prerequisites: -- The worker must be running with the agent, orchestration, and activities registered -- Set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL -- Sign in with Azure CLI for AzureCliCredential authentication -- Durable Task Scheduler must be running -""" - -import asyncio -import json -import logging -import os -import time - -from azure.identity import AzureCliCredential -from durabletask.azuremanaged.client import DurableTaskSchedulerClient -from durabletask.client import OrchestrationState - -# Configure logging -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - -# Constants -HUMAN_APPROVAL_EVENT = "HumanApproval" - - -def get_client( - taskhub: str | None = None, endpoint: str | None = None, log_handler: logging.Handler | None = None -) -> DurableTaskSchedulerClient: - """Create a configured DurableTaskSchedulerClient. - - Args: - taskhub: Task hub name (defaults to TASKHUB env var or "default") - endpoint: Scheduler endpoint (defaults to ENDPOINT env var or "http://localhost:8080") - log_handler: Optional logging handler for client logging - - Returns: - Configured DurableTaskSchedulerClient instance - """ - taskhub_name = taskhub or os.getenv("TASKHUB", "default") - endpoint_url = endpoint or os.getenv("ENDPOINT", "http://localhost:8080") - - logger.debug(f"Using taskhub: {taskhub_name}") - logger.debug(f"Using endpoint: {endpoint_url}") - - credential = None if endpoint_url == "http://localhost:8080" else AzureCliCredential() - - return DurableTaskSchedulerClient( - host_address=endpoint_url, - secure_channel=endpoint_url != "http://localhost:8080", - taskhub=taskhub_name, - token_credential=credential, - log_handler=log_handler, - ) - - -def _log_completion_result( - metadata: OrchestrationState | None, -) -> None: - """Log the orchestration completion result. - - Args: - metadata: The orchestration metadata - """ - if metadata and metadata.runtime_status.name == "COMPLETED": - result = metadata.serialized_output - - logger.debug("Orchestration completed successfully!") - - if result: - try: - result_dict = json.loads(result) - logger.info("Final Result: %s", json.dumps(result_dict, indent=2)) - except json.JSONDecodeError: - logger.debug(f"Result: {result}") - - elif metadata: - logger.error(f"Orchestration ended with status: {metadata.runtime_status.name}") - if metadata.serialized_output: - logger.error(f"Output: {metadata.serialized_output}") - else: - logger.error("Orchestration did not complete within the timeout period") - - -def _wait_and_log_completion(client: DurableTaskSchedulerClient, instance_id: str, timeout: int = 60) -> None: - """Wait for orchestration completion and log the result. - - Args: - client: The DurableTaskSchedulerClient instance - instance_id: The orchestration instance ID - timeout: Maximum time to wait for completion in seconds - """ - logger.debug("Waiting for orchestration to complete...") - metadata = client.wait_for_orchestration_completion(instance_id=instance_id, timeout=timeout) - - _log_completion_result(metadata) - - -def send_approval(client: DurableTaskSchedulerClient, instance_id: str, approved: bool, feedback: str = "") -> None: - """Send approval or rejection event to the orchestration. - - Args: - client: The DurableTaskSchedulerClient instance - instance_id: The orchestration instance ID - approved: Whether to approve or reject - feedback: Optional feedback message (used when rejected) - """ - approval_data = {"approved": approved, "feedback": feedback} - - logger.debug(f"Sending {'APPROVAL' if approved else 'REJECTION'} to instance {instance_id}") - if feedback: - logger.debug(f"Feedback: {feedback}") - - # Raise the external event - client.raise_orchestration_event(instance_id=instance_id, event_name=HUMAN_APPROVAL_EVENT, data=approval_data) - - logger.debug("Event sent successfully") - - -def wait_for_notification(client: DurableTaskSchedulerClient, instance_id: str, timeout_seconds: int = 10) -> bool: - """Wait for the orchestration to reach a notification point. - - Polls the orchestration status until it appears to be waiting for approval. - - Args: - client: The DurableTaskSchedulerClient instance - instance_id: The orchestration instance ID - timeout_seconds: Maximum time to wait - - Returns: - True if notification detected, False if timeout - """ - logger.debug("Waiting for orchestration to reach notification point...") - - start_time = time.time() - while time.time() - start_time < timeout_seconds: - try: - metadata = client.get_orchestration_state( - instance_id=instance_id, - ) - - if metadata: - # Check if we're waiting for approval by examining custom status - if metadata.serialized_custom_status: - try: - custom_status = json.loads(metadata.serialized_custom_status) - # Handle both string and dict custom status - status_str = custom_status if isinstance(custom_status, str) else str(custom_status) - if status_str.lower().startswith("requesting human feedback"): - logger.debug("Orchestration is requesting human feedback") - return True - except (json.JSONDecodeError, AttributeError): - # If it's not JSON, treat as plain string - if metadata.serialized_custom_status.lower().startswith("requesting human feedback"): - logger.debug("Orchestration is requesting human feedback") - return True - - # Check for terminal states - if metadata.runtime_status.name == "COMPLETED": - logger.debug("Orchestration already completed") - return False - if metadata.runtime_status.name == "FAILED": - logger.error("Orchestration failed") - return False - except Exception as e: - logger.debug(f"Status check: {e}") - - time.sleep(1) - - logger.warning("Timeout waiting for notification") - return False - - -def run_interactive_client(client: DurableTaskSchedulerClient) -> None: - """Run an interactive client that prompts for user input and handles approval workflow. - - Args: - client: The DurableTaskSchedulerClient instance - """ - # Get user inputs - logger.debug("Content Generation - Human-in-the-Loop") - - topic = input("Enter the topic for content generation: ").strip() - if not topic: - topic = "The benefits of cloud computing" - logger.info(f"Using default topic: {topic}") - - max_attempts_str = input("Enter max review attempts (default: 3): ").strip() - max_review_attempts = int(max_attempts_str) if max_attempts_str else 3 - - timeout_hours_str = input("Enter approval timeout in hours (default: 5): ").strip() - timeout_hours = float(timeout_hours_str) if timeout_hours_str else 5.0 - approval_timeout_seconds = int(timeout_hours * 3600) - - payload = { - "topic": topic, - "max_review_attempts": max_review_attempts, - "approval_timeout_seconds": approval_timeout_seconds, - } - - logger.debug(f"Configuration: Topic={topic}, Max attempts={max_review_attempts}, Timeout={timeout_hours}h") - - # Start the orchestration - logger.debug("Starting content generation orchestration...") - instance_id = client.schedule_new_orchestration( # type: ignore - orchestrator="content_generation_hitl_orchestration", - input=payload, - ) - - logger.info(f"Orchestration started with instance ID: {instance_id}") - - # Review loop - attempt = 1 - while attempt <= max_review_attempts: - logger.info(f"Review Attempt {attempt}/{max_review_attempts}") - - # Wait for orchestration to reach notification point - logger.debug("Waiting for content generation...") - if not wait_for_notification(client, instance_id, timeout_seconds=120): - logger.error("Failed to receive notification. Orchestration may have completed or failed.") - break - - logger.info("Content is ready for review! Please review the content in the worker logs.") - - # Get user decision - while True: - decision = input("Do you approve this content? (yes/no): ").strip().lower() - if decision in ["yes", "y", "no", "n"]: - break - logger.info("Please enter 'yes' or 'no'") - - approved = decision in ["yes", "y"] - - if approved: - logger.debug("Sending approval...") - send_approval(client, instance_id, approved=True) - logger.info("Approval sent. Waiting for orchestration to complete...") - _wait_and_log_completion(client, instance_id, timeout=60) - break - feedback = input("Enter feedback for improvement: ").strip() - if not feedback: - feedback = "Please revise the content." - - logger.debug("Sending rejection with feedback...") - send_approval(client, instance_id, approved=False, feedback=feedback) - logger.info("Rejection sent. Content will be regenerated...") - - attempt += 1 - - if attempt > max_review_attempts: - logger.info(f"Maximum review attempts ({max_review_attempts}) reached.") - _wait_and_log_completion(client, instance_id, timeout=30) - break - - # Small pause before next iteration - time.sleep(2) - - -async def main() -> None: - """Main entry point for the client application.""" - logger.debug("Starting Durable Task HITL Content Generation Client") - - # Create client using helper function - client = get_client() - - try: - run_interactive_client(client) - - except KeyboardInterrupt: - logger.info("Interrupted by user") - except Exception as e: - logger.exception(f"Error during orchestration: {e}") - finally: - logger.debug("Client shutting down") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/04-hosting/durabletask/07_single_agent_orchestration_hitl/requirements.txt b/python/samples/04-hosting/durabletask/07_single_agent_orchestration_hitl/requirements.txt deleted file mode 100644 index a4a947d9b61..00000000000 --- a/python/samples/04-hosting/durabletask/07_single_agent_orchestration_hitl/requirements.txt +++ /dev/null @@ -1,11 +0,0 @@ -# Agent Framework packages -# To use the deployed version, uncomment the lines below and comment out the local installation lines -# agent-framework-foundry -# agent-framework-durabletask - -# Local installation (for development and testing) -# Each package must be listed explicitly because pip doesn't resolve uv workspace sources. -# Without explicit entries, pip would fetch transitive dependencies from PyPI instead of local source. --e ../../../../packages/core # Core framework - base dependency for all packages --e ../../../../packages/foundry # Foundry support - dependency for hosted chat/agent samples --e ../../../../packages/durabletask # Durable Task support - the main package for this sample diff --git a/python/samples/04-hosting/durabletask/07_single_agent_orchestration_hitl/sample.py b/python/samples/04-hosting/durabletask/07_single_agent_orchestration_hitl/sample.py deleted file mode 100644 index 2d89f1b86b2..00000000000 --- a/python/samples/04-hosting/durabletask/07_single_agent_orchestration_hitl/sample.py +++ /dev/null @@ -1,53 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Human-in-the-Loop Orchestration Sample - Durable Task Integration -This sample demonstrates the HITL pattern with a WriterAgent that generates content -and waits for human approval. The orchestration handles: -- External event waiting (approval/rejection) -- Timeout handling -- Iterative refinement based on feedback -- Activity functions for notifications and publishing -Prerequisites: -- Set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL -- Sign in with Azure CLI for AzureCliCredential authentication -- Durable Task Scheduler must be running (e.g., using Docker) -To run this sample: - python sample.py -""" - -import logging - -# Import helper functions from worker and client modules -from client import get_client, run_interactive_client # pyrefly: ignore[missing-import] -from dotenv import load_dotenv -from worker import get_worker, setup_worker # pyrefly: ignore[missing-import] - -logging.basicConfig(level=logging.INFO, force=True) -logger = logging.getLogger() - - -def main(): - """Main entry point - runs both worker and client in single process.""" - logger.debug("Starting Durable Task HITL Content Generation Sample (Combined Worker + Client)...") - silent_handler = logging.NullHandler() - # Create and start the worker using helper function and context manager - dts_worker = get_worker(log_handler=silent_handler) - with dts_worker: - # Register agent, orchestration, and activities using helper function - setup_worker(dts_worker) - # Start the worker - dts_worker.start() - logger.debug("Worker started and listening for requests...") - # Create the client using helper function - client = get_client(log_handler=silent_handler) - try: - logger.debug("CLIENT: Starting orchestration tests...") - run_interactive_client(client) - except Exception as e: - logger.exception(f"Error during sample execution: {e}") - logger.debug("Sample completed. Worker shutting down...") - - -if __name__ == "__main__": - load_dotenv() - main() diff --git a/python/samples/04-hosting/durabletask/07_single_agent_orchestration_hitl/worker.py b/python/samples/04-hosting/durabletask/07_single_agent_orchestration_hitl/worker.py deleted file mode 100644 index 5f317b551b6..00000000000 --- a/python/samples/04-hosting/durabletask/07_single_agent_orchestration_hitl/worker.py +++ /dev/null @@ -1,381 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Worker process for hosting a writer agent with human-in-the-loop orchestration. - -This worker registers a WriterAgent and an orchestration function that implements -a human-in-the-loop review workflow. The orchestration pauses for external events -(human approval/rejection) with timeout handling, and iterates based on feedback. - -Prerequisites: -- Set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL -- Sign in with Azure CLI for AzureCliCredential authentication -- Start a Durable Task Scheduler (e.g., using Docker) -""" - -import asyncio -import logging -import os -from collections.abc import Generator -from datetime import timedelta -from typing import Any, cast - -from agent_framework import Agent, AgentResponse -from agent_framework.azure import DurableAIAgentOrchestrationContext, DurableAIAgentWorker -from agent_framework.foundry import FoundryChatClient -from azure.identity import AzureCliCredential -from azure.identity.aio import AzureCliCredential as AsyncAzureCliCredential -from dotenv import load_dotenv -from durabletask.azuremanaged.worker import DurableTaskSchedulerWorker -from durabletask.task import ActivityContext, OrchestrationContext, Task, when_any # type: ignore -from pydantic import BaseModel, ValidationError - -# Load environment variables from .env file -load_dotenv() - -# Configure logging -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - -# Constants -WRITER_AGENT_NAME = "WriterAgent" -HUMAN_APPROVAL_EVENT = "HumanApproval" - - -class ContentGenerationInput(BaseModel): - """Input for content generation orchestration.""" - - topic: str - max_review_attempts: int = 3 - approval_timeout_seconds: float = 300 # 5 minutes for demo (72 hours in production) - - -class GeneratedContent(BaseModel): - """Structured output from writer agent.""" - - title: str - content: str - - -class HumanApproval(BaseModel): - """Human approval decision.""" - - approved: bool - feedback: str = "" - - -def create_writer_agent() -> "Agent": - """Create the Writer agent using Azure OpenAI. - - Returns: - Agent: The configured Writer agent - """ - instructions = ( - "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. " - "Return your response as JSON with 'title' and 'content' fields." - "Limit response to 300 words or less." - ) - - _client = FoundryChatClient( - project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], - model=os.environ["FOUNDRY_MODEL"], - credential=AsyncAzureCliCredential(), - ) - return Agent( - client=_client, - name=WRITER_AGENT_NAME, - instructions=instructions, - ) - - -def notify_user_for_approval(context: ActivityContext, content: dict[str, str]) -> str: - """Activity function to notify user for approval. - - Args: - context: The activity context - content: The generated content dictionary - """ - model = GeneratedContent.model_validate(content) - logger.info("NOTIFICATION: Please review the following content for approval:") - logger.info(f"Title: {model.title or '(untitled)'}") - logger.info(f"Content: {model.content}") - logger.info("Use the client to send approval or rejection.") - return "Notification sent to user for approval." - - -def publish_content(context: ActivityContext, content: dict[str, str]) -> str: - """Activity function to publish approved content. - - Args: - context: The activity context - content: The generated content dictionary - """ - model = GeneratedContent.model_validate(content) - logger.info("PUBLISHING: Content has been published successfully:") - logger.info(f"Title: {model.title or '(untitled)'}") - logger.info(f"Content: {model.content}") - return "Published content successfully." - - -def content_generation_hitl_orchestration( - context: OrchestrationContext, payload_raw: Any -) -> Generator[Task[Any], Any, dict[str, str]]: - """Human-in-the-loop orchestration for content generation with approval workflow. - - This orchestration: - 1. Generates initial content using WriterAgent - 2. Loops up to max_review_attempts times: - a. Notifies user for approval - b. Waits for approval event or timeout - c. If approved: publishes and returns - d. If rejected: incorporates feedback and regenerates - e. If timeout: raises TimeoutError - 3. Raises RuntimeError if max attempts exhausted - - Args: - context: The orchestration context - payload_raw: The input payload - - Returns: - dict: Result with published content - - Raises: - ValueError: If input is invalid or agent returns no content - TimeoutError: If human approval times out - RuntimeError: If max review attempts exhausted - """ - logger.debug("[Orchestration] Starting HITL content generation orchestration") - - # Validate input - if not isinstance(payload_raw, dict): - raise ValueError("Content generation input is required") - - try: - payload = ContentGenerationInput.model_validate(payload_raw) - except ValidationError as exc: - raise ValueError(f"Invalid content generation input: {exc}") from exc - - logger.debug(f"[Orchestration] Topic: {payload.topic}") - logger.debug(f"[Orchestration] Max attempts: {payload.max_review_attempts}") - logger.debug(f"[Orchestration] Approval timeout: {payload.approval_timeout_seconds}s") - - # Wrap the orchestration context to access agents - agent_context = DurableAIAgentOrchestrationContext(context) - - # Get the writer agent - writer = agent_context.get_agent(WRITER_AGENT_NAME) - writer_session = writer.create_session() - - logger.info(f"SessionID: {writer_session.session_id}") - - # Generate initial content - logger.info("[Orchestration] Generating initial content...") - - initial_response: AgentResponse = yield writer.run( - messages=f"Write a short article about '{payload.topic}'.", - session=writer_session, - options={"response_format": GeneratedContent}, - ) - content = cast(GeneratedContent, initial_response.value) - - if not isinstance(content, GeneratedContent): - raise ValueError("Agent returned no content after extraction.") - - logger.debug(f"[Orchestration] Initial content generated: {content.title}") - - # Review loop - attempt = 0 - while attempt < payload.max_review_attempts: - attempt += 1 - logger.debug(f"[Orchestration] Review iteration #{attempt}/{payload.max_review_attempts}") - - context.set_custom_status( - f"Requesting human feedback (Attempt {attempt}, timeout {payload.approval_timeout_seconds}s)" - ) - - # Notify user for approval - yield context.call_activity("notify_user_for_approval", input=content.model_dump()) - - logger.debug("[Orchestration] Waiting for human approval or timeout...") - - # Wait for approval event or timeout - approval_task: Task[Any] = context.wait_for_external_event(HUMAN_APPROVAL_EVENT) # type: ignore - timeout_task: Task[Any] = context.create_timer( # type: ignore - context.current_utc_datetime + timedelta(seconds=payload.approval_timeout_seconds) - ) - - # Race between approval and timeout - winner_task = yield when_any([approval_task, timeout_task]) # type: ignore - - if winner_task == approval_task: - # Approval received before timeout - logger.debug("[Orchestration] Received human approval event") - - context.set_custom_status("Content reviewed by human reviewer.") - - # Parse approval - approval_data: Any = approval_task.get_result() # type: ignore - logger.debug(f"[Orchestration] Approval data: {approval_data}") - - # Handle different formats of approval_data - if isinstance(approval_data, dict): - approval = HumanApproval.model_validate(approval_data) - elif isinstance(approval_data, str): - # Try to parse as boolean-like string - lower_data = approval_data.lower().strip() - if lower_data in {"true", "yes", "approved", "y", "1"}: - approval = HumanApproval(approved=True, feedback="") - elif lower_data in {"false", "no", "rejected", "n", "0"}: - approval = HumanApproval(approved=False, feedback="") - else: - approval = HumanApproval(approved=False, feedback=approval_data) - else: - approval = HumanApproval(approved=False, feedback=str(approval_data)) # type: ignore - - if approval.approved: - # Content approved - publish and return - logger.debug("[Orchestration] Content approved! Publishing...") - context.set_custom_status("Content approved by human reviewer. Publishing...") - publish_task: Task[Any] = context.call_activity("publish_content", input=content.model_dump()) - yield publish_task - - logger.debug("[Orchestration] Content published successfully") - return {"content": content.content, "title": content.title} - - # Content rejected - incorporate feedback and regenerate - logger.debug(f"[Orchestration] Content rejected. Feedback: {approval.feedback}") - - # Check if we've exhausted attempts - if attempt >= payload.max_review_attempts: - context.set_custom_status("Max review attempts exhausted.") - # Max attempts exhausted - logger.error(f"[Orchestration] Max attempts ({payload.max_review_attempts}) exhausted") - break - - context.set_custom_status("Content rejected by human reviewer. Regenerating...") - - rewrite_prompt = ( - "The content was rejected by a human reviewer. Please rewrite the article incorporating their feedback.\n\n" - f"Human Feedback: {approval.feedback or 'No specific feedback provided.'}" - ) - - logger.debug("[Orchestration] Regenerating content with feedback...") - - logger.warning(f"Regenerating with SessionID: {writer_session.session_id}") - - rewrite_response: AgentResponse = yield writer.run( - messages=rewrite_prompt, - session=writer_session, - options={"response_format": GeneratedContent}, - ) - rewritten_content = cast(GeneratedContent, rewrite_response.value) - - if not isinstance(rewritten_content, GeneratedContent): - raise ValueError("Agent returned no content after rewrite.") - - content = rewritten_content - logger.debug(f"[Orchestration] Content regenerated: {content.title}") - - else: - # Timeout occurred - logger.error(f"[Orchestration] Approval timeout after {payload.approval_timeout_seconds}s") - - raise TimeoutError(f"Human approval timed out after {payload.approval_timeout_seconds} second(s).") - - # If we exit the loop without returning, max attempts were exhausted - context.set_custom_status("Max review attempts exhausted.") - raise RuntimeError(f"Content could not be approved after {payload.max_review_attempts} iteration(s).") - - -def get_worker( - taskhub: str | None = None, endpoint: str | None = None, log_handler: logging.Handler | None = None -) -> DurableTaskSchedulerWorker: - """Create a configured DurableTaskSchedulerWorker. - - Args: - taskhub: Task hub name (defaults to TASKHUB env var or "default") - endpoint: Scheduler endpoint (defaults to ENDPOINT env var or "http://localhost:8080") - log_handler: Optional logging handler for worker logging - - Returns: - Configured DurableTaskSchedulerWorker instance - """ - taskhub_name = taskhub or os.getenv("TASKHUB", "default") - endpoint_url = endpoint or os.getenv("ENDPOINT", "http://localhost:8080") - - logger.debug(f"Using taskhub: {taskhub_name}") - logger.debug(f"Using endpoint: {endpoint_url}") - - credential = None if endpoint_url == "http://localhost:8080" else AzureCliCredential() - - return DurableTaskSchedulerWorker( - host_address=endpoint_url, - secure_channel=endpoint_url != "http://localhost:8080", - taskhub=taskhub_name, - token_credential=credential, - log_handler=log_handler, - ) - - -def setup_worker(worker: DurableTaskSchedulerWorker) -> DurableAIAgentWorker: - """Set up the worker with agents, orchestrations, and activities registered. - - Args: - worker: The DurableTaskSchedulerWorker instance - - Returns: - DurableAIAgentWorker with agents, orchestrations, and activities registered - """ - # Wrap it with the agent worker - agent_worker = DurableAIAgentWorker(worker) - - # Create and register the writer agent - logger.debug("Creating and registering Writer agent...") - writer_agent = create_writer_agent() - agent_worker.add_agent(writer_agent) - - logger.debug(f"✓ Registered agent: {writer_agent.name}") - - # Register activity functions - logger.debug("Registering activity functions...") - worker.add_activity(notify_user_for_approval) # type: ignore - worker.add_activity(publish_content) # type: ignore - logger.debug("✓ Registered activity: notify_user_for_approval") - logger.debug("✓ Registered activity: publish_content") - - # Register the orchestration function - logger.debug("Registering orchestration function...") - worker.add_orchestrator(content_generation_hitl_orchestration) # type: ignore - logger.debug(f"✓ Registered orchestration: {content_generation_hitl_orchestration.__name__}") - - return agent_worker - - -async def main(): - """Main entry point for the worker process.""" - logger.debug("Starting Durable Task HITL Content Generation Worker...") - - # Create a worker using the helper function - worker = get_worker() - - # Setup worker with agents, orchestrations, and activities - setup_worker(worker) - - logger.debug("Worker is ready and listening for requests...") - logger.debug("Press Ctrl+C to stop.") - - try: - # Start the worker (this blocks until stopped) - worker.start() - - # Keep the worker running - while True: - await asyncio.sleep(1) - except KeyboardInterrupt: - logger.debug("Worker shutdown initiated") - - logger.debug("Worker stopped") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/04-hosting/durabletask/08_workflow/README.md b/python/samples/04-hosting/durabletask/08_workflow/README.md deleted file mode 100644 index 5a1fd97e90f..00000000000 --- a/python/samples/04-hosting/durabletask/08_workflow/README.md +++ /dev/null @@ -1,57 +0,0 @@ -# Workflow on a Standalone Durable Task Worker - -This sample demonstrates running an agent-framework `Workflow` as a durable -orchestration on a **standalone Durable Task worker** — no Azure Functions -required. It is the durabletask counterpart to the Azure Functions workflow -samples (`samples/04-hosting/azure_functions/10_workflow_no_shared_state`). - -## Key Concepts Demonstrated - -- Hosting a MAF `Workflow` outside Azure Functions via - `DurableAIAgentWorker.configure_workflow(workflow)`, which auto-registers: - - a durable **entity** for each agent executor, - - a durable **activity** for each non-agent executor, and - - the **workflow orchestrator** (registered as `WORKFLOW_ORCHESTRATOR_NAME`). -- Conditional routing with `add_switch_case_edge_group` (spam vs. legitimate email). -- Mixing AI agents with non-agent executors in one workflow graph. -- Starting the workflow from a client with - `DurableWorkflowClient.start_workflow(input=...)` and reading its result with - `await_workflow_output(instance_id)`. - -## Environment Setup - -See the [README.md](../README.md) in the parent directory for environment setup. - -This sample uses Microsoft Foundry credentials: - -- `FOUNDRY_PROJECT_ENDPOINT` -- `FOUNDRY_MODEL` - -It also needs a Durable Task Scheduler. For local development, start the -emulator (defaults to `http://localhost:8080`): - -```bash -docker run -d -p 8080:8080 -p 8082:8082 mcr.microsoft.com/dts/dts-emulator:latest -``` - -## Running the Sample - -Start the worker in one terminal: - -```bash -cd samples/04-hosting/durabletask/08_workflow -python worker.py -``` - -In a second terminal, run the client: - -```bash -python client.py -``` - -The client runs two cases: - -- **Legitimate email** → `SpamDetectionAgent` → `EmailAssistantAgent` → - `email_sender` → `"Email sent: ..."`. -- **Spam email** → `SpamDetectionAgent` → `spam_handler` → - `"Email marked as spam: ..."`. diff --git a/python/samples/04-hosting/durabletask/08_workflow/client.py b/python/samples/04-hosting/durabletask/08_workflow/client.py deleted file mode 100644 index da8adffde85..00000000000 --- a/python/samples/04-hosting/durabletask/08_workflow/client.py +++ /dev/null @@ -1,75 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Client that starts the standalone workflow orchestration and prints the result. - -The worker (``worker.py``) must be running first. The workflow is started via -``DurableWorkflowClient.start_workflow`` - which schedules the ``dafx-{name}`` -orchestration that ``DurableAIAgentWorker.configure_workflow`` auto-registers for -the workflow named ``email_triage``. - -Prerequisites: -- ``worker.py`` running and connected to the same Durable Task Scheduler. -- A Durable Task Scheduler reachable at ``ENDPOINT`` (default ``http://localhost:8080``). -""" - -import asyncio -import logging -import os - -from agent_framework.azure import DurableWorkflowClient -from azure.identity import AzureCliCredential -from dotenv import load_dotenv -from durabletask.azuremanaged.client import DurableTaskSchedulerClient - -load_dotenv() - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - -WORKFLOW_NAME = "email_triage" - - -def get_client(taskhub: str | None = None, endpoint: str | None = None) -> DurableTaskSchedulerClient: - """Create a configured DurableTaskSchedulerClient.""" - taskhub_name = taskhub or os.getenv("TASKHUB", "default") - endpoint_url = endpoint or os.getenv("ENDPOINT", "http://localhost:8080") - - credential = None if endpoint_url == "http://localhost:8080" else AzureCliCredential() - - return DurableTaskSchedulerClient( - host_address=endpoint_url, - secure_channel=endpoint_url != "http://localhost:8080", - taskhub=taskhub_name, - token_credential=credential, - ) - - -def run_workflow(client: DurableWorkflowClient, email_content: str) -> None: - """Start the workflow with an email and wait for the result.""" - instance_id = client.start_workflow(input=email_content) - logger.info("Started workflow instance: %s", instance_id) - - output = client.await_workflow_output(instance_id) - logger.info("Workflow output: %s", output) - - -async def main() -> None: - """Run the workflow against a legitimate email and a spam email.""" - client = DurableWorkflowClient(get_client(), workflow_name=WORKFLOW_NAME) - - logger.info("TEST 1: Legitimate email") - run_workflow( - client, - "Hi team, just a reminder about our sprint planning meeting tomorrow at 10 AM. " - "Please review the agenda in Jira.", - ) - - logger.info("TEST 2: Spam email") - run_workflow( - client, - "URGENT! You've won $1,000,000! Click here now to claim your prize! Limited time offer!", - ) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/04-hosting/durabletask/08_workflow/worker.py b/python/samples/04-hosting/durabletask/08_workflow/worker.py deleted file mode 100644 index bb9f873f0ab..00000000000 --- a/python/samples/04-hosting/durabletask/08_workflow/worker.py +++ /dev/null @@ -1,213 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Worker that hosts a MAF Workflow as a durable orchestration (no Azure Functions). - -This sample shows how to run an agent-framework ``Workflow`` on a standalone -Durable Task worker using ``DurableAIAgentWorker.configure_workflow``. The worker -auto-registers: - -- a durable entity for each agent executor, -- a durable activity for each non-agent executor, and -- the workflow orchestrator (named ``WORKFLOW_ORCHESTRATOR_NAME``). - -The workflow classifies an email and conditionally routes it: spam is handled by -a non-agent executor, while legitimate email is drafted by a second agent and -"sent" by another non-agent executor. - -Prerequisites: -- Set ``FOUNDRY_PROJECT_ENDPOINT`` and ``FOUNDRY_MODEL``. -- Sign in with Azure CLI (``az login``) for ``AzureCliCredential``. -- Start a Durable Task Scheduler (e.g. the DTS emulator on ``localhost:8080``). - -Run the worker (this process), then run ``client.py`` in another process. -""" - -import asyncio -import logging -import os -from typing import Any - -from agent_framework import ( - Agent, - AgentExecutorResponse, - Case, - Default, - Executor, - Workflow, - WorkflowBuilder, - WorkflowContext, - handler, -) -from agent_framework.azure import DurableAIAgentWorker -from agent_framework.foundry import FoundryChatClient, FoundryChatOptions -from azure.identity import AzureCliCredential -from azure.identity.aio import AzureCliCredential as AsyncAzureCliCredential -from dotenv import load_dotenv -from durabletask.azuremanaged.worker import DurableTaskSchedulerWorker -from pydantic import BaseModel, ValidationError -from typing_extensions import Never - -load_dotenv() - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - -SPAM_AGENT_NAME = "SpamDetectionAgent" -EMAIL_AGENT_NAME = "EmailAssistantAgent" -WORKFLOW_NAME = "email_triage" - -SPAM_DETECTION_INSTRUCTIONS = ( - "You are a spam detection assistant that identifies spam emails. " - "Return JSON with fields is_spam (bool) and reason (string)." -) -EMAIL_ASSISTANT_INSTRUCTIONS = ( - "You are an email assistant that drafts professional replies to legitimate emails. " - "Return JSON with a single field 'response' containing the drafted reply." -) - - -class SpamDetectionResult(BaseModel): - """Structured output from the spam detection agent.""" - - is_spam: bool - reason: str - - -class EmailResponse(BaseModel): - """Structured output from the email assistant agent.""" - - response: str - - -class SpamHandlerExecutor(Executor): - """Non-agent executor that finalizes spam emails.""" - - @handler - async def handle_spam_result(self, agent_response: AgentExecutorResponse, ctx: WorkflowContext[Never, str]) -> None: - text = agent_response.agent_response.text - try: - result = SpamDetectionResult.model_validate_json(text) - reason = result.reason - except ValidationError: - reason = "Invalid JSON from agent" - await ctx.yield_output(f"Email marked as spam: {reason}") - - -class EmailSenderExecutor(Executor): - """Non-agent executor that 'sends' the drafted reply.""" - - @handler - async def handle_email_response( - self, agent_response: AgentExecutorResponse, ctx: WorkflowContext[Never, str] - ) -> None: - text = agent_response.agent_response.text - try: - email = EmailResponse.model_validate_json(text) - reply = email.response - except ValidationError: - reply = "Error generating response." - await ctx.yield_output(f"Email sent: {reply}") - - -def is_spam_detected(message: Any) -> bool: - """Routing condition: True when the spam agent flagged the email as spam.""" - if not isinstance(message, AgentExecutorResponse): - return False - try: - return SpamDetectionResult.model_validate_json(message.agent_response.text).is_spam - except Exception: - return False - - -def _create_chat_client() -> FoundryChatClient: - """Create a Microsoft Foundry chat client using AzureCliCredential.""" - return FoundryChatClient( - project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], - model=os.environ["FOUNDRY_MODEL"], - credential=AsyncAzureCliCredential(), - ) - - -def create_workflow() -> Workflow: - """Build the conditional spam-detection workflow.""" - chat_client = _create_chat_client() - - spam_agent = Agent( - client=chat_client, - name=SPAM_AGENT_NAME, - instructions=SPAM_DETECTION_INSTRUCTIONS, - default_options=FoundryChatOptions[Any](response_format=SpamDetectionResult), - ) - email_agent = Agent( - client=chat_client, - name=EMAIL_AGENT_NAME, - instructions=EMAIL_ASSISTANT_INSTRUCTIONS, - default_options=FoundryChatOptions[Any](response_format=EmailResponse), - ) - - spam_handler = SpamHandlerExecutor(id="spam_handler") - email_sender = EmailSenderExecutor(id="email_sender") - - return ( - WorkflowBuilder(name=WORKFLOW_NAME, start_executor=spam_agent) - .add_switch_case_edge_group( - spam_agent, - [ - Case(condition=is_spam_detected, target=spam_handler), - Default(target=email_agent), - ], - ) - .add_edge(email_agent, email_sender) - .build() - ) - - -def get_worker( - taskhub: str | None = None, endpoint: str | None = None, log_handler: logging.Handler | None = None -) -> DurableTaskSchedulerWorker: - """Create a configured DurableTaskSchedulerWorker.""" - taskhub_name = taskhub or os.getenv("TASKHUB", "default") - endpoint_url = endpoint or os.getenv("ENDPOINT", "http://localhost:8080") - - credential = None if endpoint_url == "http://localhost:8080" else AzureCliCredential() - - return DurableTaskSchedulerWorker( - host_address=endpoint_url, - secure_channel=endpoint_url != "http://localhost:8080", - taskhub=taskhub_name, - token_credential=credential, - log_handler=log_handler, - ) - - -def setup_worker(worker: DurableTaskSchedulerWorker) -> DurableAIAgentWorker: - """Register the workflow (agents + activities + orchestrator) on the worker.""" - agent_worker = DurableAIAgentWorker(worker) - - workflow = create_workflow() - # One call wires up: agent entities, non-agent executor activities, and the - # workflow orchestrator (registered as WORKFLOW_ORCHESTRATOR_NAME). - agent_worker.configure_workflow(workflow) - logger.info("✓ Configured workflow with %d executors", len(workflow.executors)) - - return agent_worker - - -async def main() -> None: - """Start the worker and block until interrupted.""" - worker = get_worker() - setup_worker(worker) - - logger.info("Worker is ready and listening for work items. Press Ctrl+C to stop.") - try: - worker.start() - while True: - await asyncio.sleep(1) - except KeyboardInterrupt: - logger.info("Worker shutdown initiated") - - logger.info("Worker stopped") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/04-hosting/durabletask/09_workflow_hitl/README.md b/python/samples/04-hosting/durabletask/09_workflow_hitl/README.md deleted file mode 100644 index 470d00ba128..00000000000 --- a/python/samples/04-hosting/durabletask/09_workflow_hitl/README.md +++ /dev/null @@ -1,78 +0,0 @@ -# Human-in-the-Loop Workflow on a Standalone Durable Task Worker - -This sample demonstrates a Human-in-the-Loop (HITL) agent-framework `Workflow` -running as a durable orchestration on a **standalone Durable Task worker** — no -Azure Functions required. It is the durabletask counterpart to the Azure -Functions sample `samples/04-hosting/azure_functions/12_workflow_hitl`. - -## Key Concepts Demonstrated - -- Pausing a workflow for human input with MAF's `ctx.request_info()` / - `@response_handler` pattern, hosted on a standalone worker via - `DurableAIAgentWorker.configure_workflow(workflow)`. -- Discovering pending HITL requests from a client with - `DurableWorkflowClient.get_pending_hitl_requests(instance_id)`. -- Resuming the workflow by sending a decision with - `DurableWorkflowClient.send_hitl_response(instance_id, request_id, response)`. -- Reading the final result with `DurableWorkflowClient.await_workflow_output(instance_id)`. - -The workflow is a content-moderation pipeline: - -``` -input_router -> ContentAnalyzerAgent -> content_analyzer_executor - -> human_review_executor (HITL pause) -> publish_executor -``` - -## How HITL Works Here - -The HITL mechanism is host-agnostic — the same shared workflow orchestrator -drives it on both Azure Functions and a standalone worker: - -1. `human_review_executor` calls `ctx.request_info(...)`, which pauses the - workflow. The orchestrator records the open request in its **custom status** - and waits for an external event named by the request's `request_id`. -2. The client reads the custom status via `get_pending_hitl_requests` and sends - a response via `send_hitl_response`, which raises that external event. -3. The orchestrator routes the response back to the executor's - `@response_handler`, and the workflow resumes. - -`send_hitl_response` sanitizes the payload (neutralizing pickle-marker -injection) before delivery, since the worker deserializes it. - -## Environment Setup - -See the [README.md](../README.md) in the parent directory for environment setup. - -This sample uses Microsoft Foundry credentials: - -- `FOUNDRY_PROJECT_ENDPOINT` -- `FOUNDRY_MODEL` - -It also needs a Durable Task Scheduler. For local development, start the -emulator (defaults to `http://localhost:8080`): - -```bash -docker run -d -p 8080:8080 -p 8082:8082 mcr.microsoft.com/dts/dts-emulator:latest -``` - -## Running the Sample - -Start the worker in one terminal: - -```bash -cd samples/04-hosting/durabletask/09_workflow_hitl -python worker.py -``` - -In a second terminal, run the client: - -```bash -python client.py -``` - -The client runs two cases: - -- **Appropriate content** → analyzed → HITL pause → client **approves** → - `"Content '...' has been APPROVED and published."` -- **Spammy content** → analyzed → HITL pause → client **rejects** → - `"Content '...' has been REJECTED."` diff --git a/python/samples/04-hosting/durabletask/09_workflow_hitl/client.py b/python/samples/04-hosting/durabletask/09_workflow_hitl/client.py deleted file mode 100644 index af31d3c354d..00000000000 --- a/python/samples/04-hosting/durabletask/09_workflow_hitl/client.py +++ /dev/null @@ -1,131 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Client that drives the standalone HITL workflow to completion. - -The worker (``worker.py``) must be running first. This client: - -1. Starts the workflow with ``DurableWorkflowClient.start_workflow``. -2. Polls ``get_pending_hitl_requests`` until the workflow pauses for human input. -3. Sends a decision with ``send_hitl_response`` (the request_id correlates the - response back to the paused executor). -4. Reads the final output with ``await_workflow_output``. - -It runs two cases: appropriate content (approved) and spammy content (rejected). - -Prerequisites: -- ``worker.py`` running and connected to the same Durable Task Scheduler. -- A Durable Task Scheduler reachable at ``ENDPOINT`` (default ``http://localhost:8080``). -""" - -import asyncio -import logging -import os -import time -from typing import Any - -from agent_framework.azure import DurableWorkflowClient -from azure.identity import AzureCliCredential -from dotenv import load_dotenv -from durabletask.azuremanaged.client import DurableTaskSchedulerClient - -load_dotenv() - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - -WORKFLOW_NAME = "content_moderation" - - -def get_client(taskhub: str | None = None, endpoint: str | None = None) -> DurableTaskSchedulerClient: - """Create a configured DurableTaskSchedulerClient.""" - taskhub_name = taskhub or os.getenv("TASKHUB", "default") - endpoint_url = endpoint or os.getenv("ENDPOINT", "http://localhost:8080") - - credential = None if endpoint_url == "http://localhost:8080" else AzureCliCredential() - - return DurableTaskSchedulerClient( - host_address=endpoint_url, - secure_channel=endpoint_url != "http://localhost:8080", - taskhub=taskhub_name, - token_credential=credential, - ) - - -def _wait_for_hitl_request( - client: DurableWorkflowClient, instance_id: str, timeout_seconds: int = 60 -) -> list[dict[str, Any]]: - """Poll until the workflow has at least one pending HITL request. - - Stops early if the workflow reaches a terminal state (e.g. completed or failed) - without pausing, so a misconfiguration or early failure surfaces the real - status instead of a misleading timeout. - """ - terminal_statuses = {"COMPLETED", "FAILED", "TERMINATED"} - deadline = time.time() + timeout_seconds - while time.time() < deadline: - pending = client.get_pending_hitl_requests(instance_id) - if pending: - return pending - status = client.get_runtime_status(instance_id) - if status in terminal_statuses: - raise RuntimeError( - f"Workflow instance {instance_id} reached terminal state '{status}' before pausing for human input." - ) - time.sleep(2) - raise TimeoutError(f"Timed out waiting for a HITL request on instance {instance_id}") - - -def run_case(client: DurableWorkflowClient, submission: dict[str, Any], *, approve: bool) -> None: - """Run one moderation case: start, respond to the HITL pause, print the result.""" - instance_id = client.start_workflow(input=submission) - logger.info("Started workflow instance: %s", instance_id) - - pending = _wait_for_hitl_request(client, instance_id) - request = pending[0] - logger.info("Pending HITL request %s from %s", request["request_id"], request["source_executor_id"]) - - decision = { - "approved": approve, - "reviewer_notes": "Looks good." if approve else "Violates content policy.", - } - client.send_hitl_response(instance_id, request["request_id"], decision) - logger.info("Sent decision: approved=%s", approve) - - output = client.await_workflow_output(instance_id) - logger.info("Workflow output: %s", output) - - -async def main() -> None: - """Run an approved case and a rejected case.""" - client = DurableWorkflowClient(get_client(), workflow_name=WORKFLOW_NAME) - - logger.info("CASE 1: Appropriate content (will approve)") - run_case( - client, - { - "content_id": "article-001", - "title": "Introduction to AI in Healthcare", - "body": ( - "Artificial intelligence is improving healthcare by enabling faster diagnosis, " - "personalized treatment plans, and better patient outcomes." - ), - "author": "Dr. Jane Smith", - }, - approve=True, - ) - - logger.info("CASE 2: Spammy content (will reject)") - run_case( - client, - { - "content_id": "article-002", - "title": "Get Rich Quick", - "body": "Click here NOW to make $10,000 overnight! GUARANTEED! Limited time offer!", - "author": "Definitely Not Spam", - }, - approve=False, - ) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/04-hosting/durabletask/09_workflow_hitl/worker.py b/python/samples/04-hosting/durabletask/09_workflow_hitl/worker.py deleted file mode 100644 index 7aad1d621cc..00000000000 --- a/python/samples/04-hosting/durabletask/09_workflow_hitl/worker.py +++ /dev/null @@ -1,344 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Worker that hosts a Human-in-the-Loop (HITL) MAF Workflow on a standalone worker. - -This sample is the durabletask counterpart to the Azure Functions -``12_workflow_hitl`` sample. It runs an agent-framework ``Workflow`` that pauses -for human approval using MAF's ``ctx.request_info`` / ``@response_handler`` -pattern, hosted on a standalone Durable Task worker (no Azure Functions). - -``DurableAIAgentWorker.configure_workflow`` auto-registers: - -- a durable entity for each agent executor, -- a durable activity for each non-agent executor, and -- the workflow orchestrator (named ``dafx-{workflow.name}``). - -When the workflow calls ``ctx.request_info``, the orchestrator pauses and records -the open request in its custom status. An external client discovers the request -(``DurableWorkflowClient.get_pending_hitl_requests``) and resumes the workflow by -sending a response (``DurableWorkflowClient.send_hitl_response``). - -The workflow is a content-moderation pipeline: -``input_router`` -> ``ContentAnalyzerAgent`` -> ``content_analyzer_executor`` --> ``human_review_executor`` (HITL pause) -> ``publish_executor``. - -Prerequisites: -- Set ``FOUNDRY_PROJECT_ENDPOINT`` and ``FOUNDRY_MODEL``. -- Sign in with Azure CLI (``az login``) for ``AzureCliCredential``. -- Start a Durable Task Scheduler (e.g. the DTS emulator on ``localhost:8080``). - -Run the worker (this process), then run ``client.py`` in another process. -""" - -import asyncio -import logging -import os -from dataclasses import dataclass -from typing import Any - -from agent_framework import ( - Agent, - AgentExecutorRequest, - AgentExecutorResponse, - Executor, - Message, - Workflow, - WorkflowBuilder, - WorkflowContext, - handler, - response_handler, -) -from agent_framework.azure import DurableAIAgentWorker -from agent_framework.foundry import FoundryChatClient, FoundryChatOptions -from azure.identity import AzureCliCredential -from azure.identity.aio import AzureCliCredential as AsyncAzureCliCredential -from dotenv import load_dotenv -from durabletask.azuremanaged.worker import DurableTaskSchedulerWorker -from pydantic import BaseModel, ValidationError -from typing_extensions import Never - -load_dotenv() - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - -CONTENT_ANALYZER_AGENT_NAME = "ContentAnalyzerAgent" -WORKFLOW_NAME = "content_moderation" - -CONTENT_ANALYZER_INSTRUCTIONS = ( - "You are a content moderation assistant that analyzes user-submitted content for policy compliance. " - "Evaluate appropriateness, assign a risk level ('low', 'medium', 'high'), list any concerns, and give a " - "brief recommendation for human reviewers. Return JSON with fields is_appropriate (bool), risk_level (str), " - "concerns (list of str), and recommendation (str)." -) - - -# ============================================================================ -# Data Models -# ============================================================================ - - -class ContentAnalysisResult(BaseModel): - """Structured output from the content analysis agent.""" - - is_appropriate: bool - risk_level: str - concerns: list[str] - recommendation: str - - -@dataclass -class ContentSubmission: - """Content submitted for moderation.""" - - content_id: str - title: str - body: str - author: str - - -@dataclass -class AnalysisWithSubmission: - """Combines the AI analysis with the original submission for downstream processing.""" - - submission: ContentSubmission - analysis: ContentAnalysisResult - - -@dataclass -class HumanApprovalRequest: - """Request sent to a human reviewer. Surfaced to clients via the orchestration status.""" - - content_id: str - title: str - body: str - author: str - ai_analysis: ContentAnalysisResult - prompt: str - - -class HumanApprovalResponse(BaseModel): - """Response the external client sends back via the HITL response endpoint/method.""" - - approved: bool - reviewer_notes: str = "" - - -@dataclass -class ModerationResult: - """Final result of the moderation workflow.""" - - content_id: str - status: str - reviewer_notes: str - - -# ============================================================================ -# Executors -# ============================================================================ - - -class InputRouterExecutor(Executor): - """Parses the incoming submission and routes it to the analysis agent.""" - - def __init__(self) -> None: - super().__init__(id="input_router") - - @handler - async def route_input(self, submission: ContentSubmission, ctx: WorkflowContext[AgentExecutorRequest]) -> None: - ctx.set_state("current_submission", submission) - - message = ( - f"Please analyze the following content for policy compliance:\n\n" - f"Title: {submission.title}\n" - f"Author: {submission.author}\n" - f"Content:\n{submission.body}" - ) - await ctx.send_message( - AgentExecutorRequest(messages=[Message(role="user", contents=[message])], should_respond=True) - ) - - -class ContentAnalyzerExecutor(Executor): - """Parses the AI agent's response and forwards it with the original submission.""" - - def __init__(self) -> None: - super().__init__(id="content_analyzer_executor") - - @handler - async def handle_analysis( - self, response: AgentExecutorResponse, ctx: WorkflowContext[AnalysisWithSubmission] - ) -> None: - try: - analysis = ContentAnalysisResult.model_validate_json(response.agent_response.text) - except ValidationError: - analysis = ContentAnalysisResult( - is_appropriate=False, - risk_level="high", - concerns=["Agent execution failed or yielded invalid JSON."], - recommendation="Manual review required", - ) - - submission: ContentSubmission = ctx.get_state("current_submission") - await ctx.send_message(AnalysisWithSubmission(submission=submission, analysis=analysis)) - - -class HumanReviewExecutor(Executor): - """Requests human approval using MAF's request_info / response_handler pattern.""" - - def __init__(self) -> None: - super().__init__(id="human_review_executor") - - @handler - async def request_review(self, data: AnalysisWithSubmission, ctx: WorkflowContext) -> None: - submission = data.submission - analysis = data.analysis - - prompt = ( - f"Please review the following content for publication:\n\n" - f"Title: {submission.title}\n" - f"Author: {submission.author}\n" - f"Content: {submission.body}\n\n" - f"AI Analysis:\n" - f"- Appropriate: {analysis.is_appropriate}\n" - f"- Risk Level: {analysis.risk_level}\n" - f"- Concerns: {', '.join(analysis.concerns) if analysis.concerns else 'None'}\n" - f"- Recommendation: {analysis.recommendation}\n\n" - f"Please approve or reject this content." - ) - approval_request = HumanApprovalRequest( - content_id=submission.content_id, - title=submission.title, - body=submission.body, - author=submission.author, - ai_analysis=analysis, - prompt=prompt, - ) - - # Pause the workflow and wait for a human response. - await ctx.request_info(request_data=approval_request, response_type=HumanApprovalResponse) - - @response_handler - async def handle_approval_response( - self, - original_request: HumanApprovalRequest, - response: HumanApprovalResponse, - ctx: WorkflowContext[ModerationResult], - ) -> None: - logger.info( - "Human review received for content %s: approved=%s", - original_request.content_id, - response.approved, - ) - await ctx.send_message( - ModerationResult( - content_id=original_request.content_id, - status="approved" if response.approved else "rejected", - reviewer_notes=response.reviewer_notes, - ) - ) - - -class PublishExecutor(Executor): - """Finalizes publication or rejection of the content.""" - - def __init__(self) -> None: - super().__init__(id="publish_executor") - - @handler - async def handle_result(self, result: ModerationResult, ctx: WorkflowContext[Never, str]) -> None: - if result.status == "approved": - message = ( - f"Content '{result.content_id}' has been APPROVED and published. " - f"Reviewer notes: {result.reviewer_notes or 'None'}" - ) - else: - message = ( - f"Content '{result.content_id}' has been REJECTED. Reviewer notes: {result.reviewer_notes or 'None'}" - ) - logger.info(message) - await ctx.yield_output(message) - - -def _create_chat_client() -> FoundryChatClient: - """Create a Microsoft Foundry chat client using AzureCliCredential.""" - return FoundryChatClient( - project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], - model=os.environ["FOUNDRY_MODEL"], - credential=AsyncAzureCliCredential(), - ) - - -def create_workflow() -> Workflow: - """Build the content-moderation workflow with a human-in-the-loop pause.""" - chat_client = _create_chat_client() - - content_analyzer_agent = Agent( - client=chat_client, - name=CONTENT_ANALYZER_AGENT_NAME, - instructions=CONTENT_ANALYZER_INSTRUCTIONS, - default_options=FoundryChatOptions[Any](response_format=ContentAnalysisResult), - ) - - input_router = InputRouterExecutor() - content_analyzer_executor = ContentAnalyzerExecutor() - human_review_executor = HumanReviewExecutor() - publish_executor = PublishExecutor() - - return ( - WorkflowBuilder(name=WORKFLOW_NAME, start_executor=input_router) - .add_edge(input_router, content_analyzer_agent) - .add_edge(content_analyzer_agent, content_analyzer_executor) - .add_edge(content_analyzer_executor, human_review_executor) - .add_edge(human_review_executor, publish_executor) - .build() - ) - - -def get_worker( - taskhub: str | None = None, endpoint: str | None = None, log_handler: logging.Handler | None = None -) -> DurableTaskSchedulerWorker: - """Create a configured DurableTaskSchedulerWorker.""" - taskhub_name = taskhub or os.getenv("TASKHUB", "default") - endpoint_url = endpoint or os.getenv("ENDPOINT", "http://localhost:8080") - - credential = None if endpoint_url == "http://localhost:8080" else AzureCliCredential() - - return DurableTaskSchedulerWorker( - host_address=endpoint_url, - secure_channel=endpoint_url != "http://localhost:8080", - taskhub=taskhub_name, - token_credential=credential, - log_handler=log_handler, - ) - - -def setup_worker(worker: DurableTaskSchedulerWorker) -> DurableAIAgentWorker: - """Register the workflow (agents + activities + orchestrator) on the worker.""" - agent_worker = DurableAIAgentWorker(worker) - - workflow = create_workflow() - agent_worker.configure_workflow(workflow) - logger.info("✓ Configured HITL workflow with %d executors", len(workflow.executors)) - - return agent_worker - - -async def main() -> None: - """Start the worker and block until interrupted.""" - worker = get_worker() - setup_worker(worker) - - logger.info("Worker is ready and listening for work items. Press Ctrl+C to stop.") - try: - worker.start() - while True: - await asyncio.sleep(1) - except KeyboardInterrupt: - logger.info("Worker shutdown initiated") - - logger.info("Worker stopped") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/04-hosting/durabletask/10_workflow_streaming/README.md b/python/samples/04-hosting/durabletask/10_workflow_streaming/README.md deleted file mode 100644 index 2871773f5e8..00000000000 --- a/python/samples/04-hosting/durabletask/10_workflow_streaming/README.md +++ /dev/null @@ -1,72 +0,0 @@ -# Streaming Workflow Progress on a Standalone Durable Task Worker - -This sample demonstrates **streaming a durable workflow's progress** from a -standalone Durable Task worker — no Azure Functions required. It is the -streaming counterpart to [`08_workflow`](../08_workflow/README.md). - -## Key Concepts Demonstrated - -- The async `DurableWorkflowClient` API: - - `run_workflow(input, wait=False)` — start a workflow without blocking and get - its instance id. - - `stream_workflow(instance_id)` — an async iterator that yields typed - `WorkflowEvent` objects (`executor_invoked` / `executor_completed` / `output` - / ...) as the workflow progresses, ending when it reaches a terminal state. - Each event's `data` is already reconstructed into its original typed object, - so the client never deserializes anything by hand. - - `await_workflow_output(instance_id)` — read the final reconstructed output. -- **Brokerless streaming.** The orchestrator publishes accumulated events to the - orchestration **custom status** after each superstep (only on live execution, - not replay), and the client streams them by polling. No Redis or other message - broker is required. -- **Per-executor granularity.** Events fire per executor and per yielded output, - not at the token level. Non-agent executors carry their captured event data; - agent executors surface coarse `executor_invoked` / `executor_completed` - lifecycle events. (Token-level streaming through a durable boundary would - require an external broker.) - -## Environment Setup - -See the [README.md](../README.md) in the parent directory for environment setup. - -This sample uses Microsoft Foundry credentials: - -- `FOUNDRY_PROJECT_ENDPOINT` -- `FOUNDRY_MODEL` - -It also needs a Durable Task Scheduler. For local development, start the -emulator (defaults to `http://localhost:8080`): - -```bash -docker run -d -p 8080:8080 -p 8082:8082 mcr.microsoft.com/dts/dts-emulator:latest -``` - -## Running the Sample - -Start the worker in one terminal: - -```bash -cd samples/04-hosting/durabletask/10_workflow_streaming -python worker.py -``` - -In a second terminal, run the client: - -```bash -python client.py -``` - -The workflow is a linear pipeline — `WriterAgent` → `ReviewerAgent` → `publish` — -so the client prints the progress events as each executor runs, for example: - -```text -Streaming workflow events: - [executor_invoked] WriterAgent - [executor_completed] WriterAgent - [executor_invoked] ReviewerAgent - [executor_completed] ReviewerAgent - [executor_invoked] publish - [executor_completed] publish - [output] from publish: Published: ... -Final output: Published: ... -``` diff --git a/python/samples/04-hosting/durabletask/10_workflow_streaming/client.py b/python/samples/04-hosting/durabletask/10_workflow_streaming/client.py deleted file mode 100644 index ae8d2ae3646..00000000000 --- a/python/samples/04-hosting/durabletask/10_workflow_streaming/client.py +++ /dev/null @@ -1,74 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Client that starts the workflow and streams its progress event-by-event. - -The worker (``worker.py``) must be running first. This client demonstrates the -async ``DurableWorkflowClient`` API: - -1. ``run_workflow(input, wait=False)`` starts the workflow and returns its - instance id without blocking. -2. ``stream_workflow(instance_id)`` yields typed ``WorkflowEvent`` objects - (``executor_invoked`` / ``executor_completed`` / ``output`` / ...) as the - workflow progresses, by polling the orchestration custom status. This is - brokerless; each event's ``data`` is already reconstructed into its original - typed object, so the client never deserializes anything by hand. Granularity - is per executor / per yielded output, not token-level. -3. ``await_workflow_output(...)`` returns the final reconstructed output. - -Prerequisites: -- ``worker.py`` running and connected to the same Durable Task Scheduler. -- A Durable Task Scheduler reachable at ``ENDPOINT`` (default ``http://localhost:8080``). -""" - -import asyncio -import logging -import os - -from agent_framework.azure import DurableWorkflowClient -from azure.identity import AzureCliCredential -from dotenv import load_dotenv -from durabletask.azuremanaged.client import DurableTaskSchedulerClient - -load_dotenv() - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - - -def get_client(taskhub: str | None = None, endpoint: str | None = None) -> DurableTaskSchedulerClient: - """Create a configured DurableTaskSchedulerClient.""" - taskhub_name = taskhub or os.getenv("TASKHUB", "default") - endpoint_url = endpoint or os.getenv("ENDPOINT", "http://localhost:8080") - - credential = None if endpoint_url == "http://localhost:8080" else AzureCliCredential() - - return DurableTaskSchedulerClient( - host_address=endpoint_url, - secure_channel=endpoint_url != "http://localhost:8080", - taskhub=taskhub_name, - token_credential=credential, - ) - - -async def main() -> None: - """Start a workflow and stream its typed progress events to the console.""" - client = DurableWorkflowClient(get_client()) - - # Start without waiting so we can stream progress as it happens. - instance_id = await client.run_workflow(input="Write a short note about durable workflows.", wait=False) - logger.info("Started workflow instance: %s", instance_id) - - logger.info("Streaming workflow events:") - async for event in client.stream_workflow(instance_id, poll_interval_seconds=1.0): - if event.type == "output": - logger.info(" [output] from %s: %s", event.executor_id, event.data) - else: - logger.info(" [%s] %s", event.type, event.executor_id) - - # The stream ends when the workflow reaches a terminal state; read the result. - output = await client.await_workflow_output(instance_id) - logger.info("Final output: %s", output) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/04-hosting/durabletask/10_workflow_streaming/worker.py b/python/samples/04-hosting/durabletask/10_workflow_streaming/worker.py deleted file mode 100644 index 953b6e9dadd..00000000000 --- a/python/samples/04-hosting/durabletask/10_workflow_streaming/worker.py +++ /dev/null @@ -1,142 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Worker that hosts a multi-step MAF Workflow for streaming (no Azure Functions). - -This sample is the streaming counterpart to ``08_workflow``. It hosts a simple -linear content pipeline so a client can watch progress event-by-event: - -``writer`` (agent) -> ``reviewer`` (agent) -> ``publish`` (non-agent executor) - -``DurableAIAgentWorker.configure_workflow`` auto-registers a durable entity for -each agent executor, a durable activity for each non-agent executor, and the -workflow orchestrator. As each executor runs, the orchestrator publishes coarse -workflow events (``executor_invoked`` / ``executor_completed`` / ``output``) to -the orchestration custom status, which the client streams by polling. - -Prerequisites: -- Set ``FOUNDRY_PROJECT_ENDPOINT`` and ``FOUNDRY_MODEL``. -- Sign in with Azure CLI (``az login``) for ``AzureCliCredential``. -- Start a Durable Task Scheduler (e.g. the DTS emulator on ``localhost:8080``). - -Run the worker (this process), then run ``client.py`` in another process. -""" - -import asyncio -import logging -import os - -from agent_framework import ( - Agent, - AgentExecutorResponse, - Executor, - Workflow, - WorkflowBuilder, - WorkflowContext, - handler, -) -from agent_framework.azure import DurableAIAgentWorker -from agent_framework.foundry import FoundryChatClient -from azure.identity import AzureCliCredential -from azure.identity.aio import AzureCliCredential as AsyncAzureCliCredential -from dotenv import load_dotenv -from durabletask.azuremanaged.worker import DurableTaskSchedulerWorker -from typing_extensions import Never - -load_dotenv() - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - -WRITER_AGENT_NAME = "WriterAgent" -REVIEWER_AGENT_NAME = "ReviewerAgent" - -WRITER_INSTRUCTIONS = ( - "You are a concise technical writer. Write a short, single-paragraph draft on the requested topic." -) -REVIEWER_INSTRUCTIONS = ( - "You are an editor. Improve the draft you receive for clarity and tone, " - "and return only the improved single-paragraph version." -) - - -class PublishExecutor(Executor): - """Non-agent executor that 'publishes' the reviewed draft as the final output.""" - - @handler - async def publish(self, agent_response: AgentExecutorResponse, ctx: WorkflowContext[Never, str]) -> None: - reviewed_text = agent_response.agent_response.text - await ctx.yield_output(f"Published:\n{reviewed_text}") - - -def _create_chat_client() -> FoundryChatClient: - """Create a Microsoft Foundry chat client using AzureCliCredential.""" - return FoundryChatClient( - project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], - model=os.environ["FOUNDRY_MODEL"], - credential=AsyncAzureCliCredential(), - ) - - -def create_workflow() -> Workflow: - """Build the linear writer -> reviewer -> publish pipeline.""" - chat_client = _create_chat_client() - - writer_agent = Agent(client=chat_client, name=WRITER_AGENT_NAME, instructions=WRITER_INSTRUCTIONS) - reviewer_agent = Agent(client=chat_client, name=REVIEWER_AGENT_NAME, instructions=REVIEWER_INSTRUCTIONS) - publish = PublishExecutor(id="publish") - - return ( - WorkflowBuilder(start_executor=writer_agent) - .add_edge(writer_agent, reviewer_agent) - .add_edge(reviewer_agent, publish) - .build() - ) - - -def get_worker( - taskhub: str | None = None, endpoint: str | None = None, log_handler: logging.Handler | None = None -) -> DurableTaskSchedulerWorker: - """Create a configured DurableTaskSchedulerWorker.""" - taskhub_name = taskhub or os.getenv("TASKHUB", "default") - endpoint_url = endpoint or os.getenv("ENDPOINT", "http://localhost:8080") - - credential = None if endpoint_url == "http://localhost:8080" else AzureCliCredential() - - return DurableTaskSchedulerWorker( - host_address=endpoint_url, - secure_channel=endpoint_url != "http://localhost:8080", - taskhub=taskhub_name, - token_credential=credential, - log_handler=log_handler, - ) - - -def setup_worker(worker: DurableTaskSchedulerWorker) -> DurableAIAgentWorker: - """Register the workflow (agents + activities + orchestrator) on the worker.""" - agent_worker = DurableAIAgentWorker(worker) - - workflow = create_workflow() - agent_worker.configure_workflow(workflow) - logger.info("✓ Configured streaming workflow with %d executors", len(workflow.executors)) - - return agent_worker - - -async def main() -> None: - """Start the worker and block until interrupted.""" - worker = get_worker() - setup_worker(worker) - - logger.info("Worker is ready and listening for work items. Press Ctrl+C to stop.") - try: - worker.start() - while True: # noqa: ASYNC110 - await asyncio.sleep(1) - except KeyboardInterrupt: - logger.info("Worker shutdown initiated") - - logger.info("Worker stopped") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/04-hosting/durabletask/11_subworkflow/README.md b/python/samples/04-hosting/durabletask/11_subworkflow/README.md deleted file mode 100644 index dc4c84767e2..00000000000 --- a/python/samples/04-hosting/durabletask/11_subworkflow/README.md +++ /dev/null @@ -1,69 +0,0 @@ -# Composed Workflow (Sub-Workflow) on a Standalone Durable Task Worker - -This sample demonstrates **workflow composition** on a standalone Durable Task -worker: an inner agent-framework `Workflow` is embedded as a node inside an outer -`Workflow` using `WorkflowExecutor`. On the durable host, the inner workflow runs -as its own durable **child orchestration**. - -## Key Concepts Demonstrated - -- Embedding one `Workflow` inside another with - `WorkflowExecutor(inner_workflow, id=...)`. -- A single `DurableAIAgentWorker.configure_workflow(outer_workflow)` call walks the - composition and auto-registers a durable orchestration for **each** workflow: - - `dafx-review_pipeline` — the outer workflow. - - `dafx-sentiment_analysis` — the inner workflow, run as a durable **child - orchestration** when the outer workflow reaches the `WorkflowExecutor` node. -- Per-workflow scoping: each workflow's agent executors become durable entities and - its non-agent executors become durable activities, named per workflow so the same - executor id in two workflows never collides. -- Output forwarding: the inner workflow yields a string and, because - `allow_direct_output` is left at its default (`False`), that output is forwarded to - the outer workflow as a message delivered to the `reporter` executor. - -## Composition Layout - -```text -review_pipeline (outer) - intake (executor) - -> sentiment_sub = WorkflowExecutor(sentiment_analysis) - sentiment_agent (agent) -> sentiment_formatter (executor) - -> reporter (executor) -``` - -## Environment Setup - -See the [README.md](../README.md) in the parent directory for environment setup. - -This sample uses Microsoft Foundry credentials: - -- `FOUNDRY_PROJECT_ENDPOINT` -- `FOUNDRY_MODEL` - -It also needs a Durable Task Scheduler. For local development, start the -emulator (defaults to `http://localhost:8080`): - -```bash -docker run -d -p 8080:8080 -p 8082:8082 mcr.microsoft.com/dts/dts-emulator:latest -``` - -## Running the Sample - -Start the worker in one terminal: - -```bash -cd samples/04-hosting/durabletask/11_subworkflow -python worker.py -``` - -In a second terminal, run the client: - -```bash -python client.py -``` - -The client targets only the outer workflow (`review_pipeline`); the sub-workflow -runs automatically as a child orchestration. Each review flows: - -`intake` → `sentiment_sub` (child orchestration: `sentiment_agent` → -`sentiment_formatter`) → `reporter` → `"Review analysis complete -> sentiment: ..."`. diff --git a/python/samples/04-hosting/durabletask/11_subworkflow/client.py b/python/samples/04-hosting/durabletask/11_subworkflow/client.py deleted file mode 100644 index ced423cd3e6..00000000000 --- a/python/samples/04-hosting/durabletask/11_subworkflow/client.py +++ /dev/null @@ -1,78 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Client that starts the composed workflow orchestration and prints the result. - -The worker (``worker.py``) must be running first. Only the *outer* workflow is -started by the client; its embedded sub-workflow runs automatically as a durable -child orchestration when the outer workflow reaches the ``WorkflowExecutor`` node. - -The workflow is started via ``DurableWorkflowClient.start_workflow`` - which -schedules the ``dafx-review_pipeline`` orchestration that -``DurableAIAgentWorker.configure_workflow`` auto-registers for the outer workflow. - -Prerequisites: -- ``worker.py`` running and connected to the same Durable Task Scheduler. -- A Durable Task Scheduler reachable at ``ENDPOINT`` (default ``http://localhost:8080``). -""" - -import asyncio -import logging -import os - -from agent_framework.azure import DurableWorkflowClient -from azure.identity import AzureCliCredential -from dotenv import load_dotenv -from durabletask.azuremanaged.client import DurableTaskSchedulerClient - -load_dotenv() - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - -# The client targets the outer workflow; the sub-workflow runs as a child orchestration. -WORKFLOW_NAME = "review_pipeline" - - -def get_client(taskhub: str | None = None, endpoint: str | None = None) -> DurableTaskSchedulerClient: - """Create a configured DurableTaskSchedulerClient.""" - taskhub_name = taskhub or os.getenv("TASKHUB", "default") - endpoint_url = endpoint or os.getenv("ENDPOINT", "http://localhost:8080") - - credential = None if endpoint_url == "http://localhost:8080" else AzureCliCredential() - - return DurableTaskSchedulerClient( - host_address=endpoint_url, - secure_channel=endpoint_url != "http://localhost:8080", - taskhub=taskhub_name, - token_credential=credential, - ) - - -def run_workflow(client: DurableWorkflowClient, review: str) -> None: - """Start the outer workflow with a review and wait for the result.""" - instance_id = client.start_workflow(input=review) - logger.info("Started workflow instance: %s", instance_id) - - output = client.await_workflow_output(instance_id) - logger.info("Workflow output: %s", output) - - -async def main() -> None: - """Run the composed workflow against a couple of product reviews.""" - client = DurableWorkflowClient(get_client(), workflow_name=WORKFLOW_NAME) - - logger.info("TEST 1: Positive review") - run_workflow( - client, - "Absolutely love this espresso machine - it heats up fast and the coffee is consistently great.", - ) - - logger.info("TEST 2: Negative review") - run_workflow( - client, - "Disappointed. The device stopped working after two weeks and support never replied.", - ) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/04-hosting/durabletask/11_subworkflow/worker.py b/python/samples/04-hosting/durabletask/11_subworkflow/worker.py deleted file mode 100644 index 05e41e0a1a1..00000000000 --- a/python/samples/04-hosting/durabletask/11_subworkflow/worker.py +++ /dev/null @@ -1,211 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Worker that hosts a MAF Workflow composed of a nested sub-workflow. - -This sample shows workflow *composition* on the Durable Task host. A -``WorkflowExecutor`` embeds an inner workflow as a node inside an outer workflow. -``DurableAIAgentWorker.configure_workflow`` walks the composition and -auto-registers a durable orchestration for *each* workflow: - -- ``dafx-sentiment_analysis`` - the inner workflow, run as a durable **child - orchestration** whenever the outer workflow reaches the ``WorkflowExecutor`` node. -- ``dafx-review_pipeline`` - the outer workflow. - -Each workflow's agent executors become durable entities and its non-agent -executors become durable activities, scoped per workflow so the same executor id -in two workflows never collides. - -Composition layout:: - - review_pipeline (outer) - intake (executor) - -> sentiment_sub = WorkflowExecutor(sentiment_analysis) - sentiment_agent (agent) -> sentiment_formatter (executor) - -> reporter (executor) - -The inner workflow yields a string; because ``allow_direct_output`` is left at its -default (``False``), that output is forwarded to the outer workflow as a message -delivered to ``reporter``, which produces the final result. - -Prerequisites: -- Set ``FOUNDRY_PROJECT_ENDPOINT`` and ``FOUNDRY_MODEL``. -- Sign in with Azure CLI (``az login``) for ``AzureCliCredential``. -- Start a Durable Task Scheduler (e.g. the DTS emulator on ``localhost:8080``). - -Run the worker (this process), then run ``client.py`` in another process. -""" - -import asyncio -import logging -import os -from typing import Any - -from agent_framework import ( - Agent, - AgentExecutorResponse, - Executor, - Workflow, - WorkflowBuilder, - WorkflowContext, - WorkflowExecutor, - handler, -) -from agent_framework.azure import DurableAIAgentWorker -from agent_framework.foundry import FoundryChatClient, FoundryChatOptions -from azure.identity import AzureCliCredential -from azure.identity.aio import AzureCliCredential as AsyncAzureCliCredential -from dotenv import load_dotenv -from durabletask.azuremanaged.worker import DurableTaskSchedulerWorker -from pydantic import BaseModel, ValidationError -from typing_extensions import Never - -load_dotenv() - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - -SENTIMENT_AGENT_NAME = "SentimentAgent" -INNER_WORKFLOW_NAME = "sentiment_analysis" -OUTER_WORKFLOW_NAME = "review_pipeline" - -SENTIMENT_INSTRUCTIONS = ( - "You classify the sentiment of a customer product review. " - "Return JSON with fields sentiment (one of 'positive', 'neutral', 'negative') " - "and confidence (a number between 0 and 1)." -) - - -class SentimentResult(BaseModel): - """Structured output from the sentiment agent.""" - - sentiment: str - confidence: float - - -class SentimentFormatterExecutor(Executor): - """Inner-workflow executor that turns the agent's JSON into a summary line.""" - - @handler - async def format_sentiment(self, agent_response: AgentExecutorResponse, ctx: WorkflowContext[Never, str]) -> None: - text = agent_response.agent_response.text - try: - result = SentimentResult.model_validate_json(text) - summary = f"{result.sentiment} (confidence {result.confidence:.0%})" - except ValidationError: - summary = "unknown (could not parse sentiment)" - await ctx.yield_output(summary) - - -class IntakeExecutor(Executor): - """Outer-workflow entry point that normalizes the review before analysis.""" - - @handler - async def intake(self, review: str, ctx: WorkflowContext[str]) -> None: - normalized = review.strip() - logger.info("Intake received review (%d chars)", len(normalized)) - await ctx.send_message(normalized) - - -class ReporterExecutor(Executor): - """Outer-workflow executor that consumes the sub-workflow's forwarded output.""" - - @handler - async def report(self, sentiment_summary: str, ctx: WorkflowContext[Never, str]) -> None: - await ctx.yield_output(f"Review analysis complete -> sentiment: {sentiment_summary}") - - -def _create_chat_client() -> FoundryChatClient: - """Create a Microsoft Foundry chat client using AzureCliCredential.""" - return FoundryChatClient( - project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], - model=os.environ["FOUNDRY_MODEL"], - credential=AsyncAzureCliCredential(), - ) - - -def create_inner_workflow(chat_client: FoundryChatClient) -> Workflow: - """Build the inner ``sentiment_analysis`` workflow (agent -> formatter).""" - sentiment_agent = Agent( - client=chat_client, - name=SENTIMENT_AGENT_NAME, - instructions=SENTIMENT_INSTRUCTIONS, - default_options=FoundryChatOptions[Any](response_format=SentimentResult), - ) - sentiment_formatter = SentimentFormatterExecutor(id="sentiment_formatter") - - return ( - WorkflowBuilder(name=INNER_WORKFLOW_NAME, start_executor=sentiment_agent) - .add_edge(sentiment_agent, sentiment_formatter) - .build() - ) - - -def create_workflow() -> Workflow: - """Build the outer ``review_pipeline`` workflow that embeds the inner workflow.""" - chat_client = _create_chat_client() - inner_workflow = create_inner_workflow(chat_client) - - intake = IntakeExecutor(id="intake") - # WorkflowExecutor embeds the inner workflow as a single node in the outer - # workflow. On the durable host this node runs as a child orchestration. - sentiment_sub = WorkflowExecutor(inner_workflow, id="sentiment_sub") - reporter = ReporterExecutor(id="reporter") - - return ( - WorkflowBuilder(name=OUTER_WORKFLOW_NAME, start_executor=intake) - .add_edge(intake, sentiment_sub) - .add_edge(sentiment_sub, reporter) - .build() - ) - - -def get_worker( - taskhub: str | None = None, endpoint: str | None = None, log_handler: logging.Handler | None = None -) -> DurableTaskSchedulerWorker: - """Create a configured DurableTaskSchedulerWorker.""" - taskhub_name = taskhub or os.getenv("TASKHUB", "default") - endpoint_url = endpoint or os.getenv("ENDPOINT", "http://localhost:8080") - - credential = None if endpoint_url == "http://localhost:8080" else AzureCliCredential() - - return DurableTaskSchedulerWorker( - host_address=endpoint_url, - secure_channel=endpoint_url != "http://localhost:8080", - taskhub=taskhub_name, - token_credential=credential, - log_handler=log_handler, - ) - - -def setup_worker(worker: DurableTaskSchedulerWorker) -> DurableAIAgentWorker: - """Register the outer workflow and its nested sub-workflow on the worker.""" - agent_worker = DurableAIAgentWorker(worker) - - workflow = create_workflow() - # A single call walks the composition: it registers the outer workflow plus - # every nested sub-workflow (here, sentiment_analysis) as its own durable - # orchestration, deduped by workflow name. - agent_worker.configure_workflow(workflow) - logger.info("✓ Configured workflow '%s' with embedded sub-workflow '%s'", OUTER_WORKFLOW_NAME, INNER_WORKFLOW_NAME) - - return agent_worker - - -async def main() -> None: - """Start the worker and block until interrupted.""" - worker = get_worker() - setup_worker(worker) - - logger.info("Worker is ready and listening for work items. Press Ctrl+C to stop.") - try: - worker.start() - while True: - await asyncio.sleep(1) - except KeyboardInterrupt: - logger.info("Worker shutdown initiated") - - logger.info("Worker stopped") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/04-hosting/durabletask/12_subworkflow_hitl/README.md b/python/samples/04-hosting/durabletask/12_subworkflow_hitl/README.md deleted file mode 100644 index 0a2a8a76a5c..00000000000 --- a/python/samples/04-hosting/durabletask/12_subworkflow_hitl/README.md +++ /dev/null @@ -1,63 +0,0 @@ -# Human-in-the-Loop in a Sub-Workflow (Durable Task Worker) - -This sample combines **workflow composition** (`11_subworkflow`) with -**human-in-the-loop** (`09_workflow_hitl`): the HITL `request_info` pause lives -**inside an inner workflow** that an outer workflow embeds via `WorkflowExecutor`. - -On the durable host the inner workflow runs as its own **child orchestration**, so -its pending request is recorded on the *child* instance. The parent records the -child instance id in its custom status, which lets the client discover the nested -request behind a **single top-level addressing surface**. - -## Key Concepts Demonstrated - -- A HITL pause (`ctx.request_info` / `@response_handler`) inside a sub-workflow. -- `DurableAIAgentWorker.configure_workflow(outer_workflow)` registers a durable - orchestration for each workflow: - - `dafx-moderation_pipeline` — the outer workflow. - - `dafx-human_review` — the inner (HITL) workflow, run as a child orchestration. -- **Qualified request ids:** the nested request surfaces to the client with a - qualified id (`review_sub~0~{requestId}`). The client posts the response against the - *top-level* instance id, and the host routes it to the owning child orchestration — - so the caller never has to discover child instance ids. - -## Composition Layout - -```text -moderation_pipeline (outer) - intake (executor) - -> review_sub = WorkflowExecutor(human_review) - review_gate (executor: request_info -> response_handler) - -> publish (executor) -``` - -## Environment Setup - -See the [README.md](../README.md) in the parent directory for environment setup. - -This sample uses **no AI agents**, so no model credentials are required. It only -needs a Durable Task Scheduler. For local development, start the emulator (defaults -to `http://localhost:8080`): - -```bash -docker run -d -p 8080:8080 -p 8082:8082 mcr.microsoft.com/dts/dts-emulator:latest -``` - -## Running the Sample - -Start the worker in one terminal: - -```bash -cd samples/04-hosting/durabletask/12_subworkflow_hitl -python worker.py -``` - -In a second terminal, run the client: - -```bash -python client.py -``` - -Each case flows: `intake` → `review_sub` (child orchestration pauses at -`review_gate`) → client responds to the qualified request → `review_gate` resumes → -inner decision forwarded to `publish` → final output. diff --git a/python/samples/04-hosting/durabletask/12_subworkflow_hitl/client.py b/python/samples/04-hosting/durabletask/12_subworkflow_hitl/client.py deleted file mode 100644 index aeb8a9cf7b4..00000000000 --- a/python/samples/04-hosting/durabletask/12_subworkflow_hitl/client.py +++ /dev/null @@ -1,134 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Client that drives the composed HITL workflow, responding to a nested sub-workflow request. - -The worker (``worker.py``) must be running first. This client: - -1. Starts the *outer* workflow with ``DurableWorkflowClient.start_workflow``. -2. Polls ``get_pending_hitl_requests`` until a request appears. Because the HITL pause - happens inside a sub-workflow, the request surfaces with a **qualified** request id - (``review_sub~0~{requestId}``). -3. Sends the decision with ``send_hitl_response`` against the *top-level* instance id and - the qualified request id; the host routes it to the owning child orchestration. -4. Reads the final output with ``await_workflow_output``. - -It runs two cases: approved content and rejected content. - -Prerequisites: -- ``worker.py`` running and connected to the same Durable Task Scheduler. -- A Durable Task Scheduler reachable at ``ENDPOINT`` (default ``http://localhost:8080``). -""" - -import asyncio -import logging -import os -import time -from typing import Any - -from agent_framework.azure import DurableWorkflowClient -from azure.identity import AzureCliCredential -from dotenv import load_dotenv -from durabletask.azuremanaged.client import DurableTaskSchedulerClient - -load_dotenv() - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - -# The client targets the outer workflow; the HITL pause lives in the sub-workflow. -WORKFLOW_NAME = "moderation_pipeline" - - -def get_client(taskhub: str | None = None, endpoint: str | None = None) -> DurableTaskSchedulerClient: - """Create a configured DurableTaskSchedulerClient.""" - taskhub_name = taskhub or os.getenv("TASKHUB", "default") - endpoint_url = endpoint or os.getenv("ENDPOINT", "http://localhost:8080") - - credential = None if endpoint_url == "http://localhost:8080" else AzureCliCredential() - - return DurableTaskSchedulerClient( - host_address=endpoint_url, - secure_channel=endpoint_url != "http://localhost:8080", - taskhub=taskhub_name, - token_credential=credential, - ) - - -def _wait_for_hitl_request( - client: DurableWorkflowClient, instance_id: str, timeout_seconds: int = 60 -) -> list[dict[str, Any]]: - """Poll until the workflow (or one of its sub-workflows) has a pending HITL request. - - Stops early if the workflow reaches a terminal state without pausing, so a - misconfiguration surfaces the real status instead of a misleading timeout. - """ - terminal_statuses = {"COMPLETED", "FAILED", "TERMINATED"} - deadline = time.time() + timeout_seconds - while time.time() < deadline: - pending = client.get_pending_hitl_requests(instance_id) - if pending: - return pending - status = client.get_runtime_status(instance_id) - if status in terminal_statuses: - raise RuntimeError( - f"Workflow instance {instance_id} reached terminal state '{status}' before pausing for human input." - ) - time.sleep(2) - raise TimeoutError(f"Timed out waiting for a HITL request on instance {instance_id}") - - -def run_case(client: DurableWorkflowClient, submission: dict[str, Any], *, approve: bool) -> None: - """Run one moderation case: start, respond to the nested HITL pause, print the result.""" - instance_id = client.start_workflow(input=submission) - logger.info("Started workflow instance: %s", instance_id) - - pending = _wait_for_hitl_request(client, instance_id) - request = pending[0] - # The request id is qualified (e.g. "review_sub~0~") because the pause lives - # in a sub-workflow. We pass it back verbatim against the top-level instance id; - # the host resolves it to the owning child orchestration. - logger.info("Pending HITL request %s from %s", request["request_id"], request["source_executor_id"]) - - decision = { - "approved": approve, - "reviewer_notes": "Looks good." if approve else "Violates content policy.", - } - client.send_hitl_response(instance_id, request["request_id"], decision) - logger.info("Sent decision: approved=%s", approve) - - output = client.await_workflow_output(instance_id) - logger.info("Workflow output: %s", output) - - -async def main() -> None: - """Run an approved case and a rejected case.""" - client = DurableWorkflowClient(get_client(), workflow_name=WORKFLOW_NAME) - - logger.info("CASE 1: Appropriate content (will approve)") - run_case( - client, - { - "content_id": "article-001", - "title": "Introduction to AI in Healthcare", - "body": ( - "Artificial intelligence is improving healthcare by enabling faster diagnosis, " - "personalized treatment plans, and better patient outcomes." - ), - }, - approve=True, - ) - - logger.info("CASE 2: Spammy content (will reject)") - run_case( - client, - { - "content_id": "article-002", - "title": "Get Rich Quick", - "body": "Click here NOW to make $10,000 overnight! GUARANTEED! Limited time offer!", - }, - approve=False, - ) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/04-hosting/durabletask/12_subworkflow_hitl/worker.py b/python/samples/04-hosting/durabletask/12_subworkflow_hitl/worker.py deleted file mode 100644 index 8c4b7bc4b6c..00000000000 --- a/python/samples/04-hosting/durabletask/12_subworkflow_hitl/worker.py +++ /dev/null @@ -1,274 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Worker hosting a composed workflow whose Human-in-the-Loop pause lives in a sub-workflow. - -This sample combines composition (``11_subworkflow``) with human-in-the-loop -(``09_workflow_hitl``): the HITL ``request_info`` happens **inside an inner -workflow** that an outer workflow embeds via ``WorkflowExecutor``. On the durable -host the inner workflow runs as its own child orchestration, so its pending request -is recorded on the *child* instance. The parent records the child instance id in its -custom status, which lets the client discover the nested request behind a single -top-level addressing surface. - -``DurableAIAgentWorker.configure_workflow`` walks the composition and registers a -durable orchestration for each workflow: - -- ``dafx-moderation_pipeline`` - the outer workflow. -- ``dafx-human_review`` - the inner workflow (run as a child orchestration), which - contains the HITL pause. - -Composition layout:: - - moderation_pipeline (outer) - intake (executor) - -> review_sub = WorkflowExecutor(human_review) - review_gate (executor: request_info -> response_handler) - -> publish (executor) - -The client sees the inner pending request with a **qualified** request id -(``review_sub~0~{requestId}``) and posts the response back to the *top-level* -instance; the host routes it to the owning child orchestration automatically. - -Prerequisites: -- Start a Durable Task Scheduler (e.g. the DTS emulator on ``localhost:8080``). - (This sample uses no AI agents, so no model credentials are required.) - -Run the worker (this process), then run ``client.py`` in another process. -""" - -import asyncio -import logging -import os -from dataclasses import dataclass - -from agent_framework import ( - Executor, - Workflow, - WorkflowBuilder, - WorkflowContext, - WorkflowExecutor, - handler, - response_handler, -) -from agent_framework.azure import DurableAIAgentWorker -from azure.identity import AzureCliCredential -from dotenv import load_dotenv -from durabletask.azuremanaged.worker import DurableTaskSchedulerWorker -from pydantic import BaseModel -from typing_extensions import Never - -load_dotenv() - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - -INNER_WORKFLOW_NAME = "human_review" -OUTER_WORKFLOW_NAME = "moderation_pipeline" - - -# ============================================================================ -# Data Models -# ============================================================================ - - -@dataclass -class ContentSubmission: - """Content submitted for moderation (outer workflow input).""" - - content_id: str - title: str - body: str - - -@dataclass -class HumanApprovalRequest: - """Request surfaced to the human reviewer (carried in the orchestration status).""" - - content_id: str - title: str - body: str - prompt: str - - -class HumanApprovalResponse(BaseModel): - """Response the external client sends back via the HITL response endpoint/method.""" - - approved: bool - reviewer_notes: str = "" - - -@dataclass -class ModerationDecision: - """The inner workflow's output: the human's decision for a submission.""" - - content_id: str - approved: bool - reviewer_notes: str - - -# ============================================================================ -# Inner workflow (contains the HITL pause) -# ============================================================================ - - -class ReviewGateExecutor(Executor): - """Inner-workflow executor that pauses for human approval via request_info.""" - - def __init__(self) -> None: - super().__init__(id="review_gate") - - @handler - async def request_review(self, submission: ContentSubmission, ctx: WorkflowContext) -> None: - prompt = ( - f"Please review the following content for publication:\n\n" - f"Title: {submission.title}\n" - f"Content: {submission.body}\n\n" - f"Approve or reject this content." - ) - approval_request = HumanApprovalRequest( - content_id=submission.content_id, - title=submission.title, - body=submission.body, - prompt=prompt, - ) - # Pause the (inner) workflow and wait for a human response. On the durable - # host this pauses the child orchestration running this inner workflow. - await ctx.request_info(request_data=approval_request, response_type=HumanApprovalResponse) - - @response_handler - async def handle_approval_response( - self, - original_request: HumanApprovalRequest, - response: HumanApprovalResponse, - ctx: WorkflowContext[Never, ModerationDecision], - ) -> None: - logger.info( - "Human review received for content %s: approved=%s", - original_request.content_id, - response.approved, - ) - # Yield the decision as the inner workflow's output; the WorkflowExecutor - # forwards it to the outer workflow as a message to the next node. - await ctx.yield_output( - ModerationDecision( - content_id=original_request.content_id, - approved=response.approved, - reviewer_notes=response.reviewer_notes, - ) - ) - - -def create_inner_workflow() -> Workflow: - """Build the inner ``human_review`` workflow (a single HITL gate).""" - review_gate = ReviewGateExecutor() - return WorkflowBuilder(name=INNER_WORKFLOW_NAME, start_executor=review_gate).build() - - -# ============================================================================ -# Outer workflow (embeds the inner workflow) -# ============================================================================ - - -class IntakeExecutor(Executor): - """Outer-workflow entry point that normalizes the submission before review.""" - - def __init__(self) -> None: - super().__init__(id="intake") - - @handler - async def intake(self, submission: ContentSubmission, ctx: WorkflowContext[ContentSubmission]) -> None: - logger.info("Intake received submission %s", submission.content_id) - await ctx.send_message(submission) - - -class PublishExecutor(Executor): - """Outer-workflow executor that consumes the inner workflow's forwarded decision.""" - - def __init__(self) -> None: - super().__init__(id="publish") - - @handler - async def handle_decision(self, decision: ModerationDecision, ctx: WorkflowContext[Never, str]) -> None: - if decision.approved: - message = ( - f"Content '{decision.content_id}' APPROVED and published. " - f"Reviewer notes: {decision.reviewer_notes or 'None'}" - ) - else: - message = f"Content '{decision.content_id}' REJECTED. Reviewer notes: {decision.reviewer_notes or 'None'}" - logger.info(message) - await ctx.yield_output(message) - - -def create_workflow() -> Workflow: - """Build the outer ``moderation_pipeline`` workflow embedding the HITL sub-workflow.""" - inner_workflow = create_inner_workflow() - - intake = IntakeExecutor() - # WorkflowExecutor embeds the inner (HITL) workflow as a single node. On the - # durable host this node runs as a child orchestration, and the inner pause - # surfaces to the client as a qualified request id (``review_sub~0~{requestId}``). - review_sub = WorkflowExecutor(inner_workflow, id="review_sub") - publish = PublishExecutor() - - return ( - WorkflowBuilder(name=OUTER_WORKFLOW_NAME, start_executor=intake) - .add_edge(intake, review_sub) - .add_edge(review_sub, publish) - .build() - ) - - -def get_worker( - taskhub: str | None = None, endpoint: str | None = None, log_handler: logging.Handler | None = None -) -> DurableTaskSchedulerWorker: - """Create a configured DurableTaskSchedulerWorker.""" - taskhub_name = taskhub or os.getenv("TASKHUB", "default") - endpoint_url = endpoint or os.getenv("ENDPOINT", "http://localhost:8080") - - credential = None if endpoint_url == "http://localhost:8080" else AzureCliCredential() - - return DurableTaskSchedulerWorker( - host_address=endpoint_url, - secure_channel=endpoint_url != "http://localhost:8080", - taskhub=taskhub_name, - token_credential=credential, - log_handler=log_handler, - ) - - -def setup_worker(worker: DurableTaskSchedulerWorker) -> DurableAIAgentWorker: - """Register the outer workflow and its nested HITL sub-workflow on the worker.""" - agent_worker = DurableAIAgentWorker(worker) - - workflow = create_workflow() - # A single call registers the outer workflow plus the nested human_review - # sub-workflow (each as its own durable orchestration). - agent_worker.configure_workflow(workflow) - logger.info( - "✓ Configured workflow '%s' with embedded HITL sub-workflow '%s'", - OUTER_WORKFLOW_NAME, - INNER_WORKFLOW_NAME, - ) - - return agent_worker - - -async def main() -> None: - """Start the worker and block until interrupted.""" - worker = get_worker() - setup_worker(worker) - - logger.info("Worker is ready and listening for work items. Press Ctrl+C to stop.") - try: - worker.start() - while True: - await asyncio.sleep(1) - except KeyboardInterrupt: - logger.info("Worker shutdown initiated") - - logger.info("Worker stopped") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/04-hosting/durabletask/README.md b/python/samples/04-hosting/durabletask/README.md index 08f3568e693..140258c627f 100644 --- a/python/samples/04-hosting/durabletask/README.md +++ b/python/samples/04-hosting/durabletask/README.md @@ -1,171 +1,3 @@ -# Durable Task Samples - -This directory contains samples for durable agent hosting using the Durable Task Scheduler. These samples demonstrate the worker-client architecture pattern, enabling distributed agent execution with persistent conversation state. - -## Quick Prerequisites Checklist - -Install and verify these tools before [Running the Samples](#running-the-samples): - -- **[Docker](https://docs.docker.com/get-docker/)** – run the Durable Task Scheduler emulator locally -- **[uv](https://docs.astral.sh/uv/)** – manage Python dependencies (optional but recommended) -- **[Azure CLI](https://learn.microsoft.com/cli/azure/install-azure-cli)** – authenticate with `az login` for `AzureCliCredential` - -**Windows (PowerShell):** - -```powershell -winget install Docker.DockerDesktop -irm https://astral.sh/uv/install.ps1 | iex -winget install Microsoft.AzureCLI -``` - -**macOS / Linux:** - -```bash -# Docker: https://docs.docker.com/get-docker/ -curl -LsSf https://astral.sh/uv/install.sh | sh -# Azure CLI: https://learn.microsoft.com/cli/azure/install-azure-cli -``` - -**Verify:** - -```bash -docker --version -uv --version -az account show -``` - -## Sample Catalog - -### Basic Patterns -- **[01_single_agent](01_single_agent/)**: Host a single conversational agent and interact with it via a client. Demonstrates basic worker-client architecture and agent state management. -- **[02_multi_agent](02_multi_agent/)**: Host multiple domain-specific agents (physicist and chemist) and route requests to the appropriate agent based on the question topic. -- **[03_single_agent_streaming](03_single_agent_streaming/)**: Enable reliable, resumable streaming using Redis Streams with agent response callbacks. Demonstrates non-blocking agent execution and cursor-based resumption for disconnected clients. - -### Orchestration Patterns -- **[04_single_agent_orchestration_chaining](04_single_agent_orchestration_chaining/)**: Chain multiple invocations of the same agent using durable orchestration, preserving conversation context across sequential runs. -- **[05_multi_agent_orchestration_concurrency](05_multi_agent_orchestration_concurrency/)**: Run multiple agents concurrently within an orchestration, aggregating their responses in parallel. -- **[06_multi_agent_orchestration_conditionals](06_multi_agent_orchestration_conditionals/)**: Implement conditional branching in orchestrations with spam detection and email assistant agents. Demonstrates structured outputs with Pydantic models and activity functions for side effects. -- **[07_single_agent_orchestration_hitl](07_single_agent_orchestration_hitl/)**: Human-in-the-loop pattern with external event handling, timeouts, and iterative refinement based on human feedback. Shows long-running workflows with external interactions. - -### Workflow Hosting Patterns -- **[08_workflow](08_workflow/)**: Host a MAF `Workflow` as a durable orchestration on a standalone worker via `DurableAIAgentWorker.configure_workflow`. Demonstrates conditional routing and mixing AI agents with non-agent executors. -- **[09_workflow_hitl](09_workflow_hitl/)**: A workflow that pauses for human approval using `ctx.request_info` / `@response_handler`, with the client discovering and answering the pending request. -- **[10_workflow_streaming](10_workflow_streaming/)**: Stream a hosted workflow's events as typed `WorkflowEvent` objects by polling the orchestration's custom status. -- **[11_subworkflow](11_subworkflow/)**: Compose workflows by embedding an inner `Workflow` as a node via `WorkflowExecutor`. On the durable host the inner workflow runs as its own child orchestration, and a single `configure_workflow` call registers both. -- **[12_subworkflow_hitl](12_subworkflow_hitl/)**: A human-in-the-loop pause that lives **inside a sub-workflow**. The nested request surfaces to the client with a qualified request id (`{executor}~{ordinal}~{requestId}`) behind a single top-level addressing surface. - -## 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: - -- [Python 3.9 or later](https://www.python.org/downloads/) -- [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 Python 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). - -### 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:8082`. - -### Environment Configuration - -Each sample reads configuration from environment variables. You'll need to set the following environment variables: - -Bash (Linux/macOS/WSL): - -```bash -export FOUNDRY_PROJECT_ENDPOINT="https://your-project.services.ai.azure.com/api/projects/your-project" -export FOUNDRY_MODEL="your-deployment-name" -``` - -PowerShell: - -```powershell -$env:FOUNDRY_PROJECT_ENDPOINT="https://your-project.services.ai.azure.com/api/projects/your-project" -$env:FOUNDRY_MODEL="your-deployment-name" -``` - -### Installing Dependencies - -Navigate to the sample directory and install dependencies. For example: - -```bash -cd samples/04-hosting/durabletask/01_single_agent -pip install -r requirements.txt -``` - -If you're using `uv` for package management: - -```bash -uv pip install -r requirements.txt -``` - -### Running the Samples - -Each sample follows a worker-client architecture. Most samples provide separate `worker.py` and `client.py` files, though some include a combined `sample.py` for convenience. - -**Running with separate worker and client:** - -In one terminal, start the worker: - -```bash -python worker.py -``` - -In another terminal, run the client: - -```bash -python client.py -``` - -**Running with combined sample:** - -```bash -python sample.py -``` - -### Viewing the Sample Output - -The sample output is displayed directly in the terminal where you ran the Python script. Agent responses are printed to stdout with log formatting for better readability. - -You can also see the state of agents and orchestrations in the Durable Task Scheduler dashboard at `http://localhost:8082`. +# Durable Task Samples Have Moved +The Python Durable Task samples are now maintained in the [Durable Agent Framework extension repository](https://github.com/microsoft/agent-framework-durable-extension/tree/main/python/samples). diff --git a/python/samples/AGENTS.md b/python/samples/AGENTS.md index 8fe34b7c020..7250e424f62 100644 --- a/python/samples/AGENTS.md +++ b/python/samples/AGENTS.md @@ -7,7 +7,7 @@ ``` python/samples/ -├── 01-get-started/ # Progressive tutorial (steps 01–06) +├── 01-get-started/ # Progressive tutorial (steps 01–07) ├── 02-agents/ # Deep-dive concept samples │ ├── tools/ # Tool patterns (function, approval, schema, etc.) │ ├── middleware/ # One file per middleware concept @@ -37,8 +37,7 @@ python/samples/ ├── 04-hosting/ # Deployment & hosting │ ├── a2a/ # Agent-to-Agent protocol │ ├── af-hosting/ # Native Responses and Telegram hosting -│ ├── azure_functions/ # Azure Functions samples -│ └── durabletask/ # Durable task framework +│ └── foundry-hosted-agents/ # Foundry hosted agents ├── 05-end-to-end/ # Complete applications │ ├── chatkit-integration/ │ ├── evaluation/ @@ -51,10 +50,12 @@ python/samples/ └── _to_delete/ # Old samples awaiting review ``` +Durable Task and Azure Functions samples are maintained in the [Durable Agent Framework extension](https://github.com/microsoft/agent-framework-durable-extension/tree/main/python/samples). + ## Design principles 1. **Progressive complexity**: Sections 01→05 build from "hello world" to - production. Within 01-get-started, files are numbered 01–06 and each step + production. Within 01-get-started, files are numbered 01–07 and each step adds exactly one concept. 2. **One concept per file** in 01-get-started and flat files in 02-agents/. diff --git a/python/samples/README.md b/python/samples/README.md index 19324c85e97..6b7bc4a1737 100644 --- a/python/samples/README.md +++ b/python/samples/README.md @@ -6,10 +6,10 @@ This directory contains samples demonstrating the capabilities of Microsoft Agen | Folder | Description | |--------|-------------| -| [`01-get-started/`](./01-get-started/) | Progressive tutorial: hello agent → hosting | +| [`01-get-started/`](./01-get-started/) | Progressive tutorial: hello agent → graph workflows | | [`02-agents/`](./02-agents/) | Deep-dive by concept: tools, middleware, providers, orchestrations | | [`03-workflows/`](./03-workflows/) | Workflow patterns: sequential, concurrent, state, declarative, explicit output designation | -| [`04-hosting/`](./04-hosting/) | Deployment: Azure Functions, Durable Tasks, A2A | +| [`04-hosting/`](./04-hosting/) | Deployment: A2A, self-hosted protocol helpers, and Foundry hosted agents | | [`05-end-to-end/`](./05-end-to-end/) | Full applications, evaluation, demos | ## Getting Started @@ -23,7 +23,8 @@ Start with `01-get-started/` and work through the numbered files: 5. **[05_functional_workflow_with_agents.py](./01-get-started/05_functional_workflow_with_agents.py)** — Call agents inside a functional workflow 6. **[06_functional_workflow_basics.py](./01-get-started/06_functional_workflow_basics.py)** — Write a workflow as a plain async function 7. **[07_first_graph_workflow.py](./01-get-started/07_first_graph_workflow.py)** — Build a workflow with executors and edges -8. **[08_host_your_agent.py](./01-get-started/08_host_your_agent.py)** — Host your agent via Azure Functions + +Durable Task and Azure Functions samples have moved to the [Durable Agent Framework extension](https://github.com/microsoft/agent-framework-durable-extension/tree/main/python/samples). ## Prerequisites diff --git a/python/uv.lock b/python/uv.lock index 12fba89b2a2..3807b2b29f9 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -34,7 +34,6 @@ members = [ "agent-framework-azure-contentunderstanding", "agent-framework-azure-cosmos", "agent-framework-azure-cosmos-memory", - "agent-framework-azurefunctions", "agent-framework-bedrock", "agent-framework-chatkit", "agent-framework-claude", @@ -42,7 +41,6 @@ members = [ "agent-framework-core", "agent-framework-declarative", "agent-framework-devui", - "agent-framework-durabletask", "agent-framework-foundry", "agent-framework-foundry-hosting", "agent-framework-foundry-local", @@ -80,15 +78,15 @@ name = "a2a-sdk" version = "1.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "culsans", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" }, - { name = "google-api-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "googleapis-common-protos", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "httpx-sse", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "json-rpc", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "protobuf", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "culsans", marker = "python_full_version < '3.13'" }, + { name = "google-api-core" }, + { name = "googleapis-common-protos" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "json-rpc" }, + { name = "packaging" }, + { name = "protobuf" }, + { name = "pydantic" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c7/7e/8ac10bbf8b15b16574355f39b17dbdf617a282c27b41c7ff2116e30336df/a2a_sdk-1.1.0.tar.gz", hash = "sha256:e8102dad1b36709dbdc3d19319e38e6dfa3b3a79c30416030eb2d482576be204", size = 375726, upload-time = "2026-05-29T09:34:43.015Z" } wheels = [ @@ -109,7 +107,7 @@ name = "ag-ui-protocol" version = "0.1.19" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pydantic" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a7/10/4ad299267a7d04b89935aa99eef62979758fcf95aee9f8bb5d70c35b1be1/ag_ui_protocol-0.1.19.tar.gz", hash = "sha256:43c27f60d41712dcad0e9e0a203cbdf1c8e248b22417374c5c68321c448af4ea", size = 10720, upload-time = "2026-06-02T17:26:15.627Z" } wheels = [ @@ -121,34 +119,34 @@ name = "agent-framework" version = "1.13.0" source = { virtual = "." } dependencies = [ - { name = "agent-framework-core", extra = ["all"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core", extra = ["all"] }, ] [package.dev-dependencies] dev = [ - { name = "flit", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "mypy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "poethepoet", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "prek", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pyrefly", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pyright", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pytest-asyncio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pytest-cov", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pytest-retry", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pytest-timeout", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pytest-xdist", extra = ["psutil"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "rich", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "ruff", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "tomli", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "ty", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "uv", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "zuban", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "flit" }, + { name = "mypy" }, + { name = "opentelemetry-sdk" }, + { name = "poethepoet" }, + { name = "prek" }, + { name = "pyrefly" }, + { name = "pyright" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "pytest-retry" }, + { name = "pytest-timeout" }, + { name = "pytest-xdist", extra = ["psutil"] }, + { name = "rich" }, + { name = "ruff" }, + { name = "tomli" }, + { name = "ty" }, + { name = "uv" }, + { name = "zuban" }, ] test = [ - { name = "azure-monitor-opentelemetry", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "mcp", extra = ["ws"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-monitor-opentelemetry" }, + { name = "mcp", extra = ["ws"] }, ] [package.metadata] @@ -186,8 +184,8 @@ name = "agent-framework-a2a" version = "1.0.0b260730" source = { editable = "packages/a2a" } dependencies = [ - { name = "a2a-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "a2a-sdk" }, + { name = "agent-framework-core" }, ] [package.metadata] @@ -201,22 +199,22 @@ name = "agent-framework-ag-ui" version = "1.0.1" source = { editable = "packages/ag-ui" } dependencies = [ - { name = "ag-ui-protocol", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "fastapi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "sse-starlette", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "uvicorn", extra = ["standard"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "ag-ui-protocol" }, + { name = "agent-framework-core" }, + { name = "fastapi" }, + { name = "sse-starlette" }, + { name = "uvicorn", extra = ["standard"] }, ] [package.optional-dependencies] dev = [ - { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "httpx" }, + { name = "pytest" }, ] [package.dev-dependencies] test = [ - { name = "agent-framework-orchestrations", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-orchestrations" }, ] [package.metadata] @@ -239,8 +237,8 @@ name = "agent-framework-anthropic" version = "1.0.0b260730" source = { editable = "packages/anthropic" } dependencies = [ - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "anthropic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core" }, + { name = "anthropic" }, ] [package.metadata] @@ -254,8 +252,8 @@ name = "agent-framework-azure-ai-search" version = "1.0.0b260730" source = { editable = "packages/azure-ai-search" } dependencies = [ - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "azure-search-documents", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core" }, + { name = "azure-search-documents" }, ] [package.metadata] @@ -269,11 +267,11 @@ name = "agent-framework-azure-contentunderstanding" version = "1.0.0b260730" source = { editable = "packages/azure-contentunderstanding" } dependencies = [ - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-foundry", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "azure-ai-contentunderstanding", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "filetype", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core" }, + { name = "agent-framework-foundry" }, + { name = "aiohttp" }, + { name = "azure-ai-contentunderstanding" }, + { name = "filetype" }, ] [package.metadata] @@ -290,8 +288,8 @@ name = "agent-framework-azure-cosmos" version = "1.0.0b260730" source = { editable = "packages/azure-cosmos" } dependencies = [ - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "azure-cosmos", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core" }, + { name = "azure-cosmos" }, ] [package.metadata] @@ -305,16 +303,16 @@ name = "agent-framework-azure-cosmos-memory" version = "1.0.0a260730" source = { editable = "packages/azure-cosmos-memory" } dependencies = [ - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "azure-cosmos-agent-memory", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "prompty", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core" }, + { name = "azure-cosmos-agent-memory" }, + { name = "prompty" }, ] [package.dev-dependencies] dev = [ - { name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pytest-asyncio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pytest-cov", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, ] [package.metadata] @@ -334,20 +332,16 @@ dev = [ [[package]] name = "agent-framework-azurefunctions" version = "1.0.0b260730" -source = { editable = "packages/azurefunctions" } +source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-durabletask", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "azure-functions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "azure-functions-durable", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core" }, + { name = "agent-framework-durabletask" }, + { name = "azure-functions" }, + { name = "azure-functions-durable" }, ] - -[package.metadata] -requires-dist = [ - { name = "agent-framework-core", editable = "packages/core" }, - { name = "agent-framework-durabletask", editable = "packages/durabletask" }, - { name = "azure-functions", specifier = ">=1.24.0,<2" }, - { name = "azure-functions-durable", specifier = ">=1.3.1,<2" }, +sdist = { url = "https://files.pythonhosted.org/packages/7f/51/358ca285536098786143113d6f683d4ee08316545503a039975e6a39e518/agent_framework_azurefunctions-1.0.0b260730.tar.gz", hash = "sha256:d686dad93d048e6409377c8b9c26c87aca62bbc2054240a752f1daaa18ee53cf", size = 33039, upload-time = "2026-07-30T23:38:44.926Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/9f/72c97ba481afc34d8f76e27e3d6ed8fcbefd54189810c5b0a24eafa48a5d/agent_framework_azurefunctions-1.0.0b260730-py3-none-any.whl", hash = "sha256:fbf7d73a8ee4d676a937196f2c7f6f2a9137eb787d1c7cb39f1b7a5363d57ad2", size = 37274, upload-time = "2026-07-30T23:37:25.745Z" }, ] [[package]] @@ -355,9 +349,9 @@ name = "agent-framework-bedrock" version = "1.0.0b260730" source = { editable = "packages/bedrock" } dependencies = [ - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "boto3", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "botocore", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core" }, + { name = "boto3" }, + { name = "botocore" }, ] [package.metadata] @@ -372,8 +366,8 @@ name = "agent-framework-chatkit" version = "1.0.0b260730" source = { editable = "packages/chatkit" } dependencies = [ - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "openai-chatkit", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core" }, + { name = "openai-chatkit" }, ] [package.metadata] @@ -387,8 +381,8 @@ name = "agent-framework-claude" version = "1.0.0b260730" source = { editable = "packages/claude" } dependencies = [ - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "claude-agent-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core" }, + { name = "claude-agent-sdk" }, ] [package.metadata] @@ -402,8 +396,8 @@ name = "agent-framework-copilotstudio" version = "1.0.0b260730" source = { editable = "packages/copilotstudio" } dependencies = [ - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "microsoft-agents-copilotstudio-client", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core" }, + { name = "microsoft-agents-copilotstudio-client" }, ] [package.metadata] @@ -417,51 +411,51 @@ name = "agent-framework-core" version = "1.13.0" source = { editable = "packages/core" } dependencies = [ - { name = "msgspec", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "python-dotenv", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "msgspec" }, + { name = "opentelemetry-api" }, + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-extensions" }, ] [package.optional-dependencies] all = [ - { name = "agent-framework-a2a", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-ag-ui", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-anthropic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-azure-ai-search", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-azure-contentunderstanding", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-azure-cosmos", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-azurefunctions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-bedrock", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-chatkit", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-claude", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-copilotstudio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-declarative", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-devui", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-durabletask", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-foundry", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-foundry-hosting", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-foundry-local", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-gemini", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-github-copilot", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-a2a" }, + { name = "agent-framework-ag-ui" }, + { name = "agent-framework-anthropic" }, + { name = "agent-framework-azure-ai-search" }, + { name = "agent-framework-azure-contentunderstanding" }, + { name = "agent-framework-azure-cosmos" }, + { name = "agent-framework-azurefunctions" }, + { name = "agent-framework-bedrock" }, + { name = "agent-framework-chatkit" }, + { name = "agent-framework-claude" }, + { name = "agent-framework-copilotstudio" }, + { name = "agent-framework-declarative" }, + { name = "agent-framework-devui" }, + { name = "agent-framework-durabletask" }, + { name = "agent-framework-foundry" }, + { name = "agent-framework-foundry-hosting" }, + { name = "agent-framework-foundry-local" }, + { name = "agent-framework-gemini" }, + { name = "agent-framework-github-copilot" }, { name = "agent-framework-hyperlight", marker = "(python_full_version < '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux') or (python_full_version < '3.14' and platform_machine == 'AMD64' and sys_platform == 'win32')" }, - { name = "agent-framework-lab", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-mem0", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-mistral", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-monty", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-ollama", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-orchestrations", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-purview", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-redis", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-tools", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "mcp", extra = ["ws"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-lab" }, + { name = "agent-framework-mem0" }, + { name = "agent-framework-mistral" }, + { name = "agent-framework-monty" }, + { name = "agent-framework-ollama" }, + { name = "agent-framework-openai" }, + { name = "agent-framework-orchestrations" }, + { name = "agent-framework-purview" }, + { name = "agent-framework-redis" }, + { name = "agent-framework-tools" }, + { name = "mcp", extra = ["ws"] }, ] [package.dev-dependencies] dev = [ - { name = "azure-ai-agentserver-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-ai-agentserver-core" }, ] [package.metadata] @@ -472,14 +466,14 @@ requires-dist = [ { name = "agent-framework-azure-ai-search", marker = "extra == 'all'", editable = "packages/azure-ai-search" }, { name = "agent-framework-azure-contentunderstanding", marker = "extra == 'all'", editable = "packages/azure-contentunderstanding" }, { name = "agent-framework-azure-cosmos", marker = "extra == 'all'", editable = "packages/azure-cosmos" }, - { name = "agent-framework-azurefunctions", marker = "extra == 'all'", editable = "packages/azurefunctions" }, + { name = "agent-framework-azurefunctions", marker = "extra == 'all'" }, { name = "agent-framework-bedrock", marker = "extra == 'all'", editable = "packages/bedrock" }, { name = "agent-framework-chatkit", marker = "extra == 'all'", editable = "packages/chatkit" }, { name = "agent-framework-claude", marker = "extra == 'all'", editable = "packages/claude" }, { name = "agent-framework-copilotstudio", marker = "extra == 'all'", editable = "packages/copilotstudio" }, { name = "agent-framework-declarative", marker = "extra == 'all'", editable = "packages/declarative" }, { name = "agent-framework-devui", marker = "extra == 'all'", editable = "packages/devui" }, - { name = "agent-framework-durabletask", marker = "extra == 'all'", editable = "packages/durabletask" }, + { name = "agent-framework-durabletask", marker = "extra == 'all'" }, { name = "agent-framework-foundry", marker = "extra == 'all'", editable = "packages/foundry" }, { name = "agent-framework-foundry-hosting", marker = "extra == 'all'", editable = "packages/foundry_hosting" }, { name = "agent-framework-foundry-local", marker = "extra == 'all'", editable = "packages/foundry_local" }, @@ -513,15 +507,15 @@ name = "agent-framework-declarative" version = "1.0.1" source = { editable = "packages/declarative" } dependencies = [ - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "powerfx", marker = "(python_full_version < '3.14' and sys_platform == 'darwin') or (python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32')" }, - { name = "pyyaml", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core" }, + { name = "httpx" }, + { name = "powerfx", marker = "python_full_version < '3.14'" }, + { name = "pyyaml" }, ] [package.dev-dependencies] dev = [ - { name = "types-pyyaml", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "types-pyyaml" }, ] [package.metadata] @@ -540,22 +534,22 @@ name = "agent-framework-devui" version = "1.0.0b260730" source = { editable = "packages/devui" } dependencies = [ - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "fastapi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "uvicorn", extra = ["standard"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core" }, + { name = "fastapi" }, + { name = "openai" }, + { name = "opentelemetry-sdk" }, + { name = "uvicorn", extra = ["standard"] }, ] [package.optional-dependencies] all = [ - { name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "watchdog", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pytest" }, + { name = "watchdog" }, ] dev = [ - { name = "agent-framework-orchestrations", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "watchdog", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-orchestrations" }, + { name = "pytest" }, + { name = "watchdog" }, ] [package.metadata] @@ -576,40 +570,28 @@ provides-extras = ["dev", "all"] [[package]] name = "agent-framework-durabletask" version = "1.0.0b260730" -source = { editable = "packages/durabletask" } +source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "durabletask", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "durabletask-azuremanaged", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "python-dateutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core" }, + { name = "durabletask" }, + { name = "durabletask-azuremanaged" }, + { name = "python-dateutil" }, ] - -[package.dev-dependencies] -dev = [ - { name = "types-python-dateutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, -] - -[package.metadata] -requires-dist = [ - { name = "agent-framework-core", editable = "packages/core" }, - { name = "durabletask", specifier = ">=1.5.0,<2" }, - { name = "durabletask-azuremanaged", specifier = ">=1.4.0,<2" }, - { name = "python-dateutil", specifier = ">=2.8.0,<3" }, +sdist = { url = "https://files.pythonhosted.org/packages/0e/e0/5113b292a854046e6af90cb869d9eb6f2f4c611bef10e9cbabcb138d00ee/agent_framework_durabletask-1.0.0b260730.tar.gz", hash = "sha256:4c6ed98f7fedbd7733f110991a8eea9fe7254ca56d12e5198efd8991251698f2", size = 72176, upload-time = "2026-07-30T23:38:59.338Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/87/9bfb24812983efa30f1da875e09dc8b9788e9ab713b204939953781eed85/agent_framework_durabletask-1.0.0b260730-py3-none-any.whl", hash = "sha256:41b403b824e231be99643e32e019f35d50ded48c5a157e9938627aa0159ff34d", size = 85536, upload-time = "2026-07-30T23:37:42.819Z" }, ] -[package.metadata.requires-dev] -dev = [{ name = "types-python-dateutil", specifier = "==2.9.0.20260716" }] - [[package]] name = "agent-framework-foundry" version = "1.10.4" source = { editable = "packages/foundry" } dependencies = [ - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "azure-ai-inference", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "azure-ai-projects", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core" }, + { name = "agent-framework-openai" }, + { name = "aiohttp" }, + { name = "azure-ai-inference" }, + { name = "azure-ai-projects" }, ] [package.metadata] @@ -626,12 +608,12 @@ name = "agent-framework-foundry-hosting" version = "1.0.0b260730" source = { editable = "packages/foundry_hosting" } dependencies = [ - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "azure-ai-agentserver-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "azure-ai-agentserver-invocations", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "azure-ai-agentserver-responses", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "mcp", extra = ["ws"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core" }, + { name = "azure-ai-agentserver-core" }, + { name = "azure-ai-agentserver-invocations" }, + { name = "azure-ai-agentserver-responses" }, + { name = "httpx" }, + { name = "mcp", extra = ["ws"] }, ] [package.metadata] @@ -649,9 +631,9 @@ name = "agent-framework-foundry-local" version = "1.0.0b260730" source = { editable = "packages/foundry_local" } dependencies = [ - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "foundry-local-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core" }, + { name = "agent-framework-openai" }, + { name = "foundry-local-sdk" }, ] [package.metadata] @@ -666,8 +648,8 @@ name = "agent-framework-gemini" version = "1.0.0b260730" source = { editable = "packages/gemini" } dependencies = [ - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "google-genai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core" }, + { name = "google-genai" }, ] [package.metadata] @@ -681,8 +663,8 @@ name = "agent-framework-github-copilot" version = "1.0.1" source = { editable = "packages/github_copilot" } dependencies = [ - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "github-copilot-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core" }, + { name = "github-copilot-sdk" }, ] [package.metadata] @@ -696,7 +678,7 @@ name = "agent-framework-hosting" version = "1.0.0a260730" source = { editable = "packages/hosting" } dependencies = [ - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core" }, ] [package.metadata] @@ -707,9 +689,9 @@ name = "agent-framework-hosting-a2a" version = "1.0.0a260730" source = { editable = "packages/hosting-a2a" } dependencies = [ - { name = "a2a-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-hosting", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "a2a-sdk" }, + { name = "agent-framework-core" }, + { name = "agent-framework-hosting" }, ] [package.metadata] @@ -724,10 +706,10 @@ name = "agent-framework-hosting-mcp" version = "1.0.0a260730" source = { editable = "packages/hosting-mcp" } dependencies = [ - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-hosting", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "mcp", extra = ["ws"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core" }, + { name = "agent-framework-hosting" }, + { name = "mcp", extra = ["ws"] }, + { name = "pydantic" }, ] [package.metadata] @@ -743,15 +725,15 @@ name = "agent-framework-hosting-responses" version = "1.0.0a260730" source = { editable = "packages/hosting-responses" } dependencies = [ - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-hosting", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core" }, + { name = "agent-framework-hosting" }, + { name = "openai" }, ] [package.dev-dependencies] test = [ - { name = "fastapi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "fastapi" }, + { name = "httpx" }, ] [package.metadata] @@ -772,8 +754,8 @@ name = "agent-framework-hosting-telegram" version = "1.0.0a260730" source = { editable = "packages/hosting-telegram" } dependencies = [ - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-hosting", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core" }, + { name = "agent-framework-hosting" }, ] [package.metadata] @@ -787,10 +769,10 @@ name = "agent-framework-hyperlight" version = "1.0.0b260730" source = { editable = "packages/hyperlight" } dependencies = [ - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "hyperlight-sandbox", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core" }, + { name = "hyperlight-sandbox" }, { name = "hyperlight-sandbox-backend-wasm", marker = "(python_full_version < '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux') or (python_full_version < '3.14' and platform_machine == 'AMD64' and sys_platform == 'win32')" }, - { name = "hyperlight-sandbox-python-guest", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "hyperlight-sandbox-python-guest" }, ] [package.metadata] @@ -806,48 +788,48 @@ name = "agent-framework-lab" version = "1.0.0b260730" source = { editable = "packages/lab" } dependencies = [ - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core" }, ] [package.optional-dependencies] gaia = [ - { name = "huggingface-hub", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "orjson", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pyarrow", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "tqdm", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "huggingface-hub" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-sdk" }, + { name = "orjson" }, + { name = "pyarrow" }, + { name = "pydantic" }, + { name = "tqdm" }, ] lightning = [ - { name = "agentlightning", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agentlightning" }, ] math = [ - { name = "sympy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "sympy" }, ] tau2 = [ - { name = "loguru", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.12' and sys_platform == 'darwin') or (python_full_version < '3.12' and sys_platform == 'linux') or (python_full_version < '3.12' and sys_platform == 'win32')" }, - { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and sys_platform == 'darwin') or (python_full_version >= '3.12' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform == 'win32')" }, - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "tiktoken", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "loguru" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "pydantic" }, + { name = "tiktoken" }, ] [package.dev-dependencies] dev = [ - { name = "mypy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "poethepoet", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "prek", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pyright", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "rich", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "ruff", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "tomli", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "tomli-w", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "uv", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "mypy" }, + { name = "poethepoet" }, + { name = "prek" }, + { name = "pyright" }, + { name = "pytest" }, + { name = "rich" }, + { name = "ruff" }, + { name = "tomli" }, + { name = "tomli-w" }, + { name = "uv" }, ] tau2 = [ - { name = "tau2", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "tau2" }, ] [package.metadata] @@ -889,8 +871,8 @@ name = "agent-framework-mem0" version = "1.0.0b260730" source = { editable = "packages/mem0" } dependencies = [ - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "mem0ai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core" }, + { name = "mem0ai" }, ] [package.metadata] @@ -904,8 +886,8 @@ name = "agent-framework-mistral" version = "1.0.0b260730" source = { editable = "packages/mistral" } dependencies = [ - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "mistralai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core" }, + { name = "mistralai" }, ] [package.metadata] @@ -919,8 +901,8 @@ name = "agent-framework-monty" version = "1.0.0b260730" source = { editable = "packages/monty" } dependencies = [ - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pydantic-monty", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core" }, + { name = "pydantic-monty" }, ] [package.metadata] @@ -934,8 +916,8 @@ name = "agent-framework-ollama" version = "1.0.0b260730" source = { editable = "packages/ollama" } dependencies = [ - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "ollama", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core" }, + { name = "ollama" }, ] [package.metadata] @@ -949,8 +931,8 @@ name = "agent-framework-openai" version = "1.12.0" source = { editable = "packages/openai" } dependencies = [ - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core" }, + { name = "openai" }, ] [package.metadata] @@ -964,7 +946,7 @@ name = "agent-framework-orchestrations" version = "1.0.2" source = { editable = "packages/orchestrations" } dependencies = [ - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core" }, ] [package.metadata] @@ -975,9 +957,9 @@ name = "agent-framework-purview" version = "1.0.0b260730" source = { editable = "packages/purview" } dependencies = [ - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core" }, + { name = "azure-core" }, + { name = "httpx" }, ] [package.metadata] @@ -992,11 +974,11 @@ name = "agent-framework-redis" version = "1.0.0b260730" source = { editable = "packages/redis" } dependencies = [ - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.12' and sys_platform == 'darwin') or (python_full_version < '3.12' and sys_platform == 'linux') or (python_full_version < '3.12' and sys_platform == 'win32')" }, - { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and sys_platform == 'darwin') or (python_full_version >= '3.12' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform == 'win32')" }, - { name = "redis", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "redisvl", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "redis" }, + { name = "redisvl" }, ] [package.metadata] @@ -1012,8 +994,8 @@ name = "agent-framework-tools" version = "1.0.0b260730" source = { editable = "packages/tools" } dependencies = [ - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "psutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core" }, + { name = "psutil" }, ] [package.metadata] @@ -1027,26 +1009,26 @@ name = "agentlightning" version = "0.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "agentops", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "aiologic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "fastapi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "flask", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "gpustat", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "graphviz", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "gunicorn", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "litellm", extra = ["proxy"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-exporter-otlp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "portpicker", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "psutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "rich", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "setproctitle", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "uvicorn", extra = ["standard"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "uvicorn-worker", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agentops" }, + { name = "aiohttp" }, + { name = "aiologic" }, + { name = "fastapi" }, + { name = "flask" }, + { name = "gpustat" }, + { name = "graphviz" }, + { name = "gunicorn" }, + { name = "litellm", extra = ["proxy"] }, + { name = "openai" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp" }, + { name = "opentelemetry-sdk" }, + { name = "portpicker" }, + { name = "psutil" }, + { name = "pydantic" }, + { name = "rich" }, + { name = "setproctitle" }, + { name = "uvicorn", extra = ["standard"] }, + { name = "uvicorn-worker" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b2/8f/1bed06f70d52ba4b2ed698605fa955a82ee5aaec3addee5a21e3fd7cd0cb/agentlightning-0.3.0.tar.gz", hash = "sha256:35cd702bce54ff7c8c097d8e73aaf688c6649e4ee81e6e5e0379000465b75d43", size = 1345454, upload-time = "2025-12-24T01:49:31.24Z" } wheels = [ @@ -1058,20 +1040,20 @@ name = "agentops" version = "0.4.21" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-exporter-otlp-proto-http", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "ordered-set", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "psutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pyyaml", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "termcolor", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "wrapt", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "aiohttp" }, + { name = "httpx" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-sdk" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "ordered-set" }, + { name = "packaging" }, + { name = "psutil" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "termcolor" }, + { name = "wrapt" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0a/c4/023fe976169c57b1edd71f4c08d6dedaf66814f5b25ecf59b3a8540311ab/agentops-0.4.21.tar.gz", hash = "sha256:47759c6dfd6ea58bad2f7764257e4778cb2e34ae180cef642f60f56adced6510", size = 430861, upload-time = "2025-08-29T06:36:55.323Z" } wheels = [ @@ -1101,14 +1083,14 @@ name = "aiohttp" version = "3.14.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiohappyeyeballs", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "aiosignal", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "attrs", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "frozenlist", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "multidict", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "propcache", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" }, - { name = "yarl", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "yarl" }, ] sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" } wheels = [ @@ -1215,9 +1197,9 @@ name = "aiologic" version = "0.17.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "sniffio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" }, - { name = "wrapt", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "wrapt" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f1/7a/d51f2fde1e8ae8a83431f8e97b7a71e9358cdb1d4d2ce6be387fa44d68de/aiologic-0.17.1.tar.gz", hash = "sha256:2e1b93b9e88ced318c2a63ad7b382688f40cbfe40e3d42258d49dc9c5aea179d", size = 252354, upload-time = "2026-06-27T20:41:33.25Z" } wheels = [ @@ -1229,8 +1211,8 @@ name = "aiosignal" version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "frozenlist", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" }, + { name = "frozenlist" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } wheels = [ @@ -1269,14 +1251,14 @@ name = "anthropic" version = "0.116.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "distro", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "docstring-parser", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "jiter", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "sniffio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "anyio" }, + { name = "distro" }, + { name = "docstring-parser" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/a2/d31f14e28d49bae983a3634e38dfb4b31c50110b5e403596c5c6a20b23f8/anthropic-0.116.0.tar.gz", hash = "sha256:5fc248fbb9fe03ef686f8a774f81586bca31a043260aab88b387ea3660f4a396", size = 949149, upload-time = "2026-07-02T19:08:10.534Z" } wheels = [ @@ -1288,8 +1270,8 @@ name = "anyio" version = "4.14.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "idna", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" } wheels = [ @@ -1310,7 +1292,7 @@ name = "apscheduler" version = "3.11.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "tzlocal", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "tzlocal" }, ] sdist = { url = "https://files.pythonhosted.org/packages/8c/6b/eeff360196bb20b312c9e762a820fd1b2c6d809466c755ef57863478e454/apscheduler-3.11.3.tar.gz", hash = "sha256:cd2fcc9330039a81a5893472ad49facf23a6d5604cbe1d918c835c6de7834d5a", size = 110312, upload-time = "2026-06-28T19:39:22.493Z" } wheels = [ @@ -1399,11 +1381,11 @@ name = "azure-ai-agentserver-core" version = "2.0.0b7" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "hypercorn", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "microsoft-opentelemetry", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "starlette", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "hypercorn" }, + { name = "microsoft-opentelemetry" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-sdk" }, + { name = "starlette" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9a/6c/5e3a796274e70e899eac739bc4e81e7645de6e5146fb18b1a5b3d79297e5/azure_ai_agentserver_core-2.0.0b7.tar.gz", hash = "sha256:272265f7ab6dcda3cb518a5028394b6684992e0abde9cfc2dfc2e851a289eab7", size = 52702, upload-time = "2026-06-28T14:29:52.329Z" } wheels = [ @@ -1415,7 +1397,7 @@ name = "azure-ai-agentserver-invocations" version = "1.0.0b6" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "azure-ai-agentserver-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-ai-agentserver-core" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0d/f4/c4ff1399795dd92fd56290ac3a1014817125e7fd44eb5a205fce043f0b46/azure_ai_agentserver_invocations-1.0.0b6.tar.gz", hash = "sha256:b8c04aa71dc42491f75443c5123d7ecf9c40318e35a05bb056e9786e98585fbd", size = 60512, upload-time = "2026-06-28T14:52:25.808Z" } wheels = [ @@ -1427,10 +1409,10 @@ name = "azure-ai-agentserver-responses" version = "1.0.0b8" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "azure-ai-agentserver-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "isodate", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "aiohttp" }, + { name = "azure-ai-agentserver-core" }, + { name = "azure-core" }, + { name = "isodate" }, ] sdist = { url = "https://files.pythonhosted.org/packages/31/1f/7e7563705100f2d21c952a44d5a2e93a8193cc0a6013941bac9f52ad8874/azure_ai_agentserver_responses-1.0.0b8.tar.gz", hash = "sha256:bc0365fd70b7dabf9c9394dac5bbab08f772b59f865319d401cfde317b6832ef", size = 450099, upload-time = "2026-06-28T14:52:32.155Z" } wheels = [ @@ -1442,9 +1424,9 @@ name = "azure-ai-contentunderstanding" version = "1.2.0b2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "isodate", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-core" }, + { name = "isodate" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0b/6c/f9af836a30b5299b10304d6b5ec645f8dbb1857429fa0191e41f86825d70/azure_ai_contentunderstanding-1.2.0b2.tar.gz", hash = "sha256:0ccef3c8087759ca788aabcc9af7b22cd8ada2df0236bf63563f4974c2d8cfcd", size = 265922, upload-time = "2026-06-11T02:24:56.951Z" } wheels = [ @@ -1456,9 +1438,9 @@ name = "azure-ai-inference" version = "1.0.0b9" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "isodate", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-core" }, + { name = "isodate" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/4e/6a/ed85592e5c64e08c291992f58b1a94dab6869f28fb0f40fd753dced73ba6/azure_ai_inference-1.0.0b9.tar.gz", hash = "sha256:1feb496bd84b01ee2691befc04358fa25d7c344d8288e99364438859ad7cd5a4", size = 182408, upload-time = "2025-02-15T00:37:28.464Z" } wheels = [ @@ -1470,12 +1452,12 @@ name = "azure-ai-projects" version = "2.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "azure-identity", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "azure-storage-blob", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "isodate", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-core" }, + { name = "azure-identity" }, + { name = "azure-storage-blob" }, + { name = "isodate" }, + { name = "openai" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7a/29/ab1a80ce483fcc36ec2ac0a3db8e043aa954700b34b77db43ed5f4f9c488/azure_ai_projects-2.3.0.tar.gz", hash = "sha256:6e3006b7b8aa51c6ff9db61ef4aac3717f8a712cd1a183d5a1d34e2eb33450bd", size = 27563233, upload-time = "2026-07-01T21:09:00.018Z" } wheels = [ @@ -1487,8 +1469,8 @@ name = "azure-core" version = "1.41.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "requests" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a6/f3/b416179e408990df5db0d516283022dde0f5d0111d98c1a848e41853e81c/azure_core-1.41.0.tar.gz", hash = "sha256:f46ff5dfcd230f25cf1c19e8a34b8dc08a337b2503e268bb600a16c00db8ad5a", size = 381042, upload-time = "2026-05-07T23:30:54.302Z" } wheels = [ @@ -1500,8 +1482,8 @@ name = "azure-core-tracing-opentelemetry" version = "1.0.0b13" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-core" }, + { name = "opentelemetry-api" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ce/ab/a937e4af8afec9d437d55252f2a3a4419fc3fc7d5e5d54022622bd11b2b6/azure_core_tracing_opentelemetry-1.0.0b13.tar.gz", hash = "sha256:6cb2f8dfd5dee6c11843db0205fc92e2434e1a272c169c953afe92483aafc7eb", size = 25832, upload-time = "2026-05-01T00:59:57.941Z" } wheels = [ @@ -1513,8 +1495,8 @@ name = "azure-cosmos" version = "4.16.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-core" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fe/2a/0f2bba256e56626ba2cec97ab81dd002ff47ead1329767760b619afd927a/azure_cosmos-4.16.1.tar.gz", hash = "sha256:fa15d13702b470265a67e2dd9c0794021e6b776856dac6c223dcacc4d8e1d8d1", size = 2377651, upload-time = "2026-06-02T01:08:07.656Z" } wheels = [ @@ -1526,14 +1508,14 @@ name = "azure-cosmos-agent-memory" version = "0.2.0b3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "azure-cosmos", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "azure-identity", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "jinja2", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "prompty", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "aiohttp" }, + { name = "azure-cosmos" }, + { name = "azure-identity" }, + { name = "jinja2" }, + { name = "openai" }, + { name = "prompty" }, + { name = "pydantic" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3d/18/fcca91e3e9ec4d3ac9278e6a7375ae09a18305f8e90c6d67dc89dfd4e015/azure_cosmos_agent_memory-0.2.0b3.tar.gz", hash = "sha256:d9a6263140c2e49a238d7d407f146645afd692506468ef440239a2bce02c3c2b", size = 158451, upload-time = "2026-07-09T02:35:59.207Z" } wheels = [ @@ -1545,7 +1527,7 @@ name = "azure-functions" version = "1.24.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "werkzeug", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "werkzeug" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1d/be/5535830e0658e9668093941b3c33b0ea03eceadbf6bd6b7870aa37ef071a/azure_functions-1.24.0.tar.gz", hash = "sha256:18ea1607c7a7268b7a1e1bd0cc28c5cc57a9db6baaacddb39ba0e9f865728187", size = 134495, upload-time = "2025-10-06T19:08:08.612Z" } wheels = [ @@ -1557,13 +1539,13 @@ name = "azure-functions-durable" version = "1.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "azure-functions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "furl", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "python-dateutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "aiohttp" }, + { name = "azure-functions" }, + { name = "furl" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-sdk" }, + { name = "python-dateutil" }, + { name = "requests" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ca/40/a5aaa966fd4b1220c03fbde019ac532bcb3db619defae5dbf1eb4476b07c/azure_functions_durable-1.6.0.tar.gz", hash = "sha256:30feb3968aed71fc481e62b88fbaea5c89e9e8dacd703e0a1d5b86aa12ad8c92", size = 201246, upload-time = "2026-07-09T19:02:19.426Z" } wheels = [ @@ -1575,11 +1557,11 @@ name = "azure-identity" version = "1.25.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "cryptography", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "msal", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "msal-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-core" }, + { name = "cryptography" }, + { name = "msal" }, + { name = "msal-extensions" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c5/0e/3a63efb48aa4a5ae2cfca61ee152fbcb668092134d3eb8bfda472dd5c617/azure_identity-1.25.3.tar.gz", hash = "sha256:ab23c0d63015f50b630ef6c6cf395e7262f439ce06e5d07a64e874c724f8d9e6", size = 286304, upload-time = "2026-03-13T01:12:20.892Z" } wheels = [ @@ -1591,19 +1573,19 @@ name = "azure-monitor-opentelemetry" version = "1.8.9" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "azure-core-tracing-opentelemetry", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "azure-monitor-opentelemetry-exporter", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation-django", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation-fastapi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation-flask", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation-logging", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation-psycopg2", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation-requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation-urllib", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation-urllib3", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-resource-detector-azure", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-core" }, + { name = "azure-core-tracing-opentelemetry" }, + { name = "azure-monitor-opentelemetry-exporter" }, + { name = "opentelemetry-instrumentation-django" }, + { name = "opentelemetry-instrumentation-fastapi" }, + { name = "opentelemetry-instrumentation-flask" }, + { name = "opentelemetry-instrumentation-logging" }, + { name = "opentelemetry-instrumentation-psycopg2" }, + { name = "opentelemetry-instrumentation-requests" }, + { name = "opentelemetry-instrumentation-urllib" }, + { name = "opentelemetry-instrumentation-urllib3" }, + { name = "opentelemetry-resource-detector-azure" }, + { name = "opentelemetry-sdk" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9e/9f/75f517dd019ebc1a6476187c7380ea440f040b7c933c7a6b7159260e2c37/azure_monitor_opentelemetry-1.8.9.tar.gz", hash = "sha256:9cda4481c52e02cb3e8e11a34d2491fb07000ed32ee655d75de0747e7d24fea7", size = 80026, upload-time = "2026-07-01T17:48:58.369Z" } wheels = [ @@ -1615,12 +1597,12 @@ name = "azure-monitor-opentelemetry-exporter" version = "1.0.0b55" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "azure-identity", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "msrest", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "psutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-core" }, + { name = "azure-identity" }, + { name = "msrest" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-sdk" }, + { name = "psutil" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ab/21/178fed00e9738e4c953e147d46f5f803bfeb3fde50b11efdb99ae016f05f/azure_monitor_opentelemetry_exporter-1.0.0b55.tar.gz", hash = "sha256:6701307124e5eeb1837b269ddafab0affb69020fdb842d7c5ff1a3122514595a", size = 342308, upload-time = "2026-07-01T23:59:14.53Z" } wheels = [ @@ -1632,9 +1614,9 @@ name = "azure-search-documents" version = "12.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "isodate", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-core" }, + { name = "isodate" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/59/dc/bb4db263381aa5b29414e280a8535a343d877a3831a501ef39332174c85c/azure_search_documents-12.0.0.tar.gz", hash = "sha256:8e6d73ec0ed1623083435b757e34324db65d72d4e09cca061a59fc7e90c8ddbc", size = 386222, upload-time = "2026-05-01T20:28:22.269Z" } wheels = [ @@ -1646,10 +1628,10 @@ name = "azure-storage-blob" version = "12.30.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "cryptography", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "isodate", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-core" }, + { name = "cryptography" }, + { name = "isodate" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3b/48/84a820d898267f662b5c06f7cd76fdb8a9e272b44aa9376cef3ec0f6a294/azure_storage_blob-12.30.0.tar.gz", hash = "sha256:2cd74d4d5731e5eb6b8d5c5056ee115a5e88f8fdf22517b739836fda685018be", size = 618229, upload-time = "2026-06-08T11:45:35.575Z" } wheels = [ @@ -1670,8 +1652,8 @@ name = "blessed" version = "1.47.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "jinxed", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "wcwidth", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "jinxed" }, + { name = "wcwidth" }, ] sdist = { url = "https://files.pythonhosted.org/packages/82/45/ad23d265373cdb7f255d2e3ed5f122b62914bd3c425bb21bca01ef699e5c/blessed-1.47.0.tar.gz", hash = "sha256:ea13e06ae40f24710325411c5fa9b689d215cf170276cf1fda41feddaec8d3e0", size = 14035743, upload-time = "2026-07-09T00:43:10.055Z" } wheels = [ @@ -1692,9 +1674,9 @@ name = "boto3" version = "1.43.45" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "botocore", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "jmespath", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "s3transfer", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cb/c0/bcbe0a924ed89f8982a87727cd0f88a73397fa423a60df17ae76aa013514/boto3-1.43.45.tar.gz", hash = "sha256:65e0ae541a9948ac41b706d5c7165d96ebf155812562b6518b862be027ee7347", size = 112666, upload-time = "2026-07-09T19:30:06.944Z" } wheels = [ @@ -1706,9 +1688,9 @@ name = "botocore" version = "1.43.49" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "jmespath", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "python-dateutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "urllib3", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, ] sdist = { url = "https://files.pythonhosted.org/packages/bc/2c/da2832f435371cd6c95b64325f981b3babe71e4aa4a71798b00ce2073100/botocore-1.43.49.tar.gz", hash = "sha256:7de02863c95b8008b1400c92a1a00996f25ad38944c07f7b620949f8798d1fdf", size = 15708260, upload-time = "2026-07-15T19:32:08.14Z" } wheels = [ @@ -1834,7 +1816,7 @@ name = "cffi" version = "2.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pycparser", marker = "(implementation_name != 'PyPy' and sys_platform == 'darwin') or (implementation_name != 'PyPy' and sys_platform == 'linux') or (implementation_name != 'PyPy' and sys_platform == 'win32')" }, + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } wheels = [ @@ -1978,9 +1960,9 @@ name = "claude-agent-sdk" version = "0.2.115" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "mcp", extra = ["ws"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "sniffio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "anyio" }, + { name = "mcp", extra = ["ws"] }, + { name = "sniffio" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ba/b8/c1898153ffb0c75cfc5504c18d892d3c57a080073c9ede3df7addb8fc4a0/claude_agent_sdk-0.2.115.tar.gz", hash = "sha256:230017fbba74fb3aa1a1d86b2986277c7fe26dd2e33eea865c90553d93f4e26d", size = 268644, upload-time = "2026-07-09T23:46:43.739Z" } wheels = [ @@ -2008,7 +1990,7 @@ name = "clr-loader" version = "0.2.10" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cffi", marker = "(python_full_version < '3.14' and sys_platform == 'darwin') or (python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32')" }, + { name = "cffi" }, ] sdist = { url = "https://files.pythonhosted.org/packages/18/24/c12faf3f61614b3131b5c98d3bf0d376b49c7feaa73edca559aeb2aee080/clr_loader-0.2.10.tar.gz", hash = "sha256:81f114afbc5005bafc5efe5af1341d400e22137e275b042a8979f3feb9fc9446", size = 83605, upload-time = "2026-01-03T23:13:06.984Z" } wheels = [ @@ -2029,8 +2011,8 @@ name = "contourpy" version = "1.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.12' and sys_platform == 'darwin') or (python_full_version < '3.12' and sys_platform == 'linux') or (python_full_version < '3.12' and sys_platform == 'win32')" }, - { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and sys_platform == 'darwin') or (python_full_version >= '3.12' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform == 'win32')" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } wheels = [ @@ -2193,7 +2175,7 @@ wheels = [ [package.optional-dependencies] toml = [ - { name = "tomli", marker = "(python_full_version <= '3.11' and sys_platform == 'darwin') or (python_full_version <= '3.11' and sys_platform == 'linux') or (python_full_version <= '3.11' and sys_platform == 'win32')" }, + { name = "tomli", marker = "python_full_version <= '3.11'" }, ] [[package]] @@ -2201,7 +2183,7 @@ name = "croniter" version = "6.2.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "python-dateutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "python-dateutil" }, ] sdist = { url = "https://files.pythonhosted.org/packages/37/57/2e2a65aee2a70483cb28e2b7e15a072d00a523207593b44400d4717bb100/croniter-6.2.4.tar.gz", hash = "sha256:fc124f751b1b04805c2a04b061898b436b45ab2320b045e1e052ea895de65189", size = 166267, upload-time = "2026-07-10T09:52:59.955Z" } wheels = [ @@ -2213,7 +2195,7 @@ name = "cryptography" version = "48.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cffi", marker = "(platform_python_implementation != 'PyPy' and sys_platform == 'darwin') or (platform_python_implementation != 'PyPy' and sys_platform == 'linux') or (platform_python_implementation != 'PyPy' and sys_platform == 'win32')" }, + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/12/45/870e7f4bef50e5f53b9f51d4428aee5290eedf58ba443f16b1ebb7ab8e66/cryptography-48.0.1.tar.gz", hash = "sha256:266f4ee051abb2f725b74ef8072b521ce1feacf685a3364fa6a6b45548db791a", size = 832989, upload-time = "2026-06-09T22:32:31.8Z" } wheels = [ @@ -2272,8 +2254,8 @@ name = "culsans" version = "0.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiologic", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" }, - { name = "typing-extensions", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" }, + { name = "aiologic" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d9/e3/49afa1bc180e0d28008ec6bcdf82a4072d1c7a41032b5b759b60814ca4b0/culsans-0.11.0.tar.gz", hash = "sha256:0b43d0d05dce6106293d114c86e3fb4bfc63088cfe8ff08ed3fe36891447fe33", size = 107546, upload-time = "2025-12-31T23:15:38.196Z" } wheels = [ @@ -2294,8 +2276,8 @@ name = "deepdiff" version = "9.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cachebox", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "orderly-set", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "cachebox" }, + { name = "orderly-set" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f9/6b/6a4a5aaf38535eb332c2856aa08e73ed7c549d0851b1215401af0a2db1a7/deepdiff-9.1.0.tar.gz", hash = "sha256:07e9e366fab4297755153c4eab795ad4ef3cbd0d51660e847f5751c6bd727687", size = 382149, upload-time = "2026-05-15T20:18:05.751Z" } wheels = [ @@ -2343,10 +2325,10 @@ name = "durabletask" version = "1.7.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "asyncio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "grpcio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "protobuf", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "asyncio" }, + { name = "grpcio" }, + { name = "packaging" }, + { name = "protobuf" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ca/c4/bb2d676f12c37b12856a6b47cbf0918c58f3fe0c1d5e7867a78a2e6aaa19/durabletask-1.7.2.tar.gz", hash = "sha256:b22c234d859c8ac3ad621d324132ffb7ba546c011ae3df8e22c2bd7ca7b108d2", size = 169101, upload-time = "2026-07-09T21:39:22.91Z" } wheels = [ @@ -2358,8 +2340,8 @@ name = "durabletask-azuremanaged" version = "1.7.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "azure-identity", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "durabletask", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-identity" }, + { name = "durabletask" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a1/9a/362661fb9a2637cb6960bca6131d9c224864bc50fd71ceec055cc6ba92c7/durabletask_azuremanaged-1.7.2.tar.gz", hash = "sha256:a0f257256ac9814ac202aec47ea47aa70819e671c99e5f04c9d7e415a2519e36", size = 18389, upload-time = "2026-07-09T21:54:12.37Z" } wheels = [ @@ -2371,8 +2353,8 @@ name = "email-validator" version = "2.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "dnspython", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "idna", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "dnspython" }, + { name = "idna" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } wheels = [ @@ -2402,7 +2384,7 @@ name = "expression" version = "5.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/43/c7/bb061623b5815566bda69f5e9d156e38a97ebb383b8db3d2dedb26415466/expression-5.6.0.tar.gz", hash = "sha256:454f6fe138347194a43c7f878d958efe9b84b9cc770e462010c7a52e18058065", size = 59147, upload-time = "2025-02-19T09:37:37.432Z" } wheels = [ @@ -2414,11 +2396,11 @@ name = "fastapi" version = "0.138.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "annotated-doc", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "starlette", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-inspection", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5b/58/ff455d9fe47c60abadb34b9e05a304b1f05f5ab8000ac01565156b6f5e43/fastapi-0.138.0.tar.gz", hash = "sha256:d445a4877636ad191e7053e08c9bf98cb921a6756776848400bb773d1740c061", size = 419240, upload-time = "2026-06-20T01:18:05.259Z" } wheels = [ @@ -2430,11 +2412,11 @@ name = "fastapi-sso" version = "0.21.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "fastapi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "oauthlib", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pydantic", extra = ["email"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pyjwt", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "fastapi" }, + { name = "httpx" }, + { name = "oauthlib" }, + { name = "pydantic", extra = ["email"] }, + { name = "pyjwt" }, ] sdist = { url = "https://files.pythonhosted.org/packages/41/38/1288b0248f91822bba254ce852466857cb51217b817c3d62bcc21cfd4d9e/fastapi_sso-0.21.1.tar.gz", hash = "sha256:c6f730b075caf537efa7c0f531095cf2d963a6fa27d059b3300e5eb19b282adb", size = 18031, upload-time = "2026-06-22T15:34:56.086Z" } wheels = [ @@ -2516,12 +2498,12 @@ name = "flask" version = "3.1.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "blinker", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "click", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "itsdangerous", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "jinja2", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "markupsafe", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "werkzeug", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, ] sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" } wheels = [ @@ -2533,11 +2515,11 @@ name = "flit" version = "3.12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "docutils", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "flit-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pip", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "tomli-w", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "docutils" }, + { name = "flit-core" }, + { name = "pip" }, + { name = "requests" }, + { name = "tomli-w" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/9c/0608c91a5b6c013c63548515ae31cff6399cd9ce891bd9daee8c103da09b/flit-3.12.0.tar.gz", hash = "sha256:1c80f34dd96992e7758b40423d2809f48f640ca285d0b7821825e50745ec3740", size = 155038, upload-time = "2025-03-25T08:03:22.505Z" } wheels = [ @@ -2607,9 +2589,9 @@ name = "foundry-local-sdk" version = "0.5.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "tqdm", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "tqdm" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/ed/6b/76a7fe8f9f4c52cc84eaa1cd1b66acddf993496d55d6ea587bf0d0854d1c/foundry_local_sdk-0.5.1-py3-none-any.whl", hash = "sha256:f3639a3666bc3a94410004a91671338910ac2e1b8094b1587cc4db0f4a7df07e", size = 14003, upload-time = "2025-11-21T05:39:58.099Z" }, @@ -2725,9 +2707,9 @@ name = "fs" version = "2.4.16" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "appdirs", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "setuptools", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "six", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "appdirs" }, + { name = "setuptools" }, + { name = "six" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5d/a9/af5bfd5a92592c16cdae5c04f68187a309be8a146b528eac3c6e30edbad2/fs-2.4.16.tar.gz", hash = "sha256:ae97c7d51213f4b70b6a958292530289090de3a7e15841e108fbe144f069d313", size = 187441, upload-time = "2022-05-02T09:25:54.22Z" } wheels = [ @@ -2748,8 +2730,8 @@ name = "furl" version = "2.1.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "orderedmultidict", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "six", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "orderedmultidict" }, + { name = "six" }, ] sdist = { url = "https://files.pythonhosted.org/packages/53/e4/203a76fa2ef46cdb0a618295cc115220cbb874229d4d8721068335eb87f0/furl-2.1.4.tar.gz", hash = "sha256:877657501266c929269739fb5f5980534a41abd6bbabcb367c136d1d3b2a6015", size = 57526, upload-time = "2025-03-09T05:36:21.175Z" } wheels = [ @@ -2761,8 +2743,8 @@ name = "github-copilot-sdk" version = "1.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "python-dateutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pydantic" }, + { name = "python-dateutil" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/1f/2c/3d3ecfe500c0ba7d3127737b1aa22f19ff1a19e6e86360bfdca3f02a2c09/github_copilot_sdk-1.0.2-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:856dfc8370f36f6efd8a2aa1dd40f82c1a6d0573d0577eaff1f6affb73ed29ad", size = 97329153, upload-time = "2026-06-18T00:56:20.653Z" }, @@ -2778,11 +2760,11 @@ name = "google-api-core" version = "2.31.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "google-auth", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "googleapis-common-protos", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "proto-plus", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "protobuf", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "google-auth" }, + { name = "googleapis-common-protos" }, + { name = "proto-plus" }, + { name = "protobuf" }, + { name = "requests" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c6/22/155cadf1d49272a9cf48f3168c0f3874fa13397297e611a5ea00cd093880/google_api_core-2.31.0.tar.gz", hash = "sha256:2be84ee0f584c48e6bde1b36766e23348b361fb7e55e56135fc76ce1c397f9c2", size = 176492, upload-time = "2026-06-03T14:52:17.257Z" } wheels = [ @@ -2794,8 +2776,8 @@ name = "google-auth" version = "2.55.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cryptography", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pyasn1-modules", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "cryptography" }, + { name = "pyasn1-modules" }, ] sdist = { url = "https://files.pythonhosted.org/packages/79/b9/e370d86fea3da13ec0256df30323dd26c0cb9c8c85f0c6ec42ac9df0106b/google_auth-2.55.2.tar.gz", hash = "sha256:97ae7790ff740f2bc9db60eb864a7804f4ac19f5f02c38b3d942f2fea6e9b9ae", size = 361414, upload-time = "2026-07-07T18:43:21.227Z" } wheels = [ @@ -2804,7 +2786,7 @@ wheels = [ [package.optional-dependencies] requests = [ - { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "requests" }, ] [[package]] @@ -2812,16 +2794,16 @@ name = "google-genai" version = "2.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "distro", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "google-auth", extra = ["requests"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "sniffio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "tenacity", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "websockets", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "anyio" }, + { name = "distro" }, + { name = "google-auth", extra = ["requests"] }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "sniffio" }, + { name = "tenacity" }, + { name = "typing-extensions" }, + { name = "websockets" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a6/01/e7b5f3aac89200c78318ed7643401e7f5ed3131b0cd353c07483606b1e61/google_genai-2.11.0.tar.gz", hash = "sha256:4c5e524d24b145c96be327f9a7f8f04b0fe4efee0533877795e9848afed01749", size = 622366, upload-time = "2026-07-09T17:49:43.862Z" } wheels = [ @@ -2833,7 +2815,7 @@ name = "googleapis-common-protos" version = "1.75.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "protobuf", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "protobuf" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b5/c8/f439cffde755cffa462bfbb156278fa6f9d09119719af9814b858fd4f81f/googleapis_common_protos-1.75.0.tar.gz", hash = "sha256:53a062ff3c32552fbd62c11fe23768b78e4ddf0494d5e5fd97d3f4689c75fbbd", size = 151035, upload-time = "2026-05-07T08:04:49.423Z" } wheels = [ @@ -2845,9 +2827,9 @@ name = "gpustat" version = "1.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "blessed", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "nvidia-ml-py", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "psutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "blessed" }, + { name = "nvidia-ml-py" }, + { name = "psutil" }, ] sdist = { url = "https://files.pythonhosted.org/packages/79/c4/46d005aec3bf911cb030467d91e062a5386ff4a03e51874424cacc0f60c1/gpustat-1.1.1.tar.gz", hash = "sha256:c18d3ed5518fc16300c42d694debc70aebb3be55cae91f1db64d63b5fa8af9d8", size = 98052, upload-time = "2023-08-22T19:39:06.062Z" } @@ -2856,7 +2838,7 @@ name = "granian" version = "2.7.9" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "click", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "click" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b0/cc/9c752e6173df02c5e37c0df7bffd50c1341109e4b4f8e5073bfd3a72dc82/granian-2.7.9.tar.gz", hash = "sha256:096d9a3396b13826bc63d2cf424ed04daf1ea077beed361d48dca2d55cb4b527", size = 129789, upload-time = "2026-07-03T12:42:03.314Z" } wheels = [ @@ -3016,7 +2998,7 @@ name = "grpcio" version = "1.82.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/90/bc/656b89387d6f4ed7e0686c7b64c2ae7e554a759aa58122c8e5fb99392c32/grpcio-1.82.1.tar.gz", hash = "sha256:707b24abd90fcb1e45bcc080577da1dbf9971d107490589b9539af8e1e77b4b5", size = 13187300, upload-time = "2026-07-08T12:36:16.588Z" } wheels = [ @@ -3067,7 +3049,7 @@ name = "gunicorn" version = "23.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "packaging" }, ] sdist = { url = "https://files.pythonhosted.org/packages/34/72/9614c465dc206155d93eff0ca20d42e1e35afc533971379482de953521a4/gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec", size = 375031, upload-time = "2024-08-10T20:25:27.378Z" } wheels = [ @@ -3088,8 +3070,8 @@ name = "h2" version = "4.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "hpack", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "hyperframe", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "hpack" }, + { name = "hyperframe" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" } wheels = [ @@ -3142,8 +3124,8 @@ name = "httpcore" version = "1.0.9" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "certifi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "h11", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "certifi" }, + { name = "h11" }, ] sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } wheels = [ @@ -3198,10 +3180,10 @@ name = "httpx" version = "0.28.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "certifi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "httpcore", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "idna", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } wheels = [ @@ -3210,7 +3192,7 @@ wheels = [ [package.optional-dependencies] http2 = [ - { name = "h2", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "h2" }, ] [[package]] @@ -3227,15 +3209,15 @@ name = "huggingface-hub" version = "1.23.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "click", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "filelock", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "fsspec", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "hf-xet", marker = "(platform_machine == 'AMD64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'darwin') or (platform_machine == 'amd64' and sys_platform == 'darwin') or (platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'darwin') or (platform_machine == 'AMD64' and sys_platform == 'linux') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'amd64' and sys_platform == 'linux') or (platform_machine == 'arm64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'win32') or (platform_machine == 'amd64' and sys_platform == 'win32') or (platform_machine == 'arm64' and sys_platform == 'win32') or (platform_machine == 'x86_64' and sys_platform == 'win32')" }, - { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pyyaml", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "tqdm", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "click" }, + { name = "filelock" }, + { name = "fsspec" }, + { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "tqdm" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1a/8f/999e4dda11c6187c78f090eac00895a47e11a0049308f07579bcb7aa3aa2/huggingface_hub-1.23.0.tar.gz", hash = "sha256:c04997fb8bbdace1e57b7703d30ed7678af51f70d00d241819ff411b92ae9a88", size = 919163, upload-time = "2026-07-09T14:49:32.315Z" } wheels = [ @@ -3247,10 +3229,10 @@ name = "hypercorn" version = "0.18.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "h11", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "h2", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "priority", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "wsproto", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "h11" }, + { name = "h2" }, + { name = "priority" }, + { name = "wsproto" }, ] sdist = { url = "https://files.pythonhosted.org/packages/44/01/39f41a014b83dd5c795217362f2ca9071cf243e6a75bdcd6cd5b944658cc/hypercorn-0.18.0.tar.gz", hash = "sha256:d63267548939c46b0247dc8e5b45a9947590e35e64ee73a23c074aa3cf88e9da", size = 68420, upload-time = "2025-11-08T13:54:04.78Z" } wheels = [ @@ -3311,7 +3293,7 @@ name = "importlib-metadata" version = "8.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "zipp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "zipp" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e7/72/c600ae4f68c28fc19f9c31b9403053e5dbb8cace2e6842c7b7c3e4d42fe9/importlib_metadata-8.9.0.tar.gz", hash = "sha256:58850626cef4bd2df100378b0f2aea9724a7b92f10770d547725b047078f99ee", size = 56140, upload-time = "2026-03-20T16:56:26.362Z" } wheels = [ @@ -3359,7 +3341,7 @@ name = "jinja2" version = "3.1.6" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markupsafe", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "markupsafe" }, ] sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } wheels = [ @@ -3505,10 +3487,10 @@ name = "jsonschema" version = "4.26.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "attrs", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "jsonschema-specifications", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "referencing", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "rpds-py", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } wheels = [ @@ -3520,7 +3502,7 @@ name = "jsonschema-specifications" version = "2025.9.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "referencing", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "referencing" }, ] sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } wheels = [ @@ -3638,14 +3620,14 @@ name = "langfuse" version = "4.13.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "backoff", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-exporter-otlp-proto-http", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "wrapt", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "backoff" }, + { name = "httpx" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-sdk" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "wrapt" }, ] sdist = { url = "https://files.pythonhosted.org/packages/74/00/16324f1c0d244fee3c4e4ac00e8a422f1afb8873e963182dac7fbf8b34e8/langfuse-4.13.2.tar.gz", hash = "sha256:4484c5d949dd1c5cabab74c179c7e5c19be203940852bf90d1ba5870215eac6a", size = 362128, upload-time = "2026-07-08T15:54:07.889Z" } wheels = [ @@ -3732,18 +3714,18 @@ name = "litellm" version = "1.91.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "click", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "fastuuid", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "importlib-metadata", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "jinja2", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "jsonschema", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "python-dotenv", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "tiktoken", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "tokenizers", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "aiohttp" }, + { name = "click" }, + { name = "fastuuid" }, + { name = "httpx" }, + { name = "importlib-metadata" }, + { name = "jinja2" }, + { name = "jsonschema" }, + { name = "openai" }, + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "tiktoken" }, + { name = "tokenizers" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a7/70/1b419158085e0cc1615642dd62c7a5d4c3254db3de77df65dade3b25165b/litellm-1.91.1.tar.gz", hash = "sha256:49a24593df7e37262c52243a8e07572d451d37c100aa1b1f347d80d501d2e386", size = 14872532, upload-time = "2026-07-08T23:07:04.559Z" } wheels = [ @@ -3752,36 +3734,36 @@ wheels = [ [package.optional-dependencies] proxy = [ - { name = "apscheduler", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "azure-identity", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "azure-storage-blob", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "backoff", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "boto3", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "cryptography", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "expression", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "fastapi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "fastapi-sso", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "granian", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "gunicorn", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "litellm-enterprise", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "litellm-proxy-extras", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "mcp", extra = ["ws"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "orjson", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "polars", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pydantic-settings", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pyjwt", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pynacl", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pyroscope-io", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "python-multipart", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pyyaml", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "restrictedpython", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "rich", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "rq", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "soundfile", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "starlette", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "uvicorn", extra = ["standard"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "uvloop", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "websockets", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "apscheduler" }, + { name = "azure-identity" }, + { name = "azure-storage-blob" }, + { name = "backoff" }, + { name = "boto3" }, + { name = "cryptography" }, + { name = "expression" }, + { name = "fastapi" }, + { name = "fastapi-sso" }, + { name = "granian" }, + { name = "gunicorn" }, + { name = "litellm-enterprise" }, + { name = "litellm-proxy-extras" }, + { name = "mcp", extra = ["ws"] }, + { name = "orjson" }, + { name = "polars" }, + { name = "pydantic-settings" }, + { name = "pyjwt" }, + { name = "pynacl" }, + { name = "pyroscope-io", marker = "sys_platform != 'win32'" }, + { name = "python-multipart" }, + { name = "pyyaml" }, + { name = "restrictedpython" }, + { name = "rich" }, + { name = "rq" }, + { name = "soundfile" }, + { name = "starlette" }, + { name = "uvicorn", extra = ["standard"] }, + { name = "uvloop", marker = "sys_platform != 'win32'" }, + { name = "websockets" }, ] [[package]] @@ -3820,7 +3802,7 @@ name = "markdown-it-py" version = "4.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "mdurl", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "mdurl" }, ] sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } wheels = [ @@ -3906,16 +3888,16 @@ name = "matplotlib" version = "3.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "contourpy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "cycler", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "fonttools", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "kiwisolver", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.12' and sys_platform == 'darwin') or (python_full_version < '3.12' and sys_platform == 'linux') or (python_full_version < '3.12' and sys_platform == 'win32')" }, - { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and sys_platform == 'darwin') or (python_full_version >= '3.12' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform == 'win32')" }, - { name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pillow", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pyparsing", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "python-dateutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "contourpy" }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1f/24/080c99d223d158d3a8902769269ab6da5b50f7a0e6e072513907e02b7a6c/matplotlib-3.11.0.tar.gz", hash = "sha256:68c0c7be01b30dcca3638934f7f591df73401235cbdbf0d1ab1c71e7db7f8b57", size = 33251176, upload-time = "2026-06-12T02:29:15.508Z" } wheels = [ @@ -3971,20 +3953,20 @@ name = "mcp" version = "1.28.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "httpx-sse", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "jsonschema", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pydantic-settings", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pyjwt", extra = ["crypto"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "python-multipart", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, { name = "pywin32", marker = "sys_platform == 'win32'" }, - { name = "sse-starlette", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "starlette", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-inspection", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "uvicorn", extra = ["standard"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", extra = ["standard"] }, ] sdist = { url = "https://files.pythonhosted.org/packages/6e/77/9450b8f251a13affb6281997d0523c4615f8a8b35d0b21ff30db3a5aac9d/mcp-1.28.1.tar.gz", hash = "sha256:d51e36a5f5644faea4f85ea649bfffa6bc6c26770d42798ad6a3de3d2ba69683", size = 638501, upload-time = "2026-06-26T12:57:29.093Z" } wheels = [ @@ -3993,7 +3975,7 @@ wheels = [ [package.optional-dependencies] ws = [ - { name = "websockets", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "websockets" }, ] [[package]] @@ -4010,14 +3992,14 @@ name = "mem0ai" version = "2.0.11" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "posthog", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "protobuf", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pytz", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "qdrant-client", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "sqlalchemy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "httpx" }, + { name = "openai" }, + { name = "posthog" }, + { name = "protobuf" }, + { name = "pydantic" }, + { name = "pytz" }, + { name = "qdrant-client" }, + { name = "sqlalchemy" }, ] sdist = { url = "https://files.pythonhosted.org/packages/81/4f/9368c71195cb9a81fe16d5621317938f621f08157a1f89acf8972ea383db/mem0ai-2.0.11.tar.gz", hash = "sha256:bca405548e11c642ee75009134ffa7f69ab41ba3b1f72465816ba5ff0612e18f", size = 237552, upload-time = "2026-07-01T16:58:05.426Z" } wheels = [ @@ -4029,7 +4011,7 @@ name = "microsoft-agents-activity" version = "0.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pydantic" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5a/6a/dfc2fc0316b7dc4f6d24792b4a31a873b026be76792af1e0c3e65f843ef0/microsoft_agents_activity-0.3.1.tar.gz", hash = "sha256:c7567fc30f8e6f2a2d74cd65a1f7f31ade0d7ec9dd94531677d0d7b0648c77ee", size = 44886, upload-time = "2025-09-09T23:19:43.044Z" } wheels = [ @@ -4041,7 +4023,7 @@ name = "microsoft-agents-copilotstudio-client" version = "0.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "microsoft-agents-hosting-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "microsoft-agents-hosting-core" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e9/a5/2381ffd14d6a584f9f7ab80c7b6c634f658ea651b38702eb403c930d8396/microsoft_agents_copilotstudio_client-0.3.1.tar.gz", hash = "sha256:c529209241c9d11b7a6e8696f96a3d43121c10b49e44f00e5066f9cf5256f4f3", size = 5024, upload-time = "2025-09-09T23:19:44.833Z" } wheels = [ @@ -4053,11 +4035,11 @@ name = "microsoft-agents-hosting-core" version = "0.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "isodate", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "microsoft-agents-activity", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pyjwt", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "python-dotenv", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-core" }, + { name = "isodate" }, + { name = "microsoft-agents-activity" }, + { name = "pyjwt" }, + { name = "python-dotenv" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6a/14/a1365e0bab1486c2d16aabeb192ca90715794edf4e68be4815c245884420/microsoft_agents_hosting_core-0.3.1.tar.gz", hash = "sha256:0b76bda10e7a54ff3c86e56cbabaad5ac7a4c2a076c9833af3b2f4c86fa85e89", size = 81137, upload-time = "2025-09-09T23:19:46.73Z" } wheels = [ @@ -4069,30 +4051,30 @@ name = "microsoft-opentelemetry" version = "1.3.5" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "azure-core-tracing-opentelemetry", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "azure-monitor-opentelemetry-exporter", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-exporter-otlp-proto-http", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation-django", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation-fastapi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation-flask", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation-httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation-logging", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation-openai-agents-v2", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation-openai-v2", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation-psycopg2", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation-requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation-urllib", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation-urllib3", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-resource-detector-azure", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-util-genai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pyjwt", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "wrapt", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "aiohttp" }, + { name = "azure-core" }, + { name = "azure-core-tracing-opentelemetry" }, + { name = "azure-monitor-opentelemetry-exporter" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-instrumentation-django" }, + { name = "opentelemetry-instrumentation-fastapi" }, + { name = "opentelemetry-instrumentation-flask" }, + { name = "opentelemetry-instrumentation-httpx" }, + { name = "opentelemetry-instrumentation-logging" }, + { name = "opentelemetry-instrumentation-openai-agents-v2" }, + { name = "opentelemetry-instrumentation-openai-v2" }, + { name = "opentelemetry-instrumentation-psycopg2" }, + { name = "opentelemetry-instrumentation-requests" }, + { name = "opentelemetry-instrumentation-urllib" }, + { name = "opentelemetry-instrumentation-urllib3" }, + { name = "opentelemetry-resource-detector-azure" }, + { name = "opentelemetry-sdk" }, + { name = "opentelemetry-util-genai" }, + { name = "pyjwt" }, + { name = "requests" }, + { name = "wrapt" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a8/68/ce4cd11cc8f86d19b3e9bf1a579fdeb486a246e79f61597f47ffcbcef448/microsoft_opentelemetry-1.3.5.tar.gz", hash = "sha256:099e9b971d3c0a9aab185f1a15f45478748583d79bc00554144cfbb86b7dc127", size = 188900, upload-time = "2026-07-01T19:13:14.693Z" } wheels = [ @@ -4104,16 +4086,16 @@ name = "mistralai" version = "1.12.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "eval-type-backport", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "invoke", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-exporter-otlp-proto-http", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "python-dateutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pyyaml", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-inspection", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "eval-type-backport" }, + { name = "httpx" }, + { name = "invoke" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-sdk" }, + { name = "pydantic" }, + { name = "python-dateutil" }, + { name = "pyyaml" }, + { name = "typing-inspection" }, ] sdist = { url = "https://files.pythonhosted.org/packages/aa/12/c3476c53e907255b5f485f085ba50dd9a84b40fe662e9a888d6ded26fa7b/mistralai-1.12.4.tar.gz", hash = "sha256:e52b53bab58025dcd208eeac13e3c3df5778d4112eeca1f08124096c7738929f", size = 243129, upload-time = "2026-02-20T17:55:13.73Z" } wheels = [ @@ -4125,8 +4107,8 @@ name = "ml-dtypes" version = "0.5.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.12' and sys_platform == 'darwin') or (python_full_version < '3.12' and sys_platform == 'linux') or (python_full_version < '3.12' and sys_platform == 'win32')" }, - { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and sys_platform == 'darwin') or (python_full_version >= '3.12' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform == 'win32')" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0e/4a/c27b42ed9b1c7d13d9ba8b6905dece787d6259152f2309338aed29b2447b/ml_dtypes-0.5.4.tar.gz", hash = "sha256:8ab06a50fb9bf9666dd0fe5dfb4676fa2b0ac0f31ecff72a6c3af8e22c063453", size = 692314, upload-time = "2025-11-17T22:32:31.031Z" } wheels = [ @@ -4176,9 +4158,9 @@ name = "msal" version = "1.37.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cryptography", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pyjwt", extra = ["crypto"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "cryptography" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "requests" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9a/99/d840198ecf6e8057bbc937f129ae940404485d736cda73253bbff9537f01/msal-1.37.0.tar.gz", hash = "sha256:1b1672a33ee467c1d70b341bb16cafd51bb3c817147a95b93263794b03971bec", size = 182444, upload-time = "2026-05-29T19:49:05.561Z" } wheels = [ @@ -4190,7 +4172,7 @@ name = "msal-extensions" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "msal", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "msal" }, ] sdist = { url = "https://files.pythonhosted.org/packages/01/99/5d239b6156eddf761a636bded1118414d161bd6b7b37a9335549ed159396/msal_extensions-1.3.1.tar.gz", hash = "sha256:c5b0fd10f65ef62b5f1d62f4251d51cbcaf003fcedae8c91b040a488614be1a4", size = 23315, upload-time = "2025-03-14T23:51:03.902Z" } wheels = [ @@ -4250,11 +4232,11 @@ name = "msrest" version = "0.7.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "certifi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "isodate", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "requests-oauthlib", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-core" }, + { name = "certifi" }, + { name = "isodate" }, + { name = "requests" }, + { name = "requests-oauthlib" }, ] sdist = { url = "https://files.pythonhosted.org/packages/68/77/8397c8fb8fc257d8ea0fa66f8068e073278c65f05acb17dcb22a02bfdc42/msrest-0.7.1.zip", hash = "sha256:6e7661f46f3afd88b75667b7187a92829924446c7ea1d169be8c4bb7eeb788b9", size = 175332, upload-time = "2022-06-13T22:41:25.111Z" } wheels = [ @@ -4383,11 +4365,11 @@ name = "mypy" version = "2.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ast-serialize", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "librt", marker = "(platform_python_implementation != 'PyPy' and sys_platform == 'darwin') or (platform_python_implementation != 'PyPy' and sys_platform == 'linux') or (platform_python_implementation != 'PyPy' and sys_platform == 'win32')" }, - { name = "mypy-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pathspec", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/12/af/4e516a05d3ca2eb9283e9ec45b2c02225c1514dd6da49fd3c9eaa6639370/mypy-2.3.0.tar.gz", hash = "sha256:465965d41cd9a2726694e983e8ce7113259327bec798115d1e1dfa2a52fb666e", size = 3988104, upload-time = "2026-07-13T11:34:53.387Z" } wheels = [ @@ -4629,8 +4611,8 @@ name = "ollama" version = "0.5.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "httpx" }, + { name = "pydantic" }, ] sdist = { url = "https://files.pythonhosted.org/packages/91/6d/ae96027416dcc2e98c944c050c492789502d7d7c0b95a740f0bb39268632/ollama-0.5.3.tar.gz", hash = "sha256:40b6dff729df3b24e56d4042fd9d37e231cee8e528677e0d085413a1d6692394", size = 43331, upload-time = "2025-08-07T21:44:10.422Z" } wheels = [ @@ -4642,14 +4624,14 @@ name = "openai" version = "2.45.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "distro", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "jiter", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "sniffio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "tqdm", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/78/60/d4219875289b11d2c2f7da93c36283da224a2e55865ed865ab64e0ce9217/openai-2.45.0.tar.gz", hash = "sha256:10d34ca9c5643bce775852fddbfc172505cb1d4de1ccd101696c3ecff358765d", size = 1109653, upload-time = "2026-07-09T18:02:44.091Z" } wheels = [ @@ -4661,13 +4643,13 @@ name = "openai-agents" version = "0.18.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "griffelib", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "mcp", extra = ["ws"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "websockets", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "griffelib" }, + { name = "mcp", extra = ["ws"] }, + { name = "openai" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "typing-extensions" }, + { name = "websockets" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d7/b0/4ee0eda742e395fa48f625a96ef0713c53b49f397f9c5fadcef417d021b3/openai_agents-0.18.1.tar.gz", hash = "sha256:689ad88c8f64435413dde707ca45fb42d55217ff6ef63aab3d638c33f78e04bf", size = 5521444, upload-time = "2026-07-09T23:39:18.907Z" } wheels = [ @@ -4679,11 +4661,11 @@ name = "openai-chatkit" version = "1.6.5" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "jinja2", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "openai-agents", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "uvicorn", extra = ["standard"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "jinja2" }, + { name = "openai" }, + { name = "openai-agents" }, + { name = "pydantic" }, + { name = "uvicorn", extra = ["standard"] }, ] sdist = { url = "https://files.pythonhosted.org/packages/ef/07/c4b4ea034f34f25e73cf1a872deb349e3acab6c929a5531d547a9a994890/openai_chatkit-1.6.5.tar.gz", hash = "sha256:903e9702bf26cd8a2b23d4e7b199b657bee4379758e0ca11ebaee09362d2889e", size = 65057, upload-time = "2026-05-19T05:05:14.954Z" } wheels = [ @@ -4695,7 +4677,7 @@ name = "opentelemetry-api" version = "1.43.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ae/cc/e4c9584181f86494df0f6bdec1a4f3280c50db44704dc2a407e994fc87bb/opentelemetry_api-1.43.0.tar.gz", hash = "sha256:107d0d03857ea8fc7c5fcbbbd83f800c281f0d560553d61c1d675fccfd1761c1", size = 73476, upload-time = "2026-06-24T15:19:55.323Z" } wheels = [ @@ -4707,8 +4689,8 @@ name = "opentelemetry-exporter-otlp" version = "1.43.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-exporter-otlp-proto-grpc", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-exporter-otlp-proto-http", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-exporter-otlp-proto-grpc" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ee/47/b77366bcbe719373a8cac2b4e8ad01f8ff3c9f2c223374d77ece280aae6f/opentelemetry_exporter_otlp-1.43.0.tar.gz", hash = "sha256:65aded6c50ee7dd2b9948c9d0e59ddb4ed4eea6e8532fba95cbe6a4a64a566ba", size = 6086, upload-time = "2026-06-24T15:19:58.003Z" } wheels = [ @@ -4720,7 +4702,7 @@ name = "opentelemetry-exporter-otlp-proto-common" version = "1.43.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-proto", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-proto" }, ] sdist = { url = "https://files.pythonhosted.org/packages/55/c1/e8098490ab15abf116dcaf9fa89ededcb35547c7d08d4b5a62f573dc1e63/opentelemetry_exporter_otlp_proto_common-1.43.0.tar.gz", hash = "sha256:c4e32ba6d6b13bdb2b8f6764c4fd28d00192826561aa04f6d14eedfce7ac076f", size = 20197, upload-time = "2026-06-24T15:20:00.247Z" } wheels = [ @@ -4732,13 +4714,13 @@ name = "opentelemetry-exporter-otlp-proto-grpc" version = "1.43.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "googleapis-common-protos", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "grpcio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-exporter-otlp-proto-common", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-proto", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "googleapis-common-protos" }, + { name = "grpcio" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e2/1d/6336453716ca0a240d4417d19e6d5b77a5e7163e5670ec4f7ec4d3ede7bf/opentelemetry_exporter_otlp_proto_grpc-1.43.0.tar.gz", hash = "sha256:1b3e0627daa9bc21884d4a13946807c255eb558bfe5bdd543dffb6f4c9faee0d", size = 27213, upload-time = "2026-06-24T15:20:00.907Z" } wheels = [ @@ -4750,13 +4732,13 @@ name = "opentelemetry-exporter-otlp-proto-http" version = "1.43.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "googleapis-common-protos", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-exporter-otlp-proto-common", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-proto", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "googleapis-common-protos" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "requests" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fc/92/0b9f56412483a8891d4843890294796c9df8ab42417bd9bad8035d840cb3/opentelemetry_exporter_otlp_proto_http-1.43.0.tar.gz", hash = "sha256:fa8a42bb7d00ee5391f4c0b04d8e6a46c03caa437903296ab73a81dc11ba118f", size = 25406, upload-time = "2026-06-24T15:20:01.515Z" } wheels = [ @@ -4768,10 +4750,10 @@ name = "opentelemetry-instrumentation" version = "0.64b0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "wrapt", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "packaging" }, + { name = "wrapt" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7e/97/02fe6e1c8b1ffac42d0b429c18080edb24e0e0d18c86612edf72b5752382/opentelemetry_instrumentation-0.64b0.tar.gz", hash = "sha256:b47d528dead6271d7743114417eb67fc915bd9258111c48dbf9a4951d2efa88d", size = 41935, upload-time = "2026-06-24T15:19:12.951Z" } wheels = [ @@ -4783,11 +4765,11 @@ name = "opentelemetry-instrumentation-asgi" version = "0.64b0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "asgiref", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-util-http", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "asgiref" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, ] sdist = { url = "https://files.pythonhosted.org/packages/85/0c/71c696fccb86d37af383ea1604af4729fabad0af2fabaf203e4c79c1e859/opentelemetry_instrumentation_asgi-0.64b0.tar.gz", hash = "sha256:4dd3eee566a4303f8e6b9b84f2a0a7abc57a6640df768926c68a3868bf5b2090", size = 26136, upload-time = "2026-06-24T15:19:17.003Z" } wheels = [ @@ -4799,10 +4781,10 @@ name = "opentelemetry-instrumentation-dbapi" version = "0.64b0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "wrapt", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "wrapt" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b9/9f/3c6ce6f4394faa1540f48b7d442f455582ca76449952506ce767a54f09bf/opentelemetry_instrumentation_dbapi-0.64b0.tar.gz", hash = "sha256:8e3a1528fc9753a04190a7e7bf0180c5abd059f3aa68ec3857edb363c9847c44", size = 20138, upload-time = "2026-06-24T15:19:24.097Z" } wheels = [ @@ -4814,11 +4796,11 @@ name = "opentelemetry-instrumentation-django" version = "0.64b0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation-wsgi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-util-http", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-instrumentation-wsgi" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ce/2e/8f6084eab186533b0574683a8c39e51803e6c34c21fc33e194f58352d704/opentelemetry_instrumentation_django-0.64b0.tar.gz", hash = "sha256:3d2673b4f77156b15b1b119c8849b50e3644cfc325f7a24c03602596de2dbba4", size = 25387, upload-time = "2026-06-24T15:19:24.795Z" } wheels = [ @@ -4830,11 +4812,11 @@ name = "opentelemetry-instrumentation-fastapi" version = "0.64b0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation-asgi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-util-http", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-instrumentation-asgi" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, ] sdist = { url = "https://files.pythonhosted.org/packages/db/a1/52282e2cc5c08f4df13b087896d9907258fe2ff4f34035d3b21aa92684c3/opentelemetry_instrumentation_fastapi-0.64b0.tar.gz", hash = "sha256:05f75149929e433c1630de381688e650bf651c1e1cce7f9a7b649a807dac8a98", size = 26236, upload-time = "2026-06-24T15:19:27.219Z" } wheels = [ @@ -4846,12 +4828,12 @@ name = "opentelemetry-instrumentation-flask" version = "0.64b0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation-wsgi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-util-http", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-instrumentation-wsgi" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, + { name = "packaging" }, ] sdist = { url = "https://files.pythonhosted.org/packages/4a/6d/cf9a9cf18603e3aaebba96633ec3c1ced463706f545d8672419b715c1e59/opentelemetry_instrumentation_flask-0.64b0.tar.gz", hash = "sha256:74d07edba426beae1214bd0aec69bd31a9d0d22c29747b497d5d94c516d1c860", size = 24150, upload-time = "2026-06-24T15:19:27.847Z" } wheels = [ @@ -4863,11 +4845,11 @@ name = "opentelemetry-instrumentation-httpx" version = "0.64b0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-util-http", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "wrapt", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, + { name = "wrapt" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0d/2a/2893a8781b93894f1e8014904c0342da7e4302de6597c2d5c0cb6c1a552e/opentelemetry_instrumentation_httpx-0.64b0.tar.gz", hash = "sha256:c2cfcd03d3665762860ebd0c28038c6e47fbb48d7942dec31dd75fc634d25c92", size = 23555, upload-time = "2026-06-24T15:19:29.107Z" } wheels = [ @@ -4879,9 +4861,9 @@ name = "opentelemetry-instrumentation-logging" version = "0.64b0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/88/8e/3b7191ba5817f139867612e960fbe06a69fe0d522d3b2c5e9c8e89b3ef8c/opentelemetry_instrumentation_logging-0.64b0.tar.gz", hash = "sha256:6435b4d215bf8183e15f7e3416a10ebe1c411810d3411fbfc62667daae3abacb", size = 20000, upload-time = "2026-06-24T15:19:30.946Z" } wheels = [ @@ -4893,10 +4875,10 @@ name = "opentelemetry-instrumentation-openai-agents-v2" version = "0.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-util-genai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-genai" }, ] sdist = { url = "https://files.pythonhosted.org/packages/00/15/b6a303454d2800d772cdebc490c1d598d06d0e541619db80195eb9ea85c6/opentelemetry_instrumentation_openai_agents_v2-0.1.0.tar.gz", hash = "sha256:1033f4b261ce07f65d197ac0e9c499302c805eae987a6cc4e7f99bb279363477", size = 22423, upload-time = "2025-10-15T19:04:59.912Z" } wheels = [ @@ -4908,9 +4890,9 @@ name = "opentelemetry-instrumentation-openai-v2" version = "2.3b0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/38/4e/21f8cd16ccb471dd217ed85eb817796a10c4f2718ae2c91e752a57180cf0/opentelemetry_instrumentation_openai_v2-2.3b0.tar.gz", hash = "sha256:5de9d70cc9536eea1fe48ea016e0c5f25735fa9a13709076a64b20657fadb6ba", size = 170838, upload-time = "2025-12-24T13:20:58.33Z" } wheels = [ @@ -4922,9 +4904,9 @@ name = "opentelemetry-instrumentation-psycopg2" version = "0.64b0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation-dbapi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-instrumentation-dbapi" }, ] sdist = { url = "https://files.pythonhosted.org/packages/79/2d/0062ecf86b6bd407398820ae34d8acf7259f2b32531fa22e1c3b7748c6d0/opentelemetry_instrumentation_psycopg2-0.64b0.tar.gz", hash = "sha256:57df449718297b93e7bb57c76d3439c475eb5a5451600fcd6a9024d74822d972", size = 12065, upload-time = "2026-06-24T15:19:33.994Z" } wheels = [ @@ -4936,10 +4918,10 @@ name = "opentelemetry-instrumentation-requests" version = "0.64b0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-util-http", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a1/97/d5fb7b3f329c24ff2f6478f1a0c0c516ba942d3df2acbb197d276af31816/opentelemetry_instrumentation_requests-0.64b0.tar.gz", hash = "sha256:8213a20b6578c41aa05cc5b48419aea75e1584924a4904dfcf36524597e7cc96", size = 18106, upload-time = "2026-06-24T15:19:39.522Z" } wheels = [ @@ -4951,10 +4933,10 @@ name = "opentelemetry-instrumentation-urllib" version = "0.64b0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-util-http", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, ] sdist = { url = "https://files.pythonhosted.org/packages/87/20/547126ab1706e65629c2079cdbb956ef06ccc58a7cbad520c3505886e4cc/opentelemetry_instrumentation_urllib-0.64b0.tar.gz", hash = "sha256:954ab9908f08dc9127ca3d259f88a37b52f54c03f6fa4c384fd9fcfe9c85cd76", size = 16668, upload-time = "2026-06-24T15:19:45.096Z" } wheels = [ @@ -4966,11 +4948,11 @@ name = "opentelemetry-instrumentation-urllib3" version = "0.64b0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-util-http", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "wrapt", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, + { name = "wrapt" }, ] sdist = { url = "https://files.pythonhosted.org/packages/30/e2/9e6f747018f639b9ffed97f715f1eb16c95dd87ba572c880ce451a65a18f/opentelemetry_instrumentation_urllib3-0.64b0.tar.gz", hash = "sha256:647c6d1b012e14168323b18d4e60fca9a5d280764821b99ea0a5639450fbd74e", size = 18945, upload-time = "2026-06-24T15:19:45.738Z" } wheels = [ @@ -4982,10 +4964,10 @@ name = "opentelemetry-instrumentation-wsgi" version = "0.64b0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-util-http", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, ] sdist = { url = "https://files.pythonhosted.org/packages/95/4f/0b5232a0d635eee5da7c85af503d2f00fdcac61cc6d4d4a69aadf1fa7a0e/opentelemetry_instrumentation_wsgi-0.64b0.tar.gz", hash = "sha256:1cce6ea28d1800c154e6ccdf52f05bae2f05c5e28ee44f0dddb17ada26208986", size = 19667, upload-time = "2026-06-24T15:19:46.407Z" } wheels = [ @@ -4997,7 +4979,7 @@ name = "opentelemetry-proto" version = "1.43.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "protobuf", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "protobuf" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e0/b9/d357faefb40bda1d4799913e6af611171ff22a2dedcb93576bc92242d056/opentelemetry_proto-1.43.0.tar.gz", hash = "sha256:224778df17e1f3fafeaaa21d874236ca5f6ffc2f86e0899298ec7351aac27924", size = 46481, upload-time = "2026-06-24T15:20:07.625Z" } wheels = [ @@ -5009,7 +4991,7 @@ name = "opentelemetry-resource-detector-azure" version = "0.1.5" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-sdk" }, ] sdist = { url = "https://files.pythonhosted.org/packages/67/e4/0d359d48d03d447225b30c3dd889d5d454e3b413763ff721f9b0e4ac2e59/opentelemetry_resource_detector_azure-0.1.5.tar.gz", hash = "sha256:e0ba658a87c69eebc806e75398cd0e9f68a8898ea62de99bc1b7083136403710", size = 11503, upload-time = "2024-05-16T21:54:58.994Z" } wheels = [ @@ -5021,9 +5003,9 @@ name = "opentelemetry-sdk" version = "1.43.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3e/eb/5041074274ac0956b03637cc039d434569112468e875eddfcc9a0674ce06/opentelemetry_sdk-1.43.0.tar.gz", hash = "sha256:d8187c81c162df9913e4003dd6485f7390d9a24fc17026ec7387b8b8218b08e9", size = 254744, upload-time = "2026-06-24T15:20:08.467Z" } wheels = [ @@ -5035,8 +5017,8 @@ name = "opentelemetry-semantic-conventions" version = "0.64b0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5a/30/5f26df29509eccd86b99b481ac9ffa39da49ba9577cc69071c552ae30447/opentelemetry_semantic_conventions-0.64b0.tar.gz", hash = "sha256:72f76fb2d1582d9d033dd1fcd84532e961e6ff3d90d24ba6fabc72975a83864c", size = 148340, upload-time = "2026-06-24T15:20:09.267Z" } wheels = [ @@ -5048,9 +5030,9 @@ name = "opentelemetry-util-genai" version = "0.3b0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a2/d8/4dd2fb622d26ec45b10ef63eb87fd512f5d7467c7bd35ce390629bd6dff8/opentelemetry_util_genai-0.3b0.tar.gz", hash = "sha256:83e127789a9ad615b8ca65f05fc36955a67ce257b06142bfd46159a3b7ed73d3", size = 31800, upload-time = "2026-02-20T16:16:14.807Z" } wheels = [ @@ -5080,7 +5062,7 @@ name = "orderedmultidict" version = "1.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "six", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "six" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5c/62/61ad51f6c19d495970230a7747147ce7ed3c3a63c2af4ebfdb1f6d738703/orderedmultidict-1.0.2.tar.gz", hash = "sha256:16a7ae8432e02cc987d2d6d5af2df5938258f87c870675c73ee77a0920e6f4a6", size = 13973, upload-time = "2025-11-18T08:00:42.649Z" } wheels = [ @@ -5178,9 +5160,9 @@ name = "pandas" version = "3.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.12' and sys_platform == 'darwin') or (python_full_version < '3.12' and sys_platform == 'linux') or (python_full_version < '3.12' and sys_platform == 'win32')" }, - { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and sys_platform == 'darwin') or (python_full_version >= '3.12' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform == 'win32')" }, - { name = "python-dateutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "python-dateutil" }, { name = "tzdata", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f8/87/4341c6252d1c47b08768c3d25ac487362bf403f0313ddae4a2a26c9b1b4c/pandas-3.0.3.tar.gz", hash = "sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc", size = 4651414, upload-time = "2026-05-11T18:54:29.21Z" } @@ -5351,8 +5333,8 @@ name = "plotly" version = "6.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "narwhals", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "narwhals" }, + { name = "packaging" }, ] sdist = { url = "https://files.pythonhosted.org/packages/96/07/795c79dbce40c39bece88e69d049babbd23ffa95b5d117f248db8ea03abb/plotly-6.9.0.tar.gz", hash = "sha256:967ad33e8c704fed051800d11d985eb206a9c795c14206b30a6f463ed9c67d0d", size = 6919903, upload-time = "2026-07-09T14:55:59.982Z" } wheels = [ @@ -5373,8 +5355,8 @@ name = "poethepoet" version = "0.48.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pastel", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pyyaml", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pastel" }, + { name = "pyyaml" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5c/92/93a4af9511b8c7c647874521d9e6c904266be98067c2ee1eb2e74520d208/poethepoet-0.48.0.tar.gz", hash = "sha256:a06f49d244fadfc2e2e7faa78b54e64a9694727e4ce1d50e08f23cea3ded74f1", size = 148679, upload-time = "2026-07-05T21:48:30.106Z" } wheels = [ @@ -5386,7 +5368,7 @@ name = "polars" version = "1.42.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "polars-runtime-32", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "polars-runtime-32" }, ] sdist = { url = "https://files.pythonhosted.org/packages/27/99/fe77f10a13a778705ef05b499fc708c9a0b0a3680d9eb6bc6e1b6a6b9914/polars-1.42.1.tar.gz", hash = "sha256:2fe94f3059334650bd850ae19a9c165dcd5d9cb12cd95ea04de2201662e70e8a", size = 741532, upload-time = "2026-06-30T04:57:51.504Z" } wheels = [ @@ -5426,7 +5408,7 @@ name = "portpicker" version = "1.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "psutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "psutil" }, ] sdist = { url = "https://files.pythonhosted.org/packages/4d/d0/cda2fc582f09510c84cd6b7d7b9e22a02d4e45dbad2b2ef1c6edd7847e00/portpicker-1.6.0.tar.gz", hash = "sha256:bd507fd6f96f65ee02781f2e674e9dc6c99bbfa6e3c39992e3916204c9d431fa", size = 25676, upload-time = "2023-08-15T04:37:08.865Z" } wheels = [ @@ -5438,10 +5420,10 @@ name = "posthog" version = "7.22.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "backoff", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "distro", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "backoff" }, + { name = "distro" }, + { name = "requests" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/46/52/edf4ccfe197684d337d163e29ce2eb3cb36a9c8741e6fce3fd2ab8880f1a/posthog-7.22.0.tar.gz", hash = "sha256:b734c83eeb9d9a2fc9e2b8f4f02d13441f6a0baa0ac53e27758608e773d8a01f", size = 328897, upload-time = "2026-07-06T21:44:12.92Z" } wheels = [ @@ -5453,8 +5435,8 @@ name = "powerfx" version = "0.0.34" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cffi", marker = "(python_full_version < '3.14' and sys_platform == 'darwin') or (python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32')" }, - { name = "pythonnet", marker = "(python_full_version < '3.14' and sys_platform == 'darwin') or (python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32')" }, + { name = "cffi" }, + { name = "pythonnet" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9f/fb/6c4bf87e0c74ca1c563921ce89ca1c5785b7576bca932f7255cdf81082a7/powerfx-0.0.34.tar.gz", hash = "sha256:956992e7afd272657ed16d80f4cad24ec95d9e4a79fb9dfa4a068a09e136af32", size = 3237555, upload-time = "2025-12-22T15:50:59.682Z" } wheels = [ @@ -5499,8 +5481,8 @@ name = "prompty" version = "2.0.0b3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiofiles", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pyyaml", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "aiofiles" }, + { name = "pyyaml" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b7/8a/9a846231871a217259c5716ed359a85718821c73f7c017072192d285ab35/prompty-2.0.0b3.tar.gz", hash = "sha256:572167004a66607fdb2c9d509fbc17aca36e8fd8db10502156fb2dbb0d686fae", size = 12298730, upload-time = "2026-06-30T21:57:12.223Z" } wheels = [ @@ -5623,7 +5605,7 @@ name = "proto-plus" version = "1.28.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "protobuf", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "protobuf" }, ] sdist = { url = "https://files.pythonhosted.org/packages/87/44/767757fd2cdd4a60d7e4440d9f7b491d6131103d313638d2c03e06c268fb/proto_plus-1.28.1.tar.gz", hash = "sha256:832e68e7fe064cf90ab153b6e5eb935b27891bb89aaeb68b115e9b702f6cb168", size = 57166, upload-time = "2026-07-08T17:04:02.367Z" } wheels = [ @@ -5717,7 +5699,7 @@ name = "pyasn1-modules" version = "0.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyasn1", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pyasn1" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } wheels = [ @@ -5738,10 +5720,10 @@ name = "pydantic" version = "2.13.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "annotated-types", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pydantic-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-inspection", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, ] sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } wheels = [ @@ -5750,7 +5732,7 @@ wheels = [ [package.optional-dependencies] email = [ - { name = "email-validator", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "email-validator" }, ] [[package]] @@ -5758,7 +5740,7 @@ name = "pydantic-argparse" version = "0.10.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pydantic" }, ] sdist = { url = "https://files.pythonhosted.org/packages/94/ea/e63d587294c20d3b83e9c312b5d577c9ec28962ee8490839ca9996672849/pydantic_argparse-0.10.0.tar.gz", hash = "sha256:d57eb0a84c8f0af6605376157d3f445cfd786700f2e596ba9d48d15d557185eb", size = 15928, upload-time = "2025-02-09T08:18:30.425Z" } wheels = [ @@ -5770,7 +5752,7 @@ name = "pydantic-core" version = "2.46.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } wheels = [ @@ -5872,7 +5854,7 @@ name = "pydantic-monty" version = "0.0.18" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/14/5b/bb6a8bfdf13eb9808c966bdac064a40ce9ac881ec6d64dba3e055888f22b/pydantic_monty-0.0.18.tar.gz", hash = "sha256:c43794c7c4664fa1403d4841459d0e23f01b4f552283db638f5b40ced4dac6a1", size = 1197105, upload-time = "2026-05-29T08:31:41.077Z" } wheels = [ @@ -5931,9 +5913,9 @@ name = "pydantic-settings" version = "2.14.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "python-dotenv", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-inspection", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" } wheels = [ @@ -5960,7 +5942,7 @@ wheels = [ [package.optional-dependencies] crypto = [ - { name = "cryptography", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "cryptography" }, ] [[package]] @@ -5968,7 +5950,7 @@ name = "pynacl" version = "1.6.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cffi", marker = "(platform_python_implementation != 'PyPy' and sys_platform == 'darwin') or (platform_python_implementation != 'PyPy' and sys_platform == 'linux') or (platform_python_implementation != 'PyPy' and sys_platform == 'win32')" }, + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d9/9a/4019b524b03a13438637b11538c82781a5eda427394380381af8f04f467a/pynacl-1.6.2.tar.gz", hash = "sha256:018494d6d696ae03c7e656e5e74cdfd8ea1326962cc401bcf018f1ed8436811c", size = 3511692, upload-time = "2026-01-01T17:48:10.851Z" } wheels = [ @@ -6031,8 +6013,8 @@ name = "pyright" version = "1.1.411" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nodeenv", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "nodeenv" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7e/ab/265f7dc69d28113ebba19092e57b075f41543b2ed048429c5f56e2b88eac/pyright-1.1.411.tar.gz", hash = "sha256:d885a0551f2e763b089a02702174e7f4ba77548cddabc972ab86d1f7f1b0f998", size = 4112861, upload-time = "2026-06-25T02:14:06.37Z" } wheels = [ @@ -6044,7 +6026,7 @@ name = "pyroscope-io" version = "0.8.16" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cffi", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "cffi" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/a8/50/607b38b120ba8adad954119ba512c53590c793f0cf7f009ba6549e4e1d77/pyroscope_io-0.8.16-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:e07edcfd59f5bdce42948b92c9b118c824edbd551730305f095a6b9af401a9e8", size = 3138869, upload-time = "2026-01-22T06:23:24.664Z" }, @@ -6059,10 +6041,10 @@ version = "9.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "iniconfig", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pluggy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pygments", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } wheels = [ @@ -6074,8 +6056,8 @@ name = "pytest-asyncio" version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } wheels = [ @@ -6087,9 +6069,9 @@ name = "pytest-cov" version = "7.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "coverage", extra = ["toml"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pluggy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } wheels = [ @@ -6101,7 +6083,7 @@ name = "pytest-retry" version = "1.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pytest" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c5/5b/607b017994cca28de3a1ad22a3eee8418e5d428dcd8ec25b26b18e995a73/pytest_retry-1.7.0.tar.gz", hash = "sha256:f8d52339f01e949df47c11ba9ee8d5b362f5824dff580d3870ec9ae0057df80f", size = 19977, upload-time = "2025-01-19T01:56:13.115Z" } wheels = [ @@ -6113,7 +6095,7 @@ name = "pytest-timeout" version = "2.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pytest" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973, upload-time = "2025-05-05T19:44:34.99Z" } wheels = [ @@ -6125,8 +6107,8 @@ name = "pytest-xdist" version = "3.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "execnet", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "execnet" }, + { name = "pytest" }, ] sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } wheels = [ @@ -6135,7 +6117,7 @@ wheels = [ [package.optional-dependencies] psutil = [ - { name = "psutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "psutil" }, ] [[package]] @@ -6143,7 +6125,7 @@ name = "python-dateutil" version = "2.9.0.post0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "six", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "six" }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } wheels = [ @@ -6182,7 +6164,7 @@ name = "pythonnet" version = "3.0.5" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "clr-loader", marker = "(python_full_version < '3.14' and sys_platform == 'darwin') or (python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32')" }, + { name = "clr-loader" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9a/d6/1afd75edd932306ae9bd2c2d961d603dc2b52fcec51b04afea464f1f6646/pythonnet-3.0.5.tar.gz", hash = "sha256:48e43ca463941b3608b32b4e236db92d8d40db4c58a75ace902985f76dac21cf", size = 239212, upload-time = "2024-12-13T08:30:44.393Z" } wheels = [ @@ -6280,14 +6262,14 @@ name = "qdrant-client" version = "1.18.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "grpcio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "httpx", extra = ["http2"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.12' and sys_platform == 'darwin') or (python_full_version < '3.12' and sys_platform == 'linux') or (python_full_version < '3.12' and sys_platform == 'win32')" }, - { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and sys_platform == 'darwin') or (python_full_version >= '3.12' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform == 'win32')" }, - { name = "portalocker", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "protobuf", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "urllib3", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "grpcio" }, + { name = "httpx", extra = ["http2"] }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "portalocker" }, + { name = "protobuf" }, + { name = "pydantic" }, + { name = "urllib3" }, ] sdist = { url = "https://files.pythonhosted.org/packages/65/45/5b1bdd15a3c7730eefb9c113600829e20d689b82b5a23f9e07d107094004/qdrant_client-1.18.0.tar.gz", hash = "sha256:52e8ece1a7d40519801bf0b70713bfa0f6b7ae28c7275bbe0b0286fbed7f6db4", size = 352580, upload-time = "2026-05-11T14:12:38.702Z" } wheels = [ @@ -6299,7 +6281,7 @@ name = "redis" version = "7.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "async-timeout", marker = "(python_full_version < '3.11.3' and sys_platform == 'darwin') or (python_full_version < '3.11.3' and sys_platform == 'linux') or (python_full_version < '3.11.3' and sys_platform == 'win32')" }, + { name = "async-timeout", marker = "python_full_version < '3.11.3'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f7/80/2971931d27651affa88a44c0ad7b8c4a19dc29c998abb20b23868d319b59/redis-7.1.1.tar.gz", hash = "sha256:a2814b2bda15b39dad11391cc48edac4697214a8a5a4bd10abe936ab4892eb43", size = 4800064, upload-time = "2026-02-09T18:39:40.292Z" } wheels = [ @@ -6311,15 +6293,15 @@ name = "redisvl" version = "0.15.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "jsonpath-ng", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "ml-dtypes", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.12' and sys_platform == 'darwin') or (python_full_version < '3.12' and sys_platform == 'linux') or (python_full_version < '3.12' and sys_platform == 'win32')" }, - { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and sys_platform == 'darwin') or (python_full_version >= '3.12' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform == 'win32')" }, - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "python-ulid", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pyyaml", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "redis", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "tenacity", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "jsonpath-ng" }, + { name = "ml-dtypes" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "pydantic" }, + { name = "python-ulid" }, + { name = "pyyaml" }, + { name = "redis" }, + { name = "tenacity" }, ] sdist = { url = "https://files.pythonhosted.org/packages/72/1a/f1f0ff963622c34a9e9a9f2a0c6ad82bfbd05c082ecc89e38e092e3e9069/redisvl-0.15.0.tar.gz", hash = "sha256:0e382e9b6cd8378dfe1515b18f92d125cfba905f6f3c5fe9b8904b3ca840d1ca", size = 861480, upload-time = "2026-02-27T14:02:33.366Z" } wheels = [ @@ -6331,9 +6313,9 @@ name = "referencing" version = "0.37.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "attrs", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "rpds-py", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" }, + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } wheels = [ @@ -6449,10 +6431,10 @@ name = "requests" version = "2.34.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "certifi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "charset-normalizer", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "idna", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "urllib3", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } wheels = [ @@ -6464,8 +6446,8 @@ name = "requests-oauthlib" version = "2.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "oauthlib", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "oauthlib" }, + { name = "requests" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650, upload-time = "2024-03-22T20:32:29.939Z" } wheels = [ @@ -6486,8 +6468,8 @@ name = "rich" version = "13.9.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markdown-it-py", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pygments", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "markdown-it-py" }, + { name = "pygments" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149, upload-time = "2024-11-01T16:43:57.873Z" } wheels = [ @@ -6622,9 +6604,9 @@ name = "rq" version = "2.10.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "click", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "croniter", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "redis", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "click" }, + { name = "croniter" }, + { name = "redis" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cc/a8/7bf65cda593feb5888214a4d1e64aae6fc5eb0bbbb74df922c916099a3e5/rq-2.10.0.tar.gz", hash = "sha256:2d8c533dd27500fedabec06295f18db595966e4f22744e6988fe31155b8f7a21", size = 754610, upload-time = "2026-06-20T03:11:45.919Z" } wheels = [ @@ -6661,7 +6643,7 @@ name = "s3transfer" version = "0.19.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "botocore", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "botocore" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f6/94/dcdaeb1713cab9c84def276cfac7388b17c7d9855bbcfe88d77e4dbafd44/s3transfer-0.19.0.tar.gz", hash = "sha256:ce436931687addc4c1712d52d40b32f53e88315723f107ffa20ba82b05a0f685", size = 165171, upload-time = "2026-06-16T19:44:51.599Z" } wheels = [ @@ -6673,13 +6655,13 @@ name = "scikit-learn" version = "1.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "joblib", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "narwhals", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.12' and sys_platform == 'darwin') or (python_full_version < '3.12' and sys_platform == 'linux') or (python_full_version < '3.12' and sys_platform == 'win32')" }, - { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and sys_platform == 'darwin') or (python_full_version >= '3.12' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform == 'win32')" }, - { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.12' and sys_platform == 'darwin') or (python_full_version < '3.12' and sys_platform == 'linux') or (python_full_version < '3.12' and sys_platform == 'win32')" }, - { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and sys_platform == 'darwin') or (python_full_version >= '3.12' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform == 'win32')" }, - { name = "threadpoolctl", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "joblib" }, + { name = "narwhals" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "threadpoolctl" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fa/6f/37092bdb25f712817231799fc5674d8e704066a8a70c1d2d40517e18b4ab/scikit_learn-1.9.0.tar.gz", hash = "sha256:8833266989d3a5110178a9fae30783675460724d0e1efb13b14901d2c660c557", size = 7750767, upload-time = "2026-06-02T11:54:32.706Z" } wheels = [ @@ -6725,7 +6707,7 @@ resolution-markers = [ "python_full_version < '3.12' and sys_platform == 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.12' and sys_platform == 'darwin') or (python_full_version < '3.12' and sys_platform == 'linux') or (python_full_version < '3.12' and sys_platform == 'win32')" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } wheels = [ @@ -6810,7 +6792,7 @@ resolution-markers = [ "python_full_version == '3.12.*' and sys_platform == 'win32'", ] dependencies = [ - { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and sys_platform == 'darwin') or (python_full_version >= '3.12' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform == 'win32')" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" } wheels = [ @@ -6861,10 +6843,10 @@ name = "seaborn" version = "0.13.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "matplotlib", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.12' and sys_platform == 'darwin') or (python_full_version < '3.12' and sys_platform == 'linux') or (python_full_version < '3.12' and sys_platform == 'win32')" }, - { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and sys_platform == 'darwin') or (python_full_version >= '3.12' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform == 'win32')" }, - { name = "pandas", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "matplotlib" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "pandas" }, ] sdist = { url = "https://files.pythonhosted.org/packages/86/59/a451d7420a77ab0b98f7affa3a1d78a313d2f7281a57afb1a34bae8ab412/seaborn-0.13.2.tar.gz", hash = "sha256:93e60a40988f4d65e9f4885df477e2fdaff6b73a9ded434c1ab356dd57eefff7", size = 1457696, upload-time = "2024-01-25T13:21:52.551Z" } wheels = [ @@ -6974,10 +6956,10 @@ name = "soundfile" version = "0.14.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cffi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.12' and sys_platform == 'darwin') or (python_full_version < '3.12' and sys_platform == 'linux') or (python_full_version < '3.12' and sys_platform == 'win32')" }, - { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and sys_platform == 'darwin') or (python_full_version >= '3.12' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform == 'win32')" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "cffi" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d2/db/949331952a6fb1c5b12e9de80fd08747966c2039d1a61db4764fbd3981c2/soundfile-0.14.0.tar.gz", hash = "sha256:ba1c1a2d618bca5c406647c83b89f07cc8810fa506a50622a6993ba130c1de11", size = 47842, upload-time = "2026-06-06T08:58:47.869Z" } wheels = [ @@ -6996,8 +6978,8 @@ name = "sqlalchemy" version = "2.0.51" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "greenlet", marker = "(platform_machine == 'AMD64' and sys_platform == 'darwin') or (platform_machine == 'WIN32' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'darwin') or (platform_machine == 'amd64' and sys_platform == 'darwin') or (platform_machine == 'ppc64le' and sys_platform == 'darwin') or (platform_machine == 'win32' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'darwin') or (platform_machine == 'AMD64' and sys_platform == 'linux') or (platform_machine == 'WIN32' and sys_platform == 'linux') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'amd64' and sys_platform == 'linux') or (platform_machine == 'ppc64le' and sys_platform == 'linux') or (platform_machine == 'win32' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'WIN32' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'win32') or (platform_machine == 'amd64' and sys_platform == 'win32') or (platform_machine == 'ppc64le' and sys_platform == 'win32') or (platform_machine == 'win32' and sys_platform == 'win32') or (platform_machine == 'x86_64' and sys_platform == 'win32')" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/02/f1/a7a892f18d4d224e6b26f706531eafccc41e37594d37d304786969ee13cb/sqlalchemy-2.0.51.tar.gz", hash = "sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9", size = 9912201, upload-time = "2026-06-15T15:41:20.012Z" } wheels = [ @@ -7044,8 +7026,8 @@ name = "sse-starlette" version = "3.4.5" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "starlette", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "anyio" }, + { name = "starlette" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d2/1b/bc9e3e7a72dcdad7dc7888758f5d00f56f8909ed5cfdff822bd72bb4c520/sse_starlette-3.4.5.tar.gz", hash = "sha256:83072538bc211a2f68b7b0422226c4af3e9b62e106e07034664b832ca019842a", size = 35249, upload-time = "2026-06-20T17:36:58.322Z" } wheels = [ @@ -7057,8 +7039,8 @@ name = "starlette" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" }, + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } wheels = [ @@ -7070,7 +7052,7 @@ name = "sympy" version = "1.14.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "mpmath", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "mpmath" }, ] sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } wheels = [ @@ -7091,31 +7073,31 @@ name = "tau2" version = "0.0.1" source = { git = "https://github.com/sierra-research/tau2-bench?rev=5ba9e3e56db57c5e4114bf7f901291f09b2c5619#5ba9e3e56db57c5e4114bf7f901291f09b2c5619" } dependencies = [ - { name = "addict", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "deepdiff", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "docstring-parser", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "fastapi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "fs", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "langfuse", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "litellm", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "loguru", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "matplotlib", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pandas", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "plotly", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "psutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pydantic-argparse", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pyyaml", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "redis", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "rich", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "ruff", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "scikit-learn", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "seaborn", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "tabulate", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "tenacity", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "toml", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "uvicorn", extra = ["standard"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "watchdog", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "addict" }, + { name = "deepdiff" }, + { name = "docstring-parser" }, + { name = "fastapi" }, + { name = "fs" }, + { name = "langfuse" }, + { name = "litellm" }, + { name = "loguru" }, + { name = "matplotlib" }, + { name = "pandas" }, + { name = "plotly" }, + { name = "psutil" }, + { name = "pydantic-argparse" }, + { name = "pytest" }, + { name = "pyyaml" }, + { name = "redis" }, + { name = "rich" }, + { name = "ruff" }, + { name = "scikit-learn" }, + { name = "seaborn" }, + { name = "tabulate" }, + { name = "tenacity" }, + { name = "toml" }, + { name = "uvicorn", extra = ["standard"] }, + { name = "watchdog" }, ] [[package]] @@ -7150,8 +7132,8 @@ name = "tiktoken" version = "0.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "regex", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "regex" }, + { name = "requests" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e4/e5/5f3cb2159769d0f4324c0e9e87f9de3c4b1cd45848a96b2eb3566ad5ca77/tiktoken-0.13.0.tar.gz", hash = "sha256:c9435714c3a84c2319499de9a300c0e604449dd0799ff246458b3bb6a7f433c1", size = 38986, upload-time = "2026-05-15T04:51:27.153Z" } wheels = [ @@ -7204,7 +7186,7 @@ name = "tokenizers" version = "0.23.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "huggingface-hub", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "huggingface-hub" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c1/60/21f715d9faba5f5407ff759472ade058ec4a507ad62bcea47cb847239a73/tokenizers-0.23.1.tar.gz", hash = "sha256:1feeeadf865a7915adc25445dea30e9933e593c31bb96c277cee36de227c8bfa", size = 365748, upload-time = "2026-04-27T14:43:25.606Z" } wheels = [ @@ -7335,15 +7317,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bd/a2/83a496d717f712f894cf26690bcd9465ca23cd1c9139cac669d9cca45d14/ty-0.0.60-py3-none-win_arm64.whl", hash = "sha256:32e63228c3d21ddb192385aaf54501f19478be7b764f7572014d5110e53a9085", size = 11620140, upload-time = "2026-07-16T10:18:11.316Z" }, ] -[[package]] -name = "types-python-dateutil" -version = "2.9.0.20260716" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/df/9a/aefb9f80ff79641d36149352afe66ca835c5e6894deb518418f786f4b8ad/types_python_dateutil-2.9.0.20260716.tar.gz", hash = "sha256:1d55d1c3024bdb4861bb6a6622c9ec800c433d87bdc5b16fb84cdd0eed4ef2cb", size = 17516, upload-time = "2026-07-16T04:48:06.604Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/16/85/cff97326a81e7e8877803e78e3ea56c840b85bb811934b9c936f7c40f185/types_python_dateutil-2.9.0.20260716-py3-none-any.whl", hash = "sha256:1ae41d51a5c5f6bbeeb7f1f34df7086d55ff1605cf88d2ed71a7a276e1a7794e", size = 18443, upload-time = "2026-07-16T04:48:05.793Z" }, -] - [[package]] name = "types-pyyaml" version = "6.0.12.20260518" @@ -7367,7 +7340,7 @@ name = "typing-inspection" version = "0.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } wheels = [ @@ -7435,8 +7408,8 @@ name = "uvicorn" version = "0.51.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "click", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "h11", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "click" }, + { name = "h11" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a2/65/b7c6c443ccc58678c91e1e973bbe2a878591538655d6e1d47f24ba1c51f3/uvicorn-0.51.0.tar.gz", hash = "sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0", size = 94412, upload-time = "2026-07-08T10:59:05.962Z" } wheels = [ @@ -7445,12 +7418,12 @@ wheels = [ [package.optional-dependencies] standard = [ - { name = "httptools", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "python-dotenv", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pyyaml", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "uvloop", marker = "(platform_python_implementation != 'PyPy' and sys_platform == 'darwin') or (platform_python_implementation != 'PyPy' and sys_platform == 'linux')" }, - { name = "watchfiles", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "websockets", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, ] [[package]] @@ -7458,8 +7431,8 @@ name = "uvicorn-worker" version = "0.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "gunicorn", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "uvicorn", extra = ["standard"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "gunicorn" }, + { name = "uvicorn", extra = ["standard"] }, ] sdist = { url = "https://files.pythonhosted.org/packages/80/59/9101b9c0680fd80e9d26c07deb822a5d18a324339fcf9cd017885ee808ad/uvicorn_worker-0.4.0.tar.gz", hash = "sha256:8ee5306070d8f38dce124adce488c3c0b50f20cf0c0222b12c66188da7214493", size = 9361, upload-time = "2025-09-20T10:47:01.218Z" } wheels = [ @@ -7536,7 +7509,7 @@ name = "watchfiles" version = "1.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "anyio" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } wheels = [ @@ -7691,7 +7664,7 @@ name = "werkzeug" version = "3.1.8" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markupsafe", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "markupsafe" }, ] sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" } wheels = [ @@ -7771,7 +7744,7 @@ name = "wsproto" version = "1.3.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "h11", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "h11" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116, upload-time = "2025-11-20T18:18:01.871Z" } wheels = [ @@ -7783,9 +7756,9 @@ name = "yarl" version = "1.24.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "idna", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "multidict", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "propcache", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, ] sdist = { url = "https://files.pythonhosted.org/packages/79/12/1e8f37460ea0f7eb59c221fdaf0ed75e7ac43e97f8093b9c6f411df50a78/yarl-1.24.2.tar.gz", hash = "sha256:9ac374123c6fd7abf64d1fec93962b0bd4ee2c19751755a762a72dd96c0378f8", size = 210798, upload-time = "2026-05-19T21:31:05.599Z" } wheels = [ diff --git a/schemas/durable-agent-entity-state.json b/schemas/durable-agent-entity-state.json deleted file mode 100644 index 50b4e0d2b06..00000000000 --- a/schemas/durable-agent-entity-state.json +++ /dev/null @@ -1,217 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/microsoft/agent-framework/schemas/durable-agent-entity-state.json", - "$defs": { - "usage": { - "type": "object", - "description": "Token usage statistics.", - "properties": { - "inputTokenCount": { "type": "integer" }, - "outputTokenCount": { "type": "integer" }, - "totalTokenCount": { "type": "integer" } - } - }, - "dataContent": { - "type": "object", - "description": "The content of a message exchanged with the agent.", - "properties": { - "$type": { "type": "string", "const": "data" }, - "uri": { "type": "string", "description": "The URI that comprises the data." }, - "mediaType": { "type": "string", "description": "The media type of the data." } - }, - "required": ["$type", "uri"] - }, - "errorContent": { - "type": "object", - "description": "The error content of a message exchanged with the agent.", - "properties": { - "$type": { "type": "string", "const": "error" }, - "message": { "type": "string", "description": "The error message." }, - "errorCode": { "type": "string", "description": "The error code." }, - "details": { "description": "Additional details about the error." } - }, - "required": ["$type"] - }, - "hostedFileContent": { - "type": "object", - "description": "The hosted file content of a message exchanged with the agent.", - "properties": { - "$type": { "type": "string", "const": "hostedFile" }, - "fileId": { "type": "string", "description": "The identifier of the hosted file." } - }, - "required": ["$type", "fileId"] - }, - "hostedVectorStoreContent": { - "type": "object", - "description": "The hosted vector store content of a message exchanged with the agent.", - "properties": { - "$type": { "type": "string", "const": "hostedVectorStore" }, - "vectorStoreId": { "type": "string", "description": "The identifier of the hosted vector store." } - }, - "required": ["$type", "vectorStoreId"] - }, - "textReasoningContent": { - "type": "object", - "description": "The reasoning content of a message exchanged with the agent.", - "properties": { - "$type": { "type": "string", "const": "reasoning" }, - "text": { "type": "string", "description": "The reasoning text." } - }, - "required": ["$type"] - }, - "uriContent": { - "type": "object", - "description": "The URI content of a message exchanged with the agent.", - "properties": { - "$type": { "type": "string", "const": "uri" }, - "uri": { "type": "string", "description": "The URI." }, - "mediaType": { "type": "string", "description": "The media type of the URI." } - }, - "required": ["$type", "uri", "mediaType"] - }, - "usageContent": { - "type": "object", - "description": "The usage content of a message exchanged with the agent.", - "properties": { - "$type": { "type": "string", "const": "usage" }, - "usage": { "$ref": "#/$defs/usage" } - }, - "required": ["$type", "usage"] - }, - "textContent": { - "type": "object", - "description": "The text content of a message exchanged with the agent.", - "properties": { - "$type": { "type": "string", "const": "text" }, - "text": { "type": "string", "description": "The text content of the message." } - }, - "required": ["$type", "text"] - }, - "functionCallContent": { - "type": "object", - "description": "The function call content of a message exchanged with the agent.", - "properties": { - "$type": { "type": "string", "const": "functionCall" }, - "callId": { "type": "string", "description": "The identifier of the function being called." }, - "name": { "type": "string", "description": "The name of the function being called." }, - "arguments": { "type": "object", "description": "The arguments provided to the function call." } - }, - "required": ["$type", "callId", "name"] - }, - "functionResultContent": { - "type": "object", - "description": "The function result content of a message exchanged with the agent.", - "properties": { - "$type": { "type": "string", "const": "functionResult" }, - "callId": { "type": "string", "description": "The identifier of the function being called." }, - "result": { "description": "The result returned by the function call." } - }, - "required": ["$type", "callId"] - }, - "unknownContent": { - "type": "object", - "description": "The unknown content of a message exchanged with the agent.", - "properties": { - "$type": { "type": "string", "const": "unknown" }, - "content": { "description": "The unknown message content serialized as JSON." } - }, - "required": ["$type", "content"] - }, - "chatContentItem": { - "oneOf": [ - { "$ref": "#/$defs/dataContent" }, - { "$ref": "#/$defs/errorContent" }, - { "$ref": "#/$defs/functionCallContent" }, - { "$ref": "#/$defs/functionResultContent" }, - { "$ref": "#/$defs/hostedFileContent" }, - { "$ref": "#/$defs/hostedVectorStoreContent" }, - { "$ref": "#/$defs/usageContent" }, - { "$ref": "#/$defs/textContent" }, - { "$ref": "#/$defs/textReasoningContent" }, - { "$ref": "#/$defs/uriContent" }, - { "$ref": "#/$defs/unknownContent" } - ] - }, - "chatMessage": { - "type": "object", - "description": "Single chat message exchanged with the agent.", - "properties": { - "authorName": { "type": "string", "description": "The name of the author of the message." }, - "role": { "type": "string", "enum": ["user", "assistant", "system", "tool"] }, - "contents": { - "type": "array", - "items": { "$ref": "#/$defs/chatContentItem" } - }, - "createdAt": { "type": "string", "format": "date-time", "description": "When this message was created (RFC 3339)." } - }, - "required": ["role"] - }, - "chatMessages": { - "type": "array", - "description": "Ordered list of chat messages.", - "items": { "$ref": "#/$defs/chatMessage" } - }, - "conversationEntry": { - "type": "object", - "properties": { - "createdAt": { "type": "string", "format": "date-time", "description": "When this exchange was created (RFC 3339)." }, - "correlationId": { "type": "string", "description": "An optional correlation ID to group related exchanges." }, - "messages": { "$ref": "#/$defs/chatMessages" } - } - }, - "agentRequest": { - "allOf": [ - { "$ref": "#/$defs/conversationEntry" } - ], - "description": "The request (i.e. prompt) sent to the agent.", - "properties": { - "$type": { "type": "string", "const": "request" }, - "orchestrationId": { - "type": "string", - "description": "The identifier of the orchestration that initiated this agent request (if any)." - }, - "responseSchema": { - "type": "object", - "description": "If the expected response type is JSON, this schema defines the expected structure of the response." - }, - "responseType": { - "type": "string", - "description": "The expected type of the response (e.g., 'text', 'json')." - } - } - }, - "agentResponse": { - "allOf": [ - { "$ref": "#/$defs/conversationEntry" } - ], - "description": "The response received from the agent.", - "properties": { - "$type": { "type": "string", "const": "response" }, - "usage": { - "$ref": "#/$defs/usage" - } - } - }, - "data": { - "type": "object", - "description": "The durable agent's state data.", - "properties": { - "conversationHistory": { - "type": "array", - "description": "Ordered list of conversation entries.", - "items": { "$ref": "#/$defs/conversationEntry" } - } - } - } - }, - "type": "object", - "properties": { - "schemaVersion": { - "type": "string", - "description": "Semantic version of this state schema. By convention, this should be the first property.", - "pattern": "^\\d+\\.\\d+\\.\\d+$" - }, - "data": { "$ref": "#/$defs/data" } - }, - "required": ["schemaVersion", "data"] -}